@khorsheed/dsh-local-agent-dsh 0.2.0-rc.1 → 0.2.0-rc.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.0-rc.3(2026-10-22)
4
+
5
+ (版本号说明:家族的 dsh-headless 包 0.2.0-rc.2 曾被发布后又撤回,npm 永久烧掉该版本号;为守住家族「同一版本线」纪律(check:release-groups),本波家族整体上移 rc.3,内容即 rc.2 计划稿。)
6
+
7
+ - **本机 DSH 委派继承父实例所选模型路线**:子 dsh 的模型目录与默认选择本就来自父实例,但此前独立 headless profile 只收到 DeepSeek 凭据——父实例选中 Kimi 等官方 pi-ai 路线时以 `NO_ADAPTER` 失败,live 镜像又把原生错误改写成 `UNKNOWN` 掩盖原因。现在本地 exec 与常驻启动经公开 settings 服务快照所选路线的有效配置(原生 DeepSeek,以及显式配置了 API-key 引用的 pi-ai 路线),只复制选中的路线、只解析它声明的凭据;凭据经启动环境传递,不进 argv 与 profile 文件。模型目录隐藏没有继承合同的适配器并标记目录不完整;原生失败保留原始错误码与消息。配置或 key 变化下一轮即生效,无需重启父实例。
8
+ - **设置面并入「本机 Agent 委派」家族卡**:家族核提供 `localAgentUi` 注册表时,本包设置界面作为家族卡的一节渲染(Plugins 页从四张 per-harness 卡收拢为一张);旧 core 或无 core 时回落为本包独立的 `plugins.item` 卡,行为不变。独立卡本身从家族折叠后失效的 keyed 席位改挂 `plugins.item`、按宿主服务的命名空间门控——设置卡在 0.1.7-alpha.1+ 宿主的各种安装形态(独立安装、家族 bundle、模式包)下恢复且一致,0.1.5 与 0.1.6-alpha.2 不再渲染(已记入 README Compatibility 与 `dsh.compat.notes`)。插件管理器与设置卡标题统一为「本机 DSH 委派 / Local DSH Delegation」。
9
+ - **评测子 dsh 的 preset 交付改为拷进 scope 的包(宿主 0.2.0)**:0.2.0 的 preset 只以声明式 loader 行存在——provisioning 在子 profile 追加 agent-preset-registry 的 `default` 行,preset 定义是实例直接依赖里的一个包(声明 `dsh.bundle.patch` 的形态),逐字节镜像到 `<scope>/preset-packages/<id>` 并以相对软链挂进子 profile;0.1.5 的目录形态与 roster 行不再写也不再认。宿主 profile 改为**拷贝** headless bundle 而非软链——pack 过的 bundle 被 0.2.0 归为 linked root,startup/runner 的裸导入失败、子 dsh 挂住不出会话(3185 实例探针 420 秒超时实证),拷贝后同一份组合走到 `MISSING_CREDENTIAL`(探针无 key 的预期终点)。新增配置键 `unitHeadlessBundleDir` 写单元里的 headless 路径:写了之后 provisioning 写孪生子 profile(`<name>` 走本实例 bundle、`<name>-unit` 走单元路径),带 `exec` 的轮次选后者,宿主轮不再依赖机器级 `/opt/dsh-headless` 软链。
10
+ - **适配宿主 0.2.1(host-021)**:委派迁移到 `SubagentRuntime.startActivation`(0.2.1 删除 `ctx.subagents.start()`)——resume 句柄、工具结果文本、Remote 推送与 stop/cancel 语义等模型面行为不变,run id 改为每次执行唯一。`engines.dsh` 声明为 `>=0.1.5-rc.1 <0.3.0-0`(声明即独占兼容裁决,未验证的宿主线安装期 fail-closed);invariant 伴随件随官方删除 invariants 体系整体退役(harness 契约审计探针是记录在案的接受损失)。`@deepseek-ai/dsh-*` peer 区间加宽覆盖 0.2.1 线(semver 预发布只覆盖比较子点名的三元组,原区间在严格 npm/pnpm 解析下不含 0.2.1-rc.1);`dsh.compat.verifiedHost` 前移至 0.2.1-rc.1(host-021 波对 0.2.1-rc.1 树全量构建+测试绿,并经 3080 生产验收)。
11
+ - **修复发布 tarball 缺 js-yaml**:`lib/` 裸导入 js-yaml 而 pack-dist 剥掉 `dependencies`,实例内由宿主解析无事,独立运行产物则以 `ERR_MODULE_NOT_FOUND` 死。js-yaml 现声明进 `dsh.runtimeDependencies`,dist manifest 会携带并安装它。
12
+
3
13
  ## 0.2.0-rc.1(2026-09-30)
4
14
 
5
15
  - **家族折叠:`subagent_dsh` 改挂家族核的 `./tool-factory`**。开关 ON 时的动态挂载从已退役的 `@khorsheed/dsh-local-agent-tool-subagent` 包改为 `@khorsheed/dsh-local-agent/tool-factory` 的 `mountSubagentDelegationTool`(内联插件对象,owner 传本包包名),行为与生命周期不变(设置开关独占挂载/卸载)。依赖同步移除。
package/README.en.md CHANGED
@@ -4,18 +4,18 @@ English | [中文](README.md)
4
4
 
5
5
  Delegate a task to dsh itself — a headless sub-dsh in its own scoped home, continuable across rounds, sibling to the kimi / codex / claude-code harnesses.
6
6
 
7
- Some subtasks deserve a full, isolated dsh instance: an evaluation control, its own session history, its own permission boundary — without ever touching the current instance's state. This plugin spawns a sub-dsh headless CLI: it runs under its own scoped home (profile, sessions, and state fully separate), authenticates through the parent's DeepSeek API key, and takes a caller-supplied session id it can resume across rounds. Nothing mounts by default — flip the DeepSeek delegation switch on the settings card and the model gets the `subagent_dsh` tool.
7
+ Some subtasks deserve a full, isolated dsh instance: an evaluation control, its own session history, its own permission boundary — without ever touching the current instance's state. This plugin spawns a sub-dsh headless CLI: it runs under its own scoped home (profile, sessions, and state fully separate), inherits the selected API-key model route from the parent, and takes a caller-supplied session id it can resume across rounds. Nothing mounts by default — flip the DeepSeek delegation switch on the settings card and the model gets the `subagent_dsh` tool.
8
8
 
9
9
  ## Features
10
10
 
11
11
  - **Delegate to dsh itself** — spawns a sub-dsh headless CLI process: one-shot exec (the default), or the live driver with one resident `--serve` process per member (`live: true` — runtime-level graceful interrupt, push-mode mirroring).
12
12
  - **Scoped-home isolation** — the sub-dsh's own `DSH_HOME` (`$DSH_HOME/local-agent/dsh`): profile, sessions, and state never mixed into the parent.
13
13
  - **Resume across rounds** — the child session id is caller-supplied (`session-<uuid>`, never parsed from stdout); pass it back and the same sub-dsh session continues via `--resume`.
14
- - **No separate login** — the sub-dsh authenticates through the parent's `DEEPSEEK_API_KEY`; no device-code flow. `/dsh status` reports whether the credential resolves.
14
+ - **No separate login** — the sub-dsh resolves the parent's selected model credential; no separate login flow. `/dsh status` reports whether the credential resolves.
15
15
  - **DeepSeek delegation switch, default off** — until the switch is on, the model sees no delegation tool and delegation stays on the official in-process subagent; the switch flips the composition live via the settings watcher, no reload.
16
16
  - **Progress visible live** — the sub-dsh's incremental output mirrors event-by-event into the parent session's subagent surface, reconciled against the file mirror at settle.
17
17
  - **Model readback and per-cell working directory** — every settled round reads the actual model back from the sub-session event sources (`provider/model`), counts the round's tool calls, and records both in the delegation record; orchestrators pass a `cwd` per cell, and a resume in a different directory is rejected.
18
- - **Evaluation-ready provisioning** — named scopes, per-scope preset rosters, the `permissions` boundary, and in-container delegation (`docker exec`), all landing through idempotent sub-profile provisioning.
18
+ - **Evaluation-ready provisioning** — named scopes, per-scope presets (0.2.0 declarative preset packages), the `permissions` boundary, and in-container delegation (`docker exec`), all landing through idempotent sub-profile provisioning.
19
19
 
20
20
  ## Install
21
21
 
@@ -25,7 +25,7 @@ The family core and this bundle must be named in one command, then restart the p
25
25
  dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
26
26
  ```
27
27
 
28
- After the restart, flip the DeepSeek delegation switch on this package's settings card (the plugin detail view; on a 0.1.5 host: Settings → Plugins → 插件配置). No login step needed — `/dsh status` reports whether the parent's `DEEPSEEK_API_KEY` resolves.
28
+ After the restart, flip the DeepSeek delegation switch in the dsh section of the core's「Local Agent Delegation」family card (the Plugins page; on an older core, this package's own `plugins.item` card). No login step needed — `/dsh status` reports whether the selected model's credential resolves.
29
29
 
30
30
  Tarball installs (family packages unpublished on npm) need an `overrides:` block in the profile's `pnpm-workspace.yaml` pinning each family name to a `file:` tarball — inside a tarball the family edges are registry ranges, and the pin resolves headless and its siblings as **transitive** dependencies (headless itself declares no `dsh.bundle`, so even a mistaken direct-dependency install would mount nothing — but none is needed).
31
31
 
@@ -42,9 +42,10 @@ The scoped home (`$DSH_HOME/local-agent/dsh`) is kept on purpose — it holds th
42
42
  | field | default | meaning |
43
43
  | --- | --- | --- |
44
44
  | `profileName` | `headless-local-agent-dsh` | sub-dsh profile under the scoped home |
45
- | `apiKeyRef` | `DEEPSEEK_API_KEY` | credential reference the sub-dsh resolves |
45
+ | `apiKeyRef` | Native DeepSeek route reference; falls back to `DEEPSEEK_API_KEY` | Optional native DeepSeek credential override |
46
46
  | `cliLaunch` | parent's own launch | dsh launch argv prefix override |
47
- | `headlessBundleDir` | resolved from installation | headless bundle directory for the sub-profile symlink (**must be pinned** when the scoped home is resolved by more than one filesystem — see "Container delegation") |
47
+ | `headlessBundleDir` | resolved from installation | headless bundle directory the sub-profile takes its bundle from (copied into it; **pinned** alone, without `unitHeadlessBundleDir`, it is symlinked instead — the 0.1.x shared-profile form, see "Container delegation") |
48
+ | `unitHeadlessBundleDir` | unset | headless bundle path for **container rounds only** (a path in the unit's filesystem). When set, the host round's sub-profile links to this instance's own installation and container rounds use a `<profileName>-unit` twin sub-profile linking here — see "the dual-filesystem contract" below |
48
49
  | `permissions` | unset (follows dsh-base) | the sub-dsh's permission preset: `read-only` / `workspace-write` / `danger-full-access`, written into the sub-profile's patch layer by provisioning — see "Permission boundary" |
49
50
  | `model` | unset (follows the host default) | every delegation round starts the sub-dsh with it (`--model <provider/model>`) — see "Default model" |
50
51
  | `live` | `false` | live driver: one resident `--serve` sub-dsh process per member; a delegation round is a turn sent to the living runtime (runtime-level graceful interrupt, push-mode mirroring); off — or a channel that cannot come up — means the one-shot exec path |
@@ -72,8 +73,8 @@ The rich directory additionally retains public adapter labels, resolved route na
72
73
 
73
74
  ## Compatibility
74
75
 
75
- - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ public API compatible; minHost is 0.1.5-rc.1 — older hosts stay on the previous release line. Live generation uses the local-agent transient Remote and public Conversation nodes; suffix checkpoints provide recovery, and native final messages retain transcript and usage semantics. Browser P95 acceptance is tracked separately in the room coordinator proposal. One by-design degradation: host 0.1.5 retired the per-chunk session event, so token-granularity deltas no longer land in the child session log — they ride the run-progress channel and the round settles as one combined `assistant/message` (identical final text); a late-arriving usage whose carrier message already mirrored is dropped with a warn (no usage-backfill event exists). The old `liveMirrorGranularity: event | token` key is still accepted for existing profiles but no longer affects behavior or a running process; exec remains available for evaluation.
76
- - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.5-rc.1)
76
+ - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ public API compatible; minHost is 0.1.5-rc.1 — older hosts stay on the previous release line. Live generation uses the local-agent transient Remote and public Conversation nodes; suffix checkpoints provide recovery, and native final messages retain transcript and usage semantics. Browser P95 acceptance is tracked separately in the room coordinator proposal. One by-design degradation: host 0.1.5 retired the per-chunk session event, so token-granularity deltas no longer land in the child session log — they ride the run-progress channel and the round settles as one combined `assistant/message` (identical final text); a late-arriving usage whose carrier message already mirrored is dropped with a warn (no usage-backfill event exists). The old `liveMirrorGranularity: event | token` key is still accepted for existing profiles but no longer affects behavior or a running process; exec remains available for evaluation. The settings card renders as a section of the core's「Local Agent Delegation」family card (the core's `plugins.item` entry on the Plugins page, 0.1.7-alpha.1+; on an older core this package falls back to its own `plugins.item` card), mounted through `configForms.whileServed` while the Host serves the `local-agent-dsh` namespace; 0.1.5 renders no card (the host removed the `settings.plugin.item` seat) — every other surface is unchanged.
77
+ - source line (deepseek-harness master): ✅ (verifiedHost: 0.2.1-rc.1) — host-021 wave: full build+test green against the 0.2.1-rc.1 tree; delegation migrated to `ctx.subagents.startActivation` (managed activation), and run.id is now unique per execution.
77
78
 
78
79
  ## Known Limitations
79
80
 
@@ -92,7 +93,9 @@ The rich directory additionally retains public adapter labels, resolved route na
92
93
 
93
94
  **Live driver (`live: true`).** Replaces the per-round spawn: the member's first delegation brings up one resident `--serve` sub-dsh process, and every later round is a `turn/start` request to that living runtime over the family-internal stdio JSON-RPC wire (the headless package's `src/wire.ts`). Session events stream back as `session/event` notifications and mirror into the child session event-by-event (same fold rules as the file mirror, which runs once more at settle as reconciliation), and `cancel` lands as a runtime-level `turn/interrupt` (in-process `Agent.cancel`) — the process survives and the session stays continuable. The fold is a verbatim copy: the caller task's `user/message`, every `assistant/message`, the tool event pairs, and the sub-dsh's own `step/start`–`step/end` boundary pairs (each carrying its own (turn, step) coordinates) all cross — the host's real-time conversation view only registers a step at a boundary, so without them an assistant message renders only after a full-page rebuild; turn boundaries and scaffolding user messages (agent-instructions, plugin, and other sources) stay behind, and an interrupted step crosses as an open pair (start without end), with no boundary ever synthesized. Runtimes are reclaimed after an idle timeout (wire `shutdown`, then the SIGTERM ladder); after a crash the next round re-spawns and `agents.resume`s the on-disk session; a spawn/handshake failure marks the channel broken and permanently falls back to the exec path.
94
95
 
95
- **Auth & provisioning.** No device-code login: the sub-dsh authenticates through the parent's `DEEPSEEK_API_KEY` credential (`apiKeyRef` config); `/dsh status` reports whether the credential resolves, `/dsh sessions` lists the sub-dsh's own sessions from its scoped-home store. The sub-profile lives at `profiles/headless-local-agent-dsh`: a manifest (listing only `@deepseek-ai/dsh-base`), the headless package's patch copied byte-for-byte as the profile's own patch layer, and one symlink resolving the headless bundle (for the loader's insert rows) — everything else resolves from the dsh installation anchor, so provisioning costs no pnpm install and is idempotent, rewriting automatically on content drift (upgrades and old-format healing). The parent replicates its own launch (or a configured `cliLaunch`) so the sub-dsh runs the same dsh build.
96
+ **Local model inheritance.** The directory uses the parent's public model API, restricted to transferable native DeepSeek and official pi-ai API-key routes. Each round reads only the selected provider's effective configuration and credential reference. Endpoint, model definitions and compatibility options travel through the private launch environment; neither the full profile nor the credential store is copied. A resident process is replaced at the next round after a route or key change, resuming the same session. OAuth-only routes and other adapter families do not yet support this transfer: they are omitted from inheritable candidates and explicit requests fail with a reason. Container/remote targets retain their own configuration. The official subagent model-selection switch governs the official tools, not `subagent_dsh`.
97
+
98
+ **Auth & provisioning.** No device-code login: local sub-dsh runs authenticate through the parent's selected model credential (native DeepSeek retains the `apiKeyRef` override); `/dsh status` reports whether the credential resolves, `/dsh sessions` lists the sub-dsh's own sessions from its scoped-home store. The sub-profile lives at `profiles/headless-local-agent-dsh`: a manifest (listing only `@deepseek-ai/dsh-base`), the headless package's patch plus local model environment expressions as the profile patch layer, and the headless bundle itself under its `node_modules` (for the loader's insert rows) — a byte copy of the bundle's own files, not a symlink, because host 0.2.0 treats a symlinked package whose realpath lies outside the profiles tree as a *linked root* and routes only its declared `peerDependencies`; the packed bundle declares none of `commander`, `@deepseek-ai/schemastery` or `@deepseek-ai/dsh-cmdline`, so a linked bundle fails to import and the sub-dsh hangs with no session (no dependency closure is copied: `@deepseek-ai/*` still resolves from the installation running the sub-dsh, one instance each) — everything else resolves from the dsh installation anchor, so provisioning costs no pnpm install and is idempotent, rewriting automatically on content drift (upgrades and old-format healing). The parent replicates its own launch (or a configured `cliLaunch`) so the sub-dsh runs the same dsh build.
96
99
 
97
100
  **The session log is resolved by generation, never hardcoded.** A sub-dsh's history lives in `<scoped home>/sessions/<project>/<session id>/`, but WHICH file holds it is the host's generation choice: the original generation is `session.jsonl`, and every later one carries a lowercase `vN` — host 0.1.5 writes `session.v3.jsonl.zstd`. Either basename may also carry `.zstd` (compression is the default). This package parses every directory entry by the host's own rule (`^session(\.v[1-9][0-9]*)?\.jsonl$`, matched after any compression suffix is stripped — `.v0`, leading zeros, uppercase, `session.lock` and temporary files are not generations), takes the NUMERICALLY HIGHEST one, and carries the chosen filename back on the read-back result (`sessionLogFile`).
98
101
 
@@ -104,34 +107,34 @@ What hardcoding one name costs has been measured: after the host moved to 0.1.5
104
107
 
105
108
  **Named scopes.** A scoped delegation runs against `<homesRoot>/dsh@<name>`: the sub-profile follows the directory — one is provisioned there when the directory is materialized — so that scope's rounds launch the sub-dsh from its own profile and write their session log there. The credential is not in the directory at all (dsh authenticates through the host instance), so a named scope changes the profile and the session records, not the account. Exec-only: the resident `serve` process is bound per member to the default scoped home.
106
109
 
107
- **Per-scope preset roster (the capability face becomes a factor).** The sub-profile's patch may carry one more layer: an `insert` row mounting `@deepseek-ai/dsh-agent-presets` whose `default` is this scope's preset id, which the headless agent loader joins in its agent setup. Without the layer the behavior is exactly what it was before the field existed — the model-facing rows sit in the host plane and the agent reads them off the global layer. With it, **the scope directory IS the capability face**: two scopes whose rosters name two presets are two subjects, and the difference is a file a person can read.
110
+ **Per-scope preset (the capability face becomes a factor).** From host 0.2.0 on, a preset is a declarative `@deepseek-ai/dsh-agent-preset` row, collected by `@deepseek-ai/dsh-agent-preset-registry` (service `agentPresets`); the headless agent loader `mount`s the default preset from it in its agent setup. The sub-profile's patch may therefore carry one more layer: an `insert` row mounting the registry whose `config.default` is this scope's preset id. Without the layer the behavior is exactly what it was before the field existed — the agent runs dsh-base's root rows. With it, **the scope directory IS the capability face**: two scopes naming two presets are two subjects, and the difference is a file a person can read.
108
111
 
109
112
  ```ts
110
113
  import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
111
114
 
112
- provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean' } })
115
+ provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean', package: '@dataseek-eval/preset-eval-lean' } })
113
116
  readSubProfilePreset(scopedHome) // 'eval-lean' — the generated layer is a parseable one
114
117
  ```
115
118
 
116
- Where the preset directories come from: the sub-dsh launches with `DSH_HOME=<scoped home>`, so the roster's own user root is `<scoped home>/.agent-presets` — drop a preset directory there and that scope has a preset of its own (`roots` / `includeShippedRoot` / `includeUserRoot` override the derived roots). The roster module is deliberately **not** symlinked: it is an official package, already in the dsh installation anchor's closure beside `@deepseek-ai/dsh-base`. A linked second copy would give it a second `@deepseek-ai/cordis`, and cordis does service lookup and type checks by instance identity — the symptom is silently missing services, not an error (the same trap the dual-filesystem contract above describes for the bundle). A deployment whose anchor genuinely lacks it gets the loader's own "cannot resolve" message, which names the module better than this step could. A preset id must match `[a-z0-9][a-z0-9-]*` (it is a directory name). Re-provision without `preset` and the layer disappears again.
119
+ Where the preset's DEFINITION comes from: a preset package — a `package.json` declaring `dsh.bundle.patch`, whose patch carries an `@deepseek-ai/dsh-agent-preset` row with `config.id` equal to the preset id (`@khorsheed/dsh-presets` has the same shape). With `preset.package` set, the sub-profile's `dsh.profile.bundles` lists it after `@deepseek-ai/dsh-base`, and the sub-profile's `node_modules` gets a **relative** symlink to the scope's copy (next paragraph), so the host and a unit resolve the same directory. The registry and preset-row modules themselves are deliberately **not** symlinked: they are official packages, already in the dsh installation anchor's closure. A linked second copy would give them a second `@deepseek-ai/cordis`, and cordis does service lookup and type checks by instance identity — the symptom is silently missing services, not an error (the same trap the dual-filesystem contract below describes for the bundle). A preset id must match `[a-z0-9][a-z0-9-]*`. Re-provision without `preset` and the layer disappears again. The 0.1.5 directory presets (`$DSH_HOME/.agent-presets/<id>` plus the `@deepseek-ai/dsh-agent-presets` roster) have no module on the 0.2.0 toolchain; this package neither writes nor reads them any more.
117
120
 
118
- **The scope's own copy of the preset (the only arrangement a container round can use).** `provisionDshScope` turns "drop a preset directory in `<scoped home>/.agent-presets`" into a checkable procedure — and it is the **only** arrangement a container round can use: an evaluation unit bind-mounts the scoped home and nothing else, so a roster whose `roots` name the deployment's preset root names a path the unit does not have and the sub-dsh will not start (`preset "eval-lean" not found`). A copy in the scope's own user root is `<scope>/.agent-presets/<id>` on the host and `/creds/dsh/.agent-presets/<id>` inside the unit — one directory, because the unit binds the scope.
121
+ **The scope's own copy of the preset package (the only arrangement a container round can use).** `provisionDshScope` turns the above into a checkable procedure: it finds, in **the evaluation instance's own profile**, the one package that declares the preset id (scanning the profile `package.json`'s dependencies and resolving `node_modules/<name>` through its realpath; the match must be unique) and snapshots it byte for byte to `<scoped home>/preset-packages/<id>`. It is the **only** arrangement a container round can use: an evaluation unit bind-mounts the scoped home and nothing else, so a path into the deployment's installation does not exist inside the unit. The copy is `<scope>/preset-packages/<id>` on the host and `/creds/dsh/preset-packages/<id>` inside the unit — one directory, because the unit binds the scope; the sub-profile's relative link resolves to it on both sides. The instance's profile directory comes from this plugin's `ctx.baseUrl` (the loader sets it to the directory of the profile's `cordis.yml`).
119
122
 
120
123
  ```ts
121
124
  import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
122
125
 
123
- // Explicit request: re-sync the copy from the deployment's preset root and persist the decision
124
- provisionDshScope(scopedHome, config, { preset: 'eval-lean', presetRoot })
125
- readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }
126
+ // Explicit request: re-sync the copy from the preset package installed in the instance profile, and persist the decision
127
+ provisionDshScope(scopedHome, config, { preset: 'eval-lean', instanceProfileDir })
128
+ readScopeSubProfile(scopedHome) // { preset: 'eval-lean' } — the package name is read from the copy's own package.json each time
126
129
  // Every later provisioning (a restart's materialization, a host round's self-heal) needs no telling
127
- provisionDshScope(scopedHome, config, { presetRoot })
130
+ provisionDshScope(scopedHome, config, { instanceProfileDir })
128
131
  ```
129
132
 
130
133
  Three things worth stating on their own:
131
134
 
132
- - **Where the preset resolves from**: *explicit argument → `<scoped home>/sub-profile.json` → plugin config*. The middle one is not a convenience. This module regenerates `cordis.patch.yml` WHOLE, and the registry re-provisions a scope the first time anything names it in a fresh host process — so a roster layer hand-appended to the patch silently disappears there, and the next read-back reports a scope that rosters nothing. The scope's own declaration is how it survives, and it is also the only way "two scopes rostering two presets" is expressible at all on a config that is **instance-global**.
133
- - **When the copy is refreshed**: only by the call that carries an explicit `preset` (the evaluation's `conditions provision`). Every other provisioning copies only when the directory is ABSENT, and otherwise leaves it alone and REPORTS whether it still matches the source byte for byte. Nothing may move the subject under a run.
134
- - **A preset used as a factor may not name an absolute path**: the same preset is read from three directories (the deployment's preset root, the scope's copy, the unit's mount point), so an absolute path is wrong in at least two of them — and wrong silently, since `skill-filesystem` treats a root it cannot read as an empty one. The form that travels is the loader's own expression, as the shipped `cordis` preset writes it: `!!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"`, where `baseUrl` is the composition's own directory. A preset naming one is refused at the snapshot, with that idiom in the message.
135
+ - **Where the preset resolves from**: *explicit argument → `<scoped home>/sub-profile.json` → plugin config*. The middle one is not a convenience. This module regenerates `cordis.patch.yml` WHOLE, and the registry re-provisions a scope the first time anything names it in a fresh host process — so a registry layer hand-appended to the patch silently disappears there. The scope's own declaration is how it survives, and it is also the only way "two scopes, two presets" is expressible at all on a config that is **instance-global**. An explicit request snapshots first and persists second: when the snapshot fails (no such preset package installed in the instance, an absolute path inside it) nothing is written into the scope.
136
+ - **When the copy is refreshed**: only by the call that carries an explicit `preset` (the evaluation's `conditions provision`). Every other provisioning copies only when the directory is ABSENT, and otherwise leaves it alone and REPORTS whether it still matches the source byte for byte (a source package since removed from the instance reports a mismatch). Nothing may move the subject under a run.
137
+ - **A preset used as a factor may not name an absolute path**: the same preset is read from three places (the package installed in the instance profile, the scope's copy, the unit's mount point), so an absolute path is wrong in at least two of them — and wrong silently, since `skill-filesystem` treats a root it cannot read as an empty one. The skills root resolves through the package itself, as the official presets write it: `!!js "process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('<package name>/package.json')), 'skills')"` — `baseUrl` is the **profile root** (no longer the composition's own directory), and the package name resolves through the profile's `node_modules` to the copy. A package naming an absolute path is refused at the snapshot.
135
138
 
136
139
  **Permission boundary (`permissions`).** The file-effect boundary a sub-dsh's bash calls run under, and the approval policy a denied call escalates through, written by provisioning as one more patch layer: two **override** rows pinning `sandbox-policy`'s `mode` and `user-approval`'s `policy` (paired by dsh-base's own `permission-presets` table — `danger-full-access` with `never`, the other two with `ask`; pinning two plugins separately and letting them drift apart builds a boundary nobody can run inside and nobody can be asked about). Leave the key unset and no layer is written at all: the sub-dsh runs dsh-base's `workspace-write` + `ask`, byte for byte the behavior before this field existed.
137
140
 
@@ -146,9 +149,9 @@ readSubProfilePermissions(scopedHome) // 'danger-full-access' — the generate
146
149
 
147
150
  **When to pin `danger-full-access`.** Only where something else already IS the boundary: a one-shot evaluation unit, a disposable container. On a developer's machine the sub-dsh shares the real home and `workspace-write` is the right default — which is what leaving the key unset gives you. The converse also holds: **without the pin there is no shell inside a unit.** A container image typically carries no bubblewrap and gets no Landlock from the kernel (measured: `landlock-run`'s probe reports `unusable`), and the sandbox fails closed by design rather than quietly running unconfined, so every bash call takes `SANDBOX_UNAVAILABLE`; a headless sub-dsh has no approval channel either, so the model's sanctioned escalation retry only earns "no approval channel is available". Together, the agent simply has no shell (measured in I5·T39's G14).
148
151
 
149
- **Container delegation.** An orchestrator may run the round inside an **already-acquired container** through the facade's `DelegationCallOptions.exec` (`{ container, workdir, env? }`): the argv becomes `docker exec -w <workdir> [-e NAME…] <container> <the same argv>` and everything else (session mirror, settle, recording) is byte-for-byte unchanged. `env` must name the in-container `DSH_HOME`; the resolved API key rides the argv only as the NAME `-e DEEPSEEK_API_KEY`, its value staying in the docker client's environment and out of the host process table. A container round adds two behaviors specific to this package. First, **`NODE_OPTIONS=--use-env-proxy` is injected automatically** (a caller that names `NODE_OPTIONS` in `target.env` keeps its own value): dsh's HTTP client is node's `fetch` (undici), which **does not read** `HTTP(S)_PROXY` by default, so in a unit whose only egress is a whitelist proxy it dials the API directly and fails while the proxy never even receives a `CONNECT`; the flag opens undici's `EnvHttpProxyAgent`. dsh is the only one of the four that needs it, so the provider supplies it rather than every caller remembering, and reports it in `effectiveSettings.containerNodeOptions` so the condition file can see the knob. Second, **the host-side sub-profile provisioning is skipped**: that profile's `node_modules` symlink points at the host installation of the headless bundle, which resolves to nothing inside a unit — and the scoped home is bind-mounted, so writing it would plant a broken profile in the directory the unit actually reads. A containerized caller names the unit's own entry and profile through the existing knobs (`cliLaunch`, `profileName`), and **the unit must carry the family headless bundle and its runtime dependency closure** — the image's in-box `headless` profile is a different, smaller app that does not accept `--session-id`/`--resume` and cannot host a delegation round. Measured: with the bundle staged in an `eval-env:pinned` unit, one "answer 2+2" round settles `completed` with output `4`, and `observedModel` reads back as `deepseek-official/deepseek-v4-flash` from the sub-dsh session log the container wrote into the host scoped home.
152
+ **Container delegation.** An orchestrator may run the round inside an **already-acquired container** through the facade's `DelegationCallOptions.exec` (`{ container, workdir, env? }`): the argv becomes `docker exec -w <workdir> [-e NAME…] <container> <the same argv>` and everything else (session mirror, settle, recording) is byte-for-byte unchanged. `env` must name the in-container `DSH_HOME`; the resolved API key rides the argv only as the NAME `-e DEEPSEEK_API_KEY`, its value staying in the docker client's environment and out of the host process table. A container round adds two behaviors specific to this package. First, **`NODE_OPTIONS=--use-env-proxy` is injected automatically** (a caller that names `NODE_OPTIONS` in `target.env` keeps its own value): dsh's HTTP client is node's `fetch` (undici), which **does not read** `HTTP(S)_PROXY` by default, so in a unit whose only egress is a whitelist proxy it dials the API directly and fails while the proxy never even receives a `CONNECT`; the flag opens undici's `EnvHttpProxyAgent`. dsh is the only one of the four that needs it, so the provider supplies it rather than every caller remembering, and reports it in `effectiveSettings.containerNodeOptions` so the condition file can see the knob. Second, **the host-side sub-profile provisioning is skipped**: that profile's `node_modules` carries the HOST installation's headless bundle (a copy of another host line's build, or a link that resolves to nothing inside a unit) — and the scoped home is bind-mounted, so writing it would plant a broken profile in the directory the unit actually reads. A containerized caller names the unit's own entry and profile through the existing knobs (`cliLaunch`, `profileName`), and **the unit must carry the family headless bundle and its runtime dependency closure** — the image's in-box `headless` profile is a different, smaller app that does not accept `--session-id`/`--resume` and cannot host a delegation round. Measured: with the bundle staged in an `eval-env:pinned` unit, one "answer 2+2" round settles `completed` with output `4`, and `observedModel` reads back as `deepseek-official/deepseek-v4-flash` from the sub-dsh session log the container wrote into the host scoped home.
150
153
 
151
- **When one scoped home is resolved by several filesystems (the dual-filesystem contract).** When the scoped home is read and written by BOTH the host (judge delegations, readiness probes) and container units (bind-mounted) — e.g. the T20c "one owner, one directory" eval layout — the sub-profile's `node_modules` symlink target is a **string**, interpreted by whichever filesystem reads it: pointing at the host installation leaves the link dangling inside a unit, and a host-side readiness re-probe re-provisions, writing the host-only path back (skipping the container round's own provisioning cannot fix the link ALREADY written). The contract is one sentence: **pin `headlessBundleDir` to an absolute path that exists in every filesystem involved** — on the host, a same-named symlink into the host installation's bundle (Node resolves from the realpath, so its dependency closure comes along); in the image, the real installation at the same path. Once pinned, a readiness re-provision merely rewrites the same target and no longer manufactures host-only paths. Two alternatives were evaluated and rejected. First, **copying the bundle into the scoped home**: family code imports service keys and classes from `@deepseek-ai/*` at runtime (`credentialRef`, `TypertRemoteService`, `SessionId`, …), so a copied closure creates a second instance of those packages, and cordis does service lookup and type checks by instance identity — the symptom is silently missing services, not an error. The bundle's `@deepseek-ai` peers must resolve from the SAME installation that runs the sub-dsh; pinning preserves that in both runtimes, copying necessarily breaks it. Second, **an extra bundle mount for the unit**: it breaks T20c's single-mount stance, and dsh is the only harness that would need it. The pin is therefore a caller-side, machine-level precondition (document it in the eval env/README); this package needs zero code change.
154
+ **When one scoped home is resolved by several filesystems (the dual-filesystem contract).** When the scoped home is read and written by BOTH the host (judge delegations, readiness probes) and container units (bind-mounted) — e.g. the T20c "one owner, one directory" eval layout — the sub-profile's `node_modules` symlink target is a **string**, interpreted by whichever filesystem reads it: pointing at the host installation leaves the link dangling inside a unit, and a host-side readiness re-probe re-provisions, writing the host-only path back (skipping the container round's own provisioning cannot fix the link ALREADY written). The contract has two forms. The host 0.2.0 form is **`unitHeadlessBundleDir`**: provisioning writes two sub-profiles — `<profileName>` carries a copy of this instance's own headless installation (host rounds; it follows the instance across host versions, refreshed whenever the installation changes), and `<profileName>-unit` links to this in-unit path (container rounds; its patch is taken from that path when the host can read it there, from the host bundle otherwise); a readiness re-provision refreshes only the host one's copy, while the unit one keeps pointing at the unit path. The older form is to **pin `headlessBundleDir` to an absolute path that exists in every filesystem involved** — on the host, a same-named symlink into the host installation's bundle (Node resolves from the realpath, so its dependency closure comes along); in the image, the real installation at the same path. Once pinned, a readiness re-provision merely rewrites the same target and no longer manufactures host-only paths. Two alternatives were evaluated and rejected for the pinned form. First, **copying the bundle's dependency closure into the scoped home**: family code imports service keys and classes from `@deepseek-ai/*` at runtime (`credentialRef`, `TypertRemoteService`, `SessionId`, …), so a copied closure creates a second instance of those packages, and cordis does service lookup and type checks by instance identity — the symptom is silently missing services, not an error. The bundle's `@deepseek-ai` peers must resolve from the SAME installation that runs the sub-dsh; pinning preserves that in both runtimes, copying necessarily breaks it. Second, **an extra bundle mount for the unit**: it breaks T20c's single-mount stance, and dsh is the only harness that would need it. The pin is therefore a caller-side, machine-level precondition (document it in the eval env/README); this package needs zero code change.
152
155
 
153
156
  </details>
154
157
 
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/local-agent-dsh/README.en.md
5
- packages/local-agent-dsh/README.en.md: 2ef7d5c402aa6ecefe6669b86044e7918e6df199
6
- packages/local-agent-dsh/README.md: a610d863490c8de546da274773ad123aac019360
5
+ packages/local-agent-dsh/README.en.md: 111b2c346d07e2eb2b5af643ff630c1e73a72656
6
+ packages/local-agent-dsh/README.md: 2d9e4ecb209ec1d4a584d1267db578c704f091b7
package/README.md CHANGED
@@ -4,18 +4,18 @@
4
4
 
5
5
  把任务委派给 dsh 自己——一个跑在隔离 home 里、可跨轮续接的无头子 dsh,与 kimi / codex / claude-code harness 平级。
6
6
 
7
- 有些子任务值得一个完整而隔离的 dsh 实例:评测对照、独立的会话历史、自己的权限边界——同时绝不碰当前实例的状态。这个插件 spawn 一个子 dsh headless CLI:它在自己的 scoped home 下运行(profile、会话、状态全独立),用父级的 DeepSeek API key 认证,会话 id 由调用方指定、可跨轮续接。默认什么都不挂——在设置卡片上打开「DeepSeek 委派」开关后,模型才看得到 `subagent_dsh` 工具。
7
+ 有些子任务值得一个完整而隔离的 dsh 实例:评测对照、独立的会话历史、自己的权限边界——同时绝不碰当前实例的状态。这个插件 spawn 一个子 dsh headless CLI:它在自己的 scoped home 下运行(profile、会话、状态全独立),继承父实例中所选模型的 API-key 路线,会话 id 由调用方指定、可跨轮续接。默认什么都不挂——在设置卡片上打开「DeepSeek 委派」开关后,模型才看得到 `subagent_dsh` 工具。
8
8
 
9
9
  ## 特性
10
10
 
11
11
  - **委派给 dsh 自己**——spawn 一个子 dsh headless CLI 进程:一次性 exec(默认),或每成员常驻一个 `--serve` 进程的长驻驱动(`live: true`,runtime 级优雅中断、事件推送镜像)。
12
12
  - **Scoped home 隔离**——子 dsh 自己的 `DSH_HOME`(`$DSH_HOME/local-agent/dsh`):profile、会话与状态绝不混入父实例。
13
13
  - **跨轮续接**——子会话 id 由调用方指定(`session-<uuid>`,绝不从 stdout 解析),后续轮传回即经 `--resume` 续接同一个子 dsh 会话。
14
- - **无需单独登录**——子 dsh 通过父级的 `DEEPSEEK_API_KEY` 认证,无 device-code 流程;`/dsh status` 报告凭据是否可解析。
14
+ - **无需单独登录**——子 dsh 读取父实例所选模型的凭据引用,无单独登录流程;`/dsh status` 报告凭据是否可解析。
15
15
  - **DeepSeek 委派开关,默认关**——开关打开之前模型看不到任何委派工具,委派只走官方内置子代理;开关经 settings watcher 实时翻转,无需重载。
16
16
  - **过程实时可见**——子 dsh 的增量输出逐事件镜像进父会话的子代理面,settle 时再以文件镜像对账。
17
17
  - **模型回读与独立工作目录**——每轮从子会话事件的 source 回读实际模型(`provider/model`),并数出本轮工具调用计数,一并写进委派记录;编排器可用 `cwd` 选项给每格独立目录,resume 换目录即拒绝。
18
- - **评测就绪的供给**——命名 scope、按 scope 的 preset roster、`permissions` 权限边界、容器内委派(`docker exec`),全部经幂等的子 profile provisioning 落地。
18
+ - **评测就绪的供给**——命名 scope、按 scope 的 preset(0.2.0 声明式 preset 包)、`permissions` 权限边界、容器内委派(`docker exec`),全部经幂等的子 profile provisioning 落地。
19
19
 
20
20
  ## 安装
21
21
 
@@ -25,7 +25,7 @@
25
25
  dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
26
26
  ```
27
27
 
28
- 重启后在本包的设置卡片(插件详情页;0.1.5 宿主:设置 → 插件 → 插件配置)打开「DeepSeek 委派」开关。无需登录步骤;`/dsh status` 报告父级的 `DEEPSEEK_API_KEY` 凭据是否可解析。
28
+ 重启后在 core 的「本机 Agent 委派」家族卡的 dsh 一节(Plugins 页;旧版 core 上为本包自己的 `plugins.item` 卡)打开「DeepSeek 委派」开关。无需登录步骤;`/dsh status` 报告父级的 `DEEPSEEK_API_KEY` 凭据是否可解析。
29
29
 
30
30
  tarball 安装(npm 上未发布的家族包)需要在 profile 的 `pnpm-workspace.yaml` 里把各家族包名 `overrides:` 钉到 `file:` tarball——tarball 内的家族边是 registry range,钉版让 headless 等成员以**传递**依赖解析(headless 自身不声明 `dsh.bundle`,即便被误装成直接依赖也不会被挂进组合,但无需如此)。
31
31
 
@@ -42,9 +42,10 @@ scoped home(`$DSH_HOME/local-agent/dsh`)被有意保留——里面存着子
42
42
  | 字段 | 默认 | 含义 |
43
43
  | --- | --- | --- |
44
44
  | `profileName` | `headless-local-agent-dsh` | scoped home 下的子 dsh profile |
45
- | `apiKeyRef` | `DEEPSEEK_API_KEY` | 子 dsh 解析的凭据引用 |
45
+ | `apiKeyRef` | 原生 DeepSeek 路线的引用;缺省 `DEEPSEEK_API_KEY` | 可覆盖原生 DeepSeek 的凭据引用 |
46
46
  | `cliLaunch` | 父级自身启动 | dsh 启动 argv 前缀覆盖 |
47
- | `headlessBundleDir` | 从安装解析 | 子 profile 符号链接指向的 headless bundle 目录(scoped home 被多个文件系统解析时**必须 pin**,见下文「容器内委派」) |
47
+ | `headlessBundleDir` | 从安装解析 | 子 profile 取 headless bundle 的目录(拷进子 profile;只 **pin** 它、不配 `unitHeadlessBundleDir` 时改为符号链接——0.1.x 共用一个子 profile 的写法,见下文「容器内委派」) |
48
+ | `unitHeadlessBundleDir` | 不写 | 只给**容器轮**用的 headless bundle 路径(单元文件系统里的路径)。写了之后宿主轮的子 profile 链向本实例自己的安装,容器轮改用 `<profileName>-unit` 孪生子 profile 链向这里,见下文「双文件系统契约」 |
48
49
  | `permissions` | 不写(跟随 dsh-base) | 子 dsh 的权限档:`read-only` / `workspace-write` / `danger-full-access`,由 provisioning 写进子 profile 的 patch 层,见下文「权限边界」 |
49
50
  | `model` | 不写(跟随宿主默认) | 每轮委派以它起子 dsh(`--model <provider/model>`),见下文「默认模型」 |
50
51
  | `live` | `false` | 长驻驱动:每成员常驻一个 `--serve` 子 dsh 进程,委派 = 向活着的 runtime 发 turn(runtime 级优雅中断、事件推送镜像);关闭或通道不可用即回一次性 exec 路径 |
@@ -72,8 +73,8 @@ T30a 给三家 CLI harness 加了 `model` 插件配置键时,dsh 没拿到—
72
73
 
73
74
  ## Compatibility
74
75
 
75
- - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 公开 API 兼容;minHost 为 0.1.5-rc.1,更旧宿主留在前一发布线。生成中的内容走 local-agent 瞬时 Remote 与公开 Conversation 节点;后缀检查点负责恢复,最终原生消息保留转写与用量语义。浏览器 P95 另在 room 协调者提案中验收。一处设计内降级:宿主 0.1.5 退役了逐 chunk 的会话事件,token 粒度的增量不再落进子会话日志——它们改走 run-progress 通道,本轮 settle 为一条合并的 `assistant/message`(最终文本相同);迟到的 usage 若其载体消息已镜像,则以一条 warn 丢弃(不存在 usage 回填事件)。旧 `liveMirrorGranularity: event | token` 配置继续兼容读取,但不再影响行为,也不会改变运行中的进程;评测继续保留 exec。
76
- - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.5-rc.1)
76
+ - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 公开 API 兼容;minHost 为 0.1.5-rc.1,更旧宿主留在前一发布线。生成中的内容走 local-agent 瞬时 Remote 与公开 Conversation 节点;后缀检查点负责恢复,最终原生消息保留转写与用量语义。浏览器 P95 另在 room 协调者提案中验收。一处设计内降级:宿主 0.1.5 退役了逐 chunk 的会话事件,token 粒度的增量不再落进子会话日志——它们改走 run-progress 通道,本轮 settle 为一条合并的 `assistant/message`(最终文本相同);迟到的 usage 若其载体消息已镜像,则以一条 warn 丢弃(不存在 usage 回填事件)。旧 `liveMirrorGranularity: event | token` 配置继续兼容读取,但不再影响行为,也不会改变运行中的进程;评测继续保留 exec。设置卡渲染为 core「本机 Agent 委派」家族卡的一节(Plugins 页 core 的 `plugins.item` 条目,0.1.7-alpha.1+;旧版 core 上回落为本包自己的 `plugins.item` 卡),`configForms.whileServed` 随宿主服务的 `local-agent-dsh` 命名空间挂载;0.1.5 线不再呈现卡片(宿主删除了 `settings.plugin.item` 席位),其余表面不变。
77
+ - 源码线(deepseek-harness master):✅(verifiedHost: 0.2.1-rc.1)——host-021 波对 0.2.1-rc.1 树全量 build+test 绿;委派迁移到 `ctx.subagents.startActivation`(受管 activation),run.id 改为每次执行唯一。
77
78
 
78
79
  ## 已知限制
79
80
 
@@ -88,11 +89,13 @@ T30a 给三家 CLI harness 加了 `model` 插件配置键时,dsh 没拿到—
88
89
 
89
90
  **DeepSeek 开关。** 与其他家族 harness 不同,本包默认不挂载任何模型可见的东西。互斥开关位于 dsh harness 行的动作区内(设置卡片,namespace `local-agent-dsh`,默认 off):OFF 时委派走官方 in-process subagent 工具;ON 时注册 `dsh` harness、`dsh-cli` 委派 provider 与家族工具 `subagent_dsh`,与官方工具并存——两种委派形态语义不同(in-process continuable vs. 独立 CLI 进程),家族工具描述让模型可以区分。开关经 settings watcher 实时翻转组合。
90
91
 
91
- **委派。** provider 生成一个 uuid(`session-<uuid>`),记录委派(`childSessionId → cliSessionId` 恒等映射),并 spawn `dsh --profile headless-local-agent-dsh --session-id <uuid> "<task>"`,env 为 `{ DSH_HOME: <scoped home>, DEEPSEEK_API_KEY: <resolved> }`,cwd 为父会话 cwd。headless bundle(`@khorsheed/dsh-local-agent-dsh-headless`)用该确切 id 创建会话——id 由调用方提供,绝不从 stdout 解析——运行任务、打印最终助手文本、退出 0/1。后续轮把子会话 id 作为 `resume` 传入;provider spawn `--resume <uuid>`,子 dsh 经 `agents.resume` 续接同一会话。
92
+ **委派。** provider 生成一个 uuid(`session-<uuid>`),记录委派(`childSessionId → cliSessionId` 恒等映射),并 spawn `dsh --profile headless-local-agent-dsh --session-id <uuid> "<task>"`,通过显式环境传递 scoped `DSH_HOME`、所选路线配置与该路线的凭据,cwd 为父会话 cwd。headless bundle(`@khorsheed/dsh-local-agent-dsh-headless`)用该确切 id 创建会话——id 由调用方提供,绝不从 stdout 解析——运行任务、打印最终助手文本、退出 0/1。后续轮把子会话 id 作为 `resume` 传入;provider spawn `--resume <uuid>`,子 dsh 经 `agents.resume` 续接同一会话。
92
93
 
93
94
  **长驻驱动(`live: true`)。** 替代每轮 spawn:成员首轮委派拉起一个常驻 `--serve` 子 dsh 进程,之后每轮 = 经家族内部 stdio JSON-RPC wire(headless 包 `src/wire.ts`)向活着的 runtime 发 `turn/start`;会话事件以 `session/event` 通知即时推回并逐事件镜像进子会话(与文件镜像同一折叠规则,settle 时再跑一次文件镜像做对账),`cancel` 落地为 runtime 级 `turn/interrupt`(进程内 `Agent.cancel`)——进程不死、会话可续。折叠规则是逐字拷贝:调用方任务的 `user/message`、每条 `assistant/message`、工具事件对,以及子 dsh 自己的 `step/start`–`step/end` 边界对(各带自己的 (turn, step) 坐标)都过河——宿主实时会话视图只在边界上登记 step,缺了边界 assistant 消息要等整页重建才渲染;turn 边界与脚手架用户消息(agent-instructions、plugin 等来源)留在子侧,被 interrupt 的 step 以开口对(有 start 无 end)原样过河,绝不合成边界。runtime 空闲超时回收(wire `shutdown` → SIGTERM 阶梯),崩溃后下一轮自动重连并 `agents.resume` 盘上会话;spawn/握手失败标记通道不可用并永久回退 exec 路径。
94
95
 
95
- **认证与供给。** 无 device-code 登录:子 dsh 通过父级的 `DEEPSEEK_API_KEY` 凭据认证(`apiKeyRef` 配置);`/dsh status` 报告凭据是否可解析,`/dsh sessions` 从 scoped-home 存储列出子 dsh 自己的会话。子 profile 位于 `profiles/headless-local-agent-dsh`:一个 manifest(只列 `@deepseek-ai/dsh-base`)、headless 包的 patch 逐字节拷贝成的 profile 自己的 patch 层、一条解析 headless bundle 的符号链接(供 loader 解析 insert 行)——其余一切从 dsh 安装锚点解析,供给零 pnpm install 成本且幂等,内容漂移时自动重写(升级与旧格式自愈)。父级复制自己的启动方式(或配置 `cliLaunch`),让子 dsh 与父级跑同一个 dsh 构建。
96
+ **本地模型继承。** 目录来自主实例的公开模型 API,并限定为可传递的原生 DeepSeek 与官方 pi-ai API-key 路线。每轮只读取所选 provider 的有效配置与凭据引用;地址、模型定义及兼容选项通过私有启动环境传给子进程,不复制整份 profile 或凭据库。常驻进程在所选路线或 key 变化后于下一轮重建,续接原会话。OAuth-only 路线及其他适配器尚不支持这一传递方式,不作为可继承候选展示;显式指定时给出原因。容器/远程目标继续使用目标自身配置。官方「子智能体」的模型选择开关只约束官方工具,不控制 `subagent_dsh`。
97
+
98
+ **认证与供给。** 无 device-code 登录:本地子 dsh 通过父实例所选模型的凭据认证(原生 DeepSeek 仍支持 `apiKeyRef`);`/dsh status` 报告凭据是否可解析,`/dsh sessions` 从 scoped-home 存储列出子 dsh 自己的会话。子 profile 位于 `profiles/headless-local-agent-dsh`:一个 manifest(只列 `@deepseek-ai/dsh-base`)、headless 包的 patch 与本地模型环境引用组成的 profile patch 层、`node_modules` 下的 headless bundle 本身(供 loader 解析 insert 行)。bundle 是逐字节拷贝它自己的文件,不是符号链接:宿主 0.2.0 把 realpath 落在 profiles 树之外的软链包当作 *linked root*,只路由它声明的 `peerDependencies`;打包后的 bundle 没声明 `commander`、`@deepseek-ai/schemastery`、`@deepseek-ai/dsh-cmdline`,软链进来就导入失败,子 dsh 挂住、不出会话(依赖闭包不拷:`@deepseek-ai/*` 仍从运行子 dsh 的那份安装解析,各只一份实例)——其余一切从 dsh 安装锚点解析,供给零 pnpm install 成本且幂等,内容漂移时自动重写(升级与旧格式自愈)。父级复制自己的启动方式(或配置 `cliLaunch`),让子 dsh 与父级跑同一个 dsh 构建。
96
99
 
97
100
  **会话日志按代次解析,不写死文件名。** 子 dsh 的历史住在 `<作用域目录>/sessions/<项目>/<会话 id>/` 里,但**哪个文件**是宿主的代次选择:最初的一代叫 `session.jsonl`,此后每一代带一个小写 `vN`——宿主 0.1.5 写的是 `session.v3.jsonl.zstd`;两种基名都可能再带 `.zstd`(压缩是缺省)。本包按宿主自己的规则解析目录里的每个条目(`^session(\.v[1-9][0-9]*)?\.jsonl$`,去掉压缩后缀后匹配——`.v0`、前导零、大写、`session.lock` 与临时文件都不是代次),取**版本号最高**的那一份,并把选中的文件名带回读回结果(`sessionLogFile`)。
98
101
 
@@ -104,34 +107,34 @@ T30a 给三家 CLI harness 加了 `model` 插件配置键时,dsh 没拿到—
104
107
 
105
108
  **命名 scope。** 带 scope 的委派跑 `<homesRoot>/dsh@<名>`:子 profile 随目录走——目录建立时就地 provision 一份,因此该 scope 的轮从它自己的 profile 起 sub-dsh、会话日志也写在它自己那儿。凭据不在目录里(dsh 通过宿主实例认证),所以命名 scope 换的是 profile 与会话记录,不是账号。只走 exec:长驻 `serve` 按成员绑的是缺省目录。
106
109
 
107
- **按 scope 的 preset roster(能力面成为因子)。** 子 profile 的 patch 可以多带一层:一条 `insert` 行挂 `@deepseek-ai/dsh-agent-presets`,`default` 写这个 scope 的 preset id;headless 的 agent loader 在 agent setup 里 join 它。不带这一层就是本字段出现之前的表现——模型可见的行在宿主面,agent 从全局层读。带上之后**这个作用域目录就是能力面**:两个 scope 的 roster 写两个 preset,就是两个受试对象,差别是一份人能读的文件。
110
+ **按 scope 的 preset(能力面成为因子)。** 宿主 0.2.0 起 preset 是声明式的 `@deepseek-ai/dsh-agent-preset` 行,由 `@deepseek-ai/dsh-agent-preset-registry`(服务 `agentPresets`)收册;headless 的 agent loader 在 agent setup 里向它 `mount` 缺省 preset。子 profile 的 patch 因此可以多带一层:一条 `insert` 行挂 registry,`config.default` 写这个 scope 的 preset id。不带这一层就是本字段出现之前的表现——agent 跑 dsh-base 的根行。带上之后**这个作用域目录就是能力面**:两个 scope 写两个 preset,就是两个受试对象,差别是一份人能读的文件。
108
111
 
109
112
  ```ts
110
113
  import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
111
114
 
112
- provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean' } })
115
+ provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean', package: '@dataseek-eval/preset-eval-lean' } })
113
116
  readSubProfilePreset(scopedHome) // 'eval-lean'——生成的层也是可解析的层
114
117
  ```
115
118
 
116
- preset 目录从哪来:子 dsh 以 `DSH_HOME=<作用域目录>` 启动,所以 roster 自带的用户根就是 `<作用域目录>/.agent-presets`——把一份 preset 目录放在那里,这个 scope 就有了自己的 preset(`roots` / `includeShippedRoot` / `includeUserRoot` 可另行指定)。roster 模块**不软链**:它是官方包,本来就在 dsh 安装锚点的闭包里、与 `@deepseek-ai/dsh-base` 并列。链第二份会给它第二份 `@deepseek-ai/cordis`,而 cordis 按实例身份做服务查找与类型判断,症状是静默的服务缺失而不是报错(与上文「双文件系统契约」里 bundle 那一节同一个坑)。锚点里真没有它的部署会拿到 loader 自己那句「模块解析不了」,比这一步能说的更准。preset id 只接受 `[a-z0-9][a-z0-9-]*`(它是目录名)。撤掉 `preset` 再 provision 一次,这一层原样消失。
119
+ preset 的**定义**从哪来:一个 preset 包——`package.json` 声明 `dsh.bundle.patch`,patch 里有一行 `@deepseek-ai/dsh-agent-preset`,`config.id` 就是 preset id(`@khorsheed/dsh-presets` 是同一形态)。`preset.package` 写了,子 profile 的 `dsh.profile.bundles` 就在 `@deepseek-ai/dsh-base` 之后列它,并在子 profile 的 `node_modules` 下放一条**相对**符号链接指向 scope 里那份副本(见下一段),所以宿主与单元解析到的是同一个目录。registry 与 preset 行模块本身**不软链**:它们是官方包,本来就在 dsh 安装锚点的闭包里。链第二份会给它们第二份 `@deepseek-ai/cordis`,而 cordis 按实例身份做服务查找与类型判断,症状是静默的服务缺失而不是报错(与下文「双文件系统契约」里 bundle 那一节同一个坑)。preset id 只接受 `[a-z0-9][a-z0-9-]*`。撤掉 `preset` 再 provision 一次,这一层原样消失。0.1.5 的目录式 preset(`$DSH_HOME/.agent-presets/<id>` + `@deepseek-ai/dsh-agent-presets` roster)在 0.2.0 工具链里没有对应模块,本包不再写它,也不再读它。
117
120
 
118
- **scope 自带 preset 副本(容器轮唯一可行的形态)。** 上面那条把 preset 目录放进 `<作用域目录>/.agent-presets` 的做法,`provisionDshScope` 把它变成一条可核对的流程——而且它是**容器轮唯一跑得通的形态**:一个评测单元 bind 挂进去的只有 scoped home,roster 若把 `roots` 指向部署的 preset 根,那条路径在单元里根本不存在,子 dsh 起不来(`preset "eval-lean" not found`)。副本放在 scope 自己的用户根上,宿主是 `<scope>/.agent-presets/<id>`、单元里是 `/creds/dsh/.agent-presets/<id>`——同一个目录,因为单元挂的就是 scope。
121
+ **scope 自带 preset 包副本(容器轮唯一可行的形态)。** `provisionDshScope` 把上面那条变成一条可核对的流程:在**评测实例自己的 profile** 里找声明了该 preset id 的那个包(扫 profile `package.json` 的依赖、realpath 解析 `node_modules/<包名>`,必须唯一),逐字节快照到 `<作用域目录>/preset-packages/<id>`。这是**容器轮唯一跑得通的形态**:一个评测单元 bind 挂进去的只有 scoped home,指向部署安装的路径在单元里根本不存在。副本在宿主是 `<scope>/preset-packages/<id>`、单元里是 `/creds/dsh/preset-packages/<id>`——同一个目录,因为单元挂的就是 scope;子 profile 里那条相对链接在两边解析到它。评测实例的 profile 目录取自本插件的 `ctx.baseUrl`(loader 把它设成 profile 的 `cordis.yml` 所在目录)。
119
122
 
120
123
  ```ts
121
124
  import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
122
125
 
123
- // 显式请求:从部署的 preset 根重新同步副本,并把决定持久化进 scope
124
- provisionDshScope(scopedHome, config, { preset: 'eval-lean', presetRoot })
125
- readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }
126
+ // 显式请求:从实例 profile 里装的 preset 包重新同步副本,并把决定持久化进 scope
127
+ provisionDshScope(scopedHome, config, { preset: 'eval-lean', instanceProfileDir })
128
+ readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }——包名每次从副本自己的 package.json 读
126
129
  // 之后任何一次 provisioning(重启后的 materialize、宿主轮的自愈)都不必再被告知
127
- provisionDshScope(scopedHome, config, { presetRoot })
130
+ provisionDshScope(scopedHome, config, { instanceProfileDir })
128
131
  ```
129
132
 
130
133
  三件事值得单独说:
131
134
 
132
- - **preset 从哪儿解析出来**:*显式参数 → `<作用域目录>/sub-profile.json` → 插件 config*。中间那一档不是可选的便利:本模块**整份重写** `cordis.patch.yml`,而注册表在每个新宿主进程里第一次有人点名该 scope 时会重新 provision 它——手写进 patch 的 roster 层会就这样静默消失,下一次回读报「这个 scope 没有可读的 roster」。scope 自己那份声明是它活下来的方式,也是「两个 scope 各 roster 一个 preset」在一个**实例级** config 上唯一表达得出来的方式。
133
- - **副本什么时候刷新**:只有带显式 `preset` 参数的那一次(评测的 `conditions provision`)会从部署的 preset 根重新同步。其余任何一次 provisioning 只在副本**不存在**时才复制,存在就原样留着并**报告**它是否仍与源逐字节相同——run 底下的受试对象不许被任何东西挪动。
134
- - **作因子的 preset 不得写绝对路径**:同一份 preset 会被从三个目录读到(部署的 preset 根、scope 的副本、单元里的挂载点),绝对路径至少在其中两处是错的,而且 `skill-filesystem` 把读不到的根当空根,所以错得静音。要相对就用 loader 自己的表达式,官方 `cordis` preset 的写法:`!!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"`——`baseUrl` 是该组合文件自己的目录。带绝对路径的 preset 在快照这一步就被拒,消息里带这句写法。
135
+ - **preset 从哪儿解析出来**:*显式参数 → `<作用域目录>/sub-profile.json` → 插件 config*。中间那一档不是可选的便利:本模块**整份重写** `cordis.patch.yml`,而注册表在每个新宿主进程里第一次有人点名该 scope 时会重新 provision 它——手写进 patch 的 registry 层会就这样静默消失。scope 自己那份声明是它活下来的方式,也是「两个 scope 各一个 preset」在一个**实例级** config 上唯一表达得出来的方式。显式请求先快照、后持久化:快照失败(实例里没装这个 preset 包、包里有绝对路径)时 scope 里什么都不写。
136
+ - **副本什么时候刷新**:只有带显式 `preset` 参数的那一次(评测的 `conditions provision`)会从实例 profile 重新同步。其余任何一次 provisioning 只在副本**不存在**时才复制,存在就原样留着并**报告**它是否仍与源逐字节相同(实例里已卸掉源包时报不同)——run 底下的受试对象不许被任何东西挪动。
137
+ - **作因子的 preset 不得写绝对路径**:同一份 preset 会从三个地方被读到(实例 profile 里装的包、scope 的副本、单元里的挂载点),绝对路径至少在其中两处是错的,而且 `skill-filesystem` 把读不到的根当空根,所以错得静音。技能目录经包自身解析,官方 preset 的写法:`!!js "process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('<包名>/package.json')), 'skills')"`——`baseUrl` 是**profile 根**(不再是组合文件自己的目录),包名经 profile 的 `node_modules` 解析到副本。带绝对路径的包在快照这一步就被拒。
135
138
 
136
139
  **权限边界(`permissions`)。** 子 dsh 跑 bash 时的文件效应边界与审批策略,由 provisioning 多写一层 patch:两条**覆盖**行钉住 `sandbox-policy` 的 `mode` 与 `user-approval` 的 `policy`(两者按 dsh-base 自己的 `permission-presets` 表配对——`danger-full-access` 配 `never`,另两档配 `ask`;分开钉两个插件而让它们漂开,等于造一个跑不进去也问不到人的边界)。不写这一项就一层都不写,子 dsh 跑 dsh-base 组成的 `workspace-write` + `ask`——与本字段出现之前逐字节相同。
137
140
 
@@ -146,9 +149,9 @@ readSubProfilePermissions(scopedHome) // 'danger-full-access'——生成的
146
149
 
147
150
  **什么时候该钉 `danger-full-access`。** 只在**别的东西已经是边界**的时候:一次性评测单元、用完即毁的容器。在开发机上子 dsh 共享的是真实 home,`workspace-write` 才是对的默认值——这也是不写这一项时的表现。反过来,**单元里不钉就是跑不了 shell**:容器镜像通常既无 bubblewrap,内核也不给 Landlock(实测 `landlock-run` 的探针报 `unusable`),沙箱按设计 fail closed 而不是悄悄不设防,于是每一笔 bash 都拿到 `SANDBOX_UNAVAILABLE`;无头子 dsh 又没有审批通道,模型按提示升档重试也只会拿到「没有审批通道」。两条一起,选手在单元里就是没有 shell(I5·T39 的 G14 实测)。
148
151
 
149
- **容器内委派。** 编排器可以经门面 `DelegationCallOptions.exec`(`{ container, workdir, env? }`)让本轮跑在一个**已取得的容器**里:argv 变成 `docker exec -w <workdir> [-e NAME…] <container> <原 argv>`,其余(会话镜像、settle、记录)逐字节不变。`env` 必须给出容器内的 `DSH_HOME`;解析出的 API key 只以 `-e DEEPSEEK_API_KEY` 的**名字**上 argv,值留在 docker 客户端环境里,不进宿主进程表。容器轮另有两条本包独有的行为。其一,**自动补 `NODE_OPTIONS=--use-env-proxy`**(调用方在 `target.env` 里自己给了就不覆盖):dsh 的 HTTP 客户端是 node 的 `fetch`(undici),**默认不读** `HTTP(S)_PROXY`,在只有白名单代理、没有 NAT 出网的单元里会直连 API 并当场失败,而代理连一条 `CONNECT` 都收不到;这个开关打开 undici 的 `EnvHttpProxyAgent`。四家里只有 dsh 需要它,因此由 provider 自动补上,并在 `effectiveSettings.containerNodeOptions` 里报出来让条件文件看得见。其二,**跳过宿主侧子 profile 的 provisioning**:那份 profile 的 `node_modules` 符号链接指向宿主上的 headless bundle,在单元里解析不到;而作用域目录是 bind 挂载的,写进去等于在单元真正会读的目录里放一份坏 profile。容器轮的入口与 profile 由调用方用既有旋钮点名(`cliLaunch`、`profileName`),且**单元里必须备好家族 headless bundle 及其运行期依赖闭包**——镜像自带的 in-box `headless` profile 是另一个更小的 app,不认 `--session-id`/`--resume`,不足以承载一次委派轮。实测:`eval-env:pinned` 单元里备好之后,一次「回答 2+2」settle 为 `completed`、输出 `4`,`observedModel` 从容器写进宿主作用域目录的子 dsh 会话日志里回读为 `deepseek-official/deepseek-v4-flash`。
152
+ **容器内委派。** 编排器可以经门面 `DelegationCallOptions.exec`(`{ container, workdir, env? }`)让本轮跑在一个**已取得的容器**里:argv 变成 `docker exec -w <workdir> [-e NAME…] <container> <原 argv>`,其余(会话镜像、settle、记录)逐字节不变。`env` 必须给出容器内的 `DSH_HOME`;解析出的 API key 只以 `-e DEEPSEEK_API_KEY` 的**名字**上 argv,值留在 docker 客户端环境里,不进宿主进程表。容器轮另有两条本包独有的行为。其一,**自动补 `NODE_OPTIONS=--use-env-proxy`**(调用方在 `target.env` 里自己给了就不覆盖):dsh 的 HTTP 客户端是 node 的 `fetch`(undici),**默认不读** `HTTP(S)_PROXY`,在只有白名单代理、没有 NAT 出网的单元里会直连 API 并当场失败,而代理连一条 `CONNECT` 都收不到;这个开关打开 undici 的 `EnvHttpProxyAgent`。四家里只有 dsh 需要它,因此由 provider 自动补上,并在 `effectiveSettings.containerNodeOptions` 里报出来让条件文件看得见。其二,**跳过宿主侧子 profile 的 provisioning**:那份 profile 的 `node_modules` 里是宿主安装的 headless bundle(别的宿主线的构建的拷贝,或在单元里解析不到的链接);而作用域目录是 bind 挂载的,写进去等于在单元真正会读的目录里放一份坏 profile。容器轮的入口与 profile 由调用方用既有旋钮点名(`cliLaunch`、`profileName`),且**单元里必须备好家族 headless bundle 及其运行期依赖闭包**——镜像自带的 in-box `headless` profile 是另一个更小的 app,不认 `--session-id`/`--resume`,不足以承载一次委派轮。实测:`eval-env:pinned` 单元里备好之后,一次「回答 2+2」settle 为 `completed`、输出 `4`,`observedModel` 从容器写进宿主作用域目录的子 dsh 会话日志里回读为 `deepseek-official/deepseek-v4-flash`。
150
153
 
151
- **同一 scoped home 被多个文件系统解析时(双文件系统契约)。** 当 scoped home 同时被宿主(判官委派、就绪检查)和容器单元(bind 挂载)读写——例如 T20c「一个主人,一个目录」的评测布局——子 profile 里那条 `node_modules` 符号链接的目标是一个**字符串**,由读到它的文件系统各自解释:指向宿主安装路径时链接在单元里悬空,而宿主侧就绪重探会重新 provision、把宿主专用路径再写回去(跳过容器轮自己的 provisioning 防不住这条**已存在**的链接)。契约只有一条:**把 `headlessBundleDir` pin 到一条在两个文件系统里都成立的绝对路径**——宿主侧在同名路径建一条符号链接指向宿主安装里的 bundle(Node 按 realpath 解析,其依赖闭包随之可用),镜像侧在同名路径放真安装。pin 住之后,就绪检查的重 provision 只是把同一目标重写一遍,不再产生宿主专用路径。两个被评估后否决的替代:其一,**把 bundle 拷进 scoped home**——家族代码在运行期从 `@deepseek-ai/*` 导入的是服务键与类(`credentialRef`、`TypertRemoteService`、`SessionId` 等),拷一份闭包会让这些包出现第二份实例,cordis 按实例身份做服务查找与类型判断,症状是静默的服务缺失而非报错;bundle 的 `@deepseek-ai` peer 必须从**运行该子 dsh 的同一份安装**解析,pin 在两个运行时里都保住了这一点,拷贝必然破坏。其二,**给单元加第二条 bundle 挂载**——破坏 T20c「挂载只有一条」的立场,且只有 dsh 一家需要。pin 因此是调用方的一条机器级前置条件(写进题库 env/README),本包零代码改动。
154
+ **同一 scoped home 被多个文件系统解析时(双文件系统契约)。** 当 scoped home 同时被宿主(判官委派、就绪检查)和容器单元(bind 挂载)读写——例如 T20c「一个主人,一个目录」的评测布局——子 profile 里那条 `node_modules` 符号链接的目标是一个**字符串**,由读到它的文件系统各自解释:指向宿主安装路径时链接在单元里悬空,而宿主侧就绪重探会重新 provision、把宿主专用路径再写回去(跳过容器轮自己的 provisioning 防不住这条**已存在**的链接)。契约有两种写法。宿主 0.2.0 线的写法是 **`unitHeadlessBundleDir`**:provisioning 写两份子 profile——`<profileName>` 放一份本实例自己装的 headless 的拷贝(宿主轮用,实例换版本即跟上,安装一变就刷新),`<profileName>-unit` 链向这条单元里的路径(容器轮用;它的 patch 在宿主上读得到这条路径时取那份,否则取宿主 bundle 的);就绪检查的重 provision 只刷新宿主那份的拷贝,单元那份始终指向单元路径。旧写法是**把 `headlessBundleDir` pin 到一条在两个文件系统里都成立的绝对路径**——宿主侧在同名路径建一条符号链接指向宿主安装里的 bundle(Node 按 realpath 解析,其依赖闭包随之可用),镜像侧在同名路径放真安装。pin 住之后,就绪检查的重 provision 只是把同一目标重写一遍,不再产生宿主专用路径。对 pin 的写法,两个被评估后否决的替代:其一,**把 bundle 的依赖闭包拷进 scoped home**——家族代码在运行期从 `@deepseek-ai/*` 导入的是服务键与类(`credentialRef`、`TypertRemoteService`、`SessionId` 等),拷一份闭包会让这些包出现第二份实例,cordis 按实例身份做服务查找与类型判断,症状是静默的服务缺失而非报错;bundle 的 `@deepseek-ai` peer 必须从**运行该子 dsh 的同一份安装**解析,pin 在两个运行时里都保住了这一点,拷贝必然破坏。其二,**给单元加第二条 bundle 挂载**——破坏 T20c「挂载只有一条」的立场,且只有 dsh 一家需要。pin 因此是调用方的一条机器级前置条件(写进题库 env/README),本包零代码改动。
152
155
 
153
156
  </details>
154
157