@khorsheed/dsh-local-agent-dsh 0.2.0-rc.3 → 0.2.0
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 +7 -1
- package/README.en.md +23 -139
- package/README.i18n.yaml +2 -2
- package/README.md +23 -139
- package/lib/client.js +46 -46
- package/lib/dsh-cli-provider-DYRuRpVz.js +1920 -0
- package/lib/dsh-cli-provider.js +1 -1
- package/lib/index.js +1 -1
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/dsh-cli-provider.js +46 -24
- package/lib/types/live-driver.js +47 -41
- package/lib/types/session-mirror.d.ts +55 -0
- package/lib/types/session-mirror.js +155 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.2.0(2026-10-26)
|
|
4
|
+
|
|
5
|
+
- **版本号去掉 rc**:社区包不再发 prerelease 版本(docs/publishing.md「版本号规则」),家族各包同版 0.2.0;本节原记为 0.2.0-rc.4。
|
|
6
|
+
- 文档:preset 包形态的示例改为各模式包(`@khorsheed/dsh-dev-suite` 等),`@khorsheed/dsh-presets` 已退役。无代码变更。
|
|
7
|
+
- **README 重写**:改为大白话说明,完整参考移到 `docs/`。
|
|
8
|
+
|
|
3
9
|
## 0.2.0-rc.3(2026-10-22)
|
|
4
10
|
|
|
5
|
-
(版本号说明:家族的 dsh-headless 包 0.2.0-rc.2
|
|
11
|
+
(版本号说明:家族的 dsh-headless 包 0.2.0-rc.2 版本号已被 npm 发布流水线占用(重复利用被 409 拒绝:「Cannot stage previously published version」——曾发布后被撤回或曾被暂存,外部无法区分,结果相同:该号不可复用);为守住家族「同一版本线」纪律(check:release-groups),本波家族整体上移 rc.3,内容即 rc.2 计划稿。)
|
|
6
12
|
|
|
7
13
|
- **本机 DSH 委派继承父实例所选模型路线**:子 dsh 的模型目录与默认选择本就来自父实例,但此前独立 headless profile 只收到 DeepSeek 凭据——父实例选中 Kimi 等官方 pi-ai 路线时以 `NO_ADAPTER` 失败,live 镜像又把原生错误改写成 `UNKNOWN` 掩盖原因。现在本地 exec 与常驻启动经公开 settings 服务快照所选路线的有效配置(原生 DeepSeek,以及显式配置了 API-key 引用的 pi-ai 路线),只复制选中的路线、只解析它声明的凭据;凭据经启动环境传递,不进 argv 与 profile 文件。模型目录隐藏没有继承合同的适配器并标记目录不完整;原生失败保留原始错误码与消息。配置或 key 变化下一轮即生效,无需重启父实例。
|
|
8
14
|
- **设置面并入「本机 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」。
|
package/README.en.md
CHANGED
|
@@ -2,159 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
English | [中文](README.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Hand a subtask to a second, separately running DSH on your machine: it has its own sessions and settings, the result comes back to your session, and you can keep the conversation going.
|
|
6
6
|
|
|
7
|
-
Some subtasks deserve a
|
|
7
|
+
Some subtasks deserve a clean DSH: none of the current session's context, its own session history, and no way to disturb the instance you're using. With this plugin, turn on "DeepSeek delegation" in settings and the agent can hand such subtasks to a separate DSH started on your machine. Its output shows up live in a sub-session, the result comes back to you, and you can keep working in the same sub-session afterwards.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/local-agent-dsh.png" width="560" alt="The expanded DSH section: credentials follow the current DSH, the "DeepSeek delegation" switch on, the default-model picker and the resident-mode switch">
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- **
|
|
14
|
-
- **No separate
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- **Off by default, on when you want it**: with the switch off nothing changes; turn on "DeepSeek delegation" in the DSH section of the 「本机 Agent 委派」 (Local Agent Delegation) card and the agent can hand subtasks to it, no restart needed.
|
|
14
|
+
- **No separate sign-in**: it uses the model and key you picked in your current DSH, and the card shows whether that credential works.
|
|
15
|
+
- **Kept apart**: the second DSH's sessions, settings, and state live in their own folder; they never show up in your session list or change your current instance.
|
|
16
|
+
- **See the work as it happens**: its output appears in the sub-session as it runs.
|
|
17
|
+
- **Keep talking**: have the agent follow up, or open the sub-session and message it yourself; it's the same session on the second DSH.
|
|
18
|
+
- **Choose the model**: pick one under "Default model" on the card and every hand-off uses it; leave it blank to follow your current DSH's default.
|
|
19
|
+
- **Resident mode is faster**: turn on "Resident mode (live)" and the second DSH stays up; stopping doesn't kill it.
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
In DSH, open Plugins → Add plugin, enter this package name, and press Install:
|
|
23
24
|
|
|
24
|
-
```sh
|
|
25
|
-
dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
|
|
26
25
|
```
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
Uninstall:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
dsh plugin --profile web remove @khorsheed/dsh-local-agent-dsh
|
|
26
|
+
@khorsheed/dsh-bundle-local-agent
|
|
36
27
|
```
|
|
37
28
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
## Config
|
|
41
|
-
|
|
42
|
-
| field | default | meaning |
|
|
43
|
-
| --- | --- | --- |
|
|
44
|
-
| `profileName` | `headless-local-agent-dsh` | sub-dsh profile under the scoped home |
|
|
45
|
-
| `apiKeyRef` | Native DeepSeek route reference; falls back to `DEEPSEEK_API_KEY` | Optional native DeepSeek credential override |
|
|
46
|
-
| `cliLaunch` | parent's own launch | dsh launch argv prefix override |
|
|
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 |
|
|
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" |
|
|
50
|
-
| `model` | unset (follows the host default) | every delegation round starts the sub-dsh with it (`--model <provider/model>`) — see "Default model" |
|
|
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 |
|
|
52
|
-
| `liveIdleMs` | `1800000` (30 min) | idle lifetime of a resident runtime before reclaim |
|
|
53
|
-
|
|
54
|
-
### Default model (`model`)
|
|
55
|
-
|
|
56
|
-
When T30a gave the three CLI harnesses a `model` plugin-config key, dsh did not get one — the headless sub-dsh had no place to name a model per launch. **It has one now**: the headless `--model <provider/model>` overrides the sub-instance's default model selection, so this harness carries the same key.
|
|
57
|
-
|
|
58
|
-
**Absent = today's behavior.** Without the key the plugin adds no `--model` to the argv: the host instance's own `agentDefaultModel` selection decides which model runs, exactly as before.
|
|
59
|
-
|
|
60
|
-
**Set = every delegation round starts the sub-dsh with it.** Fresh and resume rounds alike, with `--model` after `--session-id` / `--resume`. The value is spelled `provider/model` (the shape `effectiveSettings.model` reports); a bare id names the model and keeps the instance's provider. It splits at the FIRST slash, so a model id that contains one survives.
|
|
61
|
-
|
|
62
|
-
The settings card’s "Default model" writes the provider setting for subsequent rounds. Its shared picker displays the scoped model directory, discovery source and completeness, plus an explicit model-ID input when needed. Clearing the selection follows the effective configuration/default chain. Saving does not interrupt an active round and requires no reload. If the shared picker is unavailable, the card retains its text-input fallback. Per-member model and effort changes use the durable controls described below.
|
|
63
|
-
|
|
64
|
-
The rich directory additionally retains public adapter labels, resolved route names and native reasoning options through `resolveModelInfo` when available. It uses the shared core cache and refresh subscription, distinguishes partial enumeration, and retains successful data on a failed full refresh. This host catalog does not prove that a separately changed scoped runtime has identical configuration. The shared model picker and member effort controls are connected. Busy selections apply at the next complete turn boundary, including tool continuations; core owns current/pending state, cancellation and retry, while frozen evaluation members reject changes.
|
|
29
|
+
This plugin comes with the 「多Agent协作」 (multi-agent) suite; all the local agents share one core, so on its own it won't work. The Dev Suite `@khorsheed/dsh-dev-suite` includes it too. Restart DSH, open the 「本机 Agent 委派」 card on the Plugins page, expand the DSH section, turn on "DeepSeek delegation", then tell the agent in a session "hand this subtask to the local DSH".
|
|
65
30
|
|
|
66
|
-
|
|
31
|
+
## Known limitations
|
|
67
32
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
**This is not an evaluation gap.** A run freezes its condition at setup: change the key mid-run and the next round's model read-back sees declared ≠ observed and fails the run as misattributed (frozen decision 5).
|
|
71
|
-
|
|
72
|
-
**Evaluation snapshot (`effectiveSettings`).** The harness's fairness snapshot carries the drive (exec/live), the no-pinned-endpoint flag, the CLI version (the very launch argv a delegation spawns, asked `--version`, cached against that entry script's path + mtime — the sub-dsh replicates the parent instance's own build), and the configured model (the host `agentDefaultModel` selection the sub-dsh inherits, formatted `provider/model`; when the service is absent or the selection unreadable the field drops out — never guessed): `sandbox` is the permission preset the sub-profile pins, reported **only when one is configured** (absence is still the honest condition-hash input — this scope pins no boundary and runs what dsh-base composes), and the endpoint is the host instance's model config, which this provider never overrides. `/dsh status` and the `LocalAgentStatus` Remote attach the same snapshot.
|
|
33
|
+
- There's no separate sign-in: the second DSH can only use the model credentials set up in your current DSH.
|
|
34
|
+
- The `/dsh` commands only work in web sessions.
|
|
73
35
|
|
|
74
36
|
## Compatibility
|
|
75
37
|
|
|
76
|
-
|
|
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.
|
|
78
|
-
|
|
79
|
-
## Known Limitations
|
|
80
|
-
|
|
81
|
-
- **No interactive or device-code login flow** — the sub-dsh authenticates only through the parent's `DEEPSEEK_API_KEY` credential; `/dsh login` reports the harness has no login flow.
|
|
82
|
-
- **Named scopes are exec-only** — the resident `serve` process is bound per member to the default scoped home, so a scoped delegation never takes the live driver.
|
|
83
|
-
- **Token-granularity deltas do not land in the child session log on host 0.1.5** — see the degradation note in Compatibility; the settled combined text is identical to a token-by-token mirror.
|
|
84
|
-
|
|
85
|
-
## How it works
|
|
86
|
-
|
|
87
|
-
<details>
|
|
88
|
-
<summary>Internals (click to expand)</summary>
|
|
89
|
-
|
|
90
|
-
**The DeepSeek toggle.** Unlike the other family harnesses, this one mounts nothing model-visible by default. A mutually-exclusive switch sits in the dsh harness row's action area (the settings card, namespace `local-agent-dsh`, default off): OFF keeps delegation on the official in-process subagent tools; ON registers the `dsh` harness, the `dsh-cli` delegation provider, and the family tool `subagent_dsh` alongside them — two coexisting delegation shapes (in-process continuable vs. separate CLI process) that the family tool description makes distinguishable. The switch flips the composition live via the settings watcher.
|
|
91
|
-
|
|
92
|
-
**Delegation.** The provider generates one uuid (`session-<uuid>`), records the delegation (`childSessionId → cliSessionId` identity mapping), and spawns `dsh --profile headless-local-agent-dsh --session-id <uuid> "<task>"` with `env: { DSH_HOME: <scoped home>, DEEPSEEK_API_KEY: <resolved> }` and the parent session's cwd. The headless bundle (`@khorsheed/dsh-local-agent-dsh-headless`) creates a session with exactly that id — caller-supplied, never parsed from stdout — runs the task, prints the final assistant text, and exits 0/1. A later round passes the child session id as `resume`; the provider spawns `--resume <uuid>` and the sub-dsh resumes the same session via `agents.resume`.
|
|
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.
|
|
95
|
-
|
|
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.
|
|
99
|
-
|
|
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`).
|
|
101
|
-
|
|
102
|
-
What hardcoding one name costs has been measured: after the host moved to 0.1.5 a delegation round still settled `completed` while observedModel, usage and toolCalls went absent ALL AT ONCE, and `/dsh sessions` listed nothing — downstream, "the log could not be read" looks exactly like "this round produced nothing". The session mirror and `/dsh sessions` therefore resolve through one shared function: the two readers cannot disagree about where a session's history lives. The old line's `session.jsonl(.zstd)` is still read, so historical directories keep working.
|
|
103
|
-
|
|
104
|
-
**Model readback and cwd override.** Every settled round reads the actual model from the sub-session's own events: the last `assistant/message` event's `message.source`, formatted `provider/model` (the shape the effectiveSettings snapshot reports), carried on the `settled` run-progress event and merged into `delegations.jsonl`'s `observedModel` field; absence is reported, never guessed. An orchestrator may also name this round's working directory through the facade's `DelegationCallOptions.cwd` (recorded in the `cwd` field); if a resume round resolves to a different directory than the first round recorded, the refusal is fail-loud before any process starts — a CLI session continues the context of the directory it started in.
|
|
105
|
-
|
|
106
|
-
**Tool-call accounting.** Every settled round also reports how many tool calls it made, on the `settled` run-progress event (`toolCalls: { count, byName }`). What is counted is the `tool/call` events in THIS ROUND'S WINDOW — the same roundSpan the model and usage come from — keyed by the name each event carries, so a round a live poll already mirrored still reports its real count at settle. One accounting per round, never cumulative; a round that called nothing carries no field at all (absent is not zero).
|
|
107
|
-
|
|
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.
|
|
109
|
-
|
|
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.
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
114
|
-
|
|
115
|
-
provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean', package: '@dataseek-eval/preset-eval-lean' } })
|
|
116
|
-
readSubProfilePreset(scopedHome) // 'eval-lean' — the generated layer is a parseable one
|
|
117
|
-
```
|
|
118
|
-
|
|
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.
|
|
120
|
-
|
|
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`).
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
125
|
-
|
|
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
|
|
129
|
-
// Every later provisioning (a restart's materialization, a host round's self-heal) needs no telling
|
|
130
|
-
provisionDshScope(scopedHome, config, { instanceProfileDir })
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
Three things worth stating on their own:
|
|
134
|
-
|
|
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.
|
|
138
|
-
|
|
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.
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
import { provisionDshSubProfile, readSubProfilePermissions } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
143
|
-
|
|
144
|
-
provisionDshSubProfile(scopedHome, { permissions: 'danger-full-access' })
|
|
145
|
-
readSubProfilePermissions(scopedHome) // 'danger-full-access' — the generated layer is a parseable layer
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
**Why the scope directory rather than an environment variable.** Those two dsh-base rows otherwise read `DSH_PERMISSION_MODE`, but the value belongs to the subject under test: written into the sub-profile it travels with the scope directory — including the copy bind-mounted into a container unit, where `home.sha` already hashes it — whereas riding the spawn env puts another name into that unit's composite fingerprint, and every key unrelated to the condition makes "these two cells differ in exactly one factor" harder to say. The layer lands after the base layer, so the literal wins over that variable and the boundary stops depending on whoever spawned the process.
|
|
149
|
-
|
|
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).
|
|
151
|
-
|
|
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.
|
|
153
|
-
|
|
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.
|
|
155
|
-
|
|
156
|
-
</details>
|
|
38
|
+
Needs DSH 0.1.5-rc.1 or later; verified on 0.2.1-rc.1.
|
|
157
39
|
|
|
158
|
-
##
|
|
40
|
+
## For developers
|
|
159
41
|
|
|
160
|
-
|
|
42
|
+
- Config keys, how the sub-profile is prepared, permission levels, default-model resolution, the resident driver, and in-container delegation are in the [full reference](https://github.com/Khorsheed/dsh-plugins/blob/main/packages/local-agent-dsh/docs/reference.en.md).
|
|
43
|
+
- To install it alone, name the core too: `dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh`; the second DSH itself comes from `@khorsheed/dsh-local-agent-dsh-headless`, installed automatically alongside.
|
|
44
|
+
- Part of [dsh-plugins](https://github.com/Khorsheed/dsh-plugins); open issues and contributions there. Changes: [CHANGELOG.md](CHANGELOG.md).
|
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:
|
|
6
|
-
packages/local-agent-dsh/README.md:
|
|
5
|
+
packages/local-agent-dsh/README.en.md: 40e63c45195893d980e8b2d8ababbdc3a1d6745d
|
|
6
|
+
packages/local-agent-dsh/README.md: 65d1e8a0762e1fbc33f58e594f15cbf1a5e64fb5
|
package/README.md
CHANGED
|
@@ -2,159 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.en.md) | 中文
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
把子任务交给你电脑上另一个单独运行的 DSH:它有自己的会话和设置,跑完结果回到当前会话,还能接着聊。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
有些子任务你想让一个干净的 DSH 来做:不带当前会话的上下文,有自己的会话记录,也不会动到你正在用的这个实例。装上之后,在设置里打开「DeepSeek 委派」,agent 就能把这类子任务交给本机另起的一个 DSH;它的输出实时出现在子会话里,跑完把结果带回来,之后还能在同一个子会话里接着干。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/local-agent-dsh.png" width="560" alt="展开的 DSH 一节:凭据沿用当前 DSH,「DeepSeek 委派」开关已打开,默认模型选择和常驻模式开关">
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
11
|
+
## 能做什么
|
|
12
|
+
|
|
13
|
+
- **默认关着,想用再开**:开关关着时一切照旧;在「本机 Agent 委派」卡片的 DSH 一节打开「DeepSeek 委派」,agent 才能把子任务交给它,不用重启。
|
|
14
|
+
- **不用另外登录**:它沿用你在当前 DSH 里选的模型和 key,卡片上直接显示凭据能不能用。
|
|
15
|
+
- **互不打扰**:子 DSH 的会话、设置和状态都放在单独的目录里,不会出现在你的会话列表里,也不会改动当前实例。
|
|
16
|
+
- **过程看得见**:子 DSH 的输出边跑边出现在子会话里。
|
|
17
|
+
- **接着聊**:让 agent 追问,或者打开子会话直接发消息,继续的是同一个子 DSH 会话。
|
|
18
|
+
- **指定模型**:卡片的「默认模型」里选一个模型,之后每次委派都用它;留空就跟随当前 DSH 的默认模型。
|
|
19
|
+
- **常驻模式更快**:打开「常驻模式(live)」,子 DSH 常驻,停下不杀进程。
|
|
19
20
|
|
|
20
21
|
## 安装
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
在 DSH 的「插件 → 添加插件」里输入下面的包名,点安装:
|
|
23
24
|
|
|
24
|
-
```sh
|
|
25
|
-
dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
|
|
26
25
|
```
|
|
27
|
-
|
|
28
|
-
重启后在 core 的「本机 Agent 委派」家族卡的 dsh 一节(Plugins 页;旧版 core 上为本包自己的 `plugins.item` 卡)打开「DeepSeek 委派」开关。无需登录步骤;`/dsh status` 报告父级的 `DEEPSEEK_API_KEY` 凭据是否可解析。
|
|
29
|
-
|
|
30
|
-
tarball 安装(npm 上未发布的家族包)需要在 profile 的 `pnpm-workspace.yaml` 里把各家族包名 `overrides:` 钉到 `file:` tarball——tarball 内的家族边是 registry range,钉版让 headless 等成员以**传递**依赖解析(headless 自身不声明 `dsh.bundle`,即便被误装成直接依赖也不会被挂进组合,但无需如此)。
|
|
31
|
-
|
|
32
|
-
卸载:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
dsh plugin --profile web remove @khorsheed/dsh-local-agent-dsh
|
|
26
|
+
@khorsheed/dsh-bundle-local-agent
|
|
36
27
|
```
|
|
37
28
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
## 配置
|
|
41
|
-
|
|
42
|
-
| 字段 | 默认 | 含义 |
|
|
43
|
-
| --- | --- | --- |
|
|
44
|
-
| `profileName` | `headless-local-agent-dsh` | scoped home 下的子 dsh profile |
|
|
45
|
-
| `apiKeyRef` | 原生 DeepSeek 路线的引用;缺省 `DEEPSEEK_API_KEY` | 可覆盖原生 DeepSeek 的凭据引用 |
|
|
46
|
-
| `cliLaunch` | 父级自身启动 | dsh 启动 argv 前缀覆盖 |
|
|
47
|
-
| `headlessBundleDir` | 从安装解析 | 子 profile 取 headless bundle 的目录(拷进子 profile;只 **pin** 它、不配 `unitHeadlessBundleDir` 时改为符号链接——0.1.x 共用一个子 profile 的写法,见下文「容器内委派」) |
|
|
48
|
-
| `unitHeadlessBundleDir` | 不写 | 只给**容器轮**用的 headless bundle 路径(单元文件系统里的路径)。写了之后宿主轮的子 profile 链向本实例自己的安装,容器轮改用 `<profileName>-unit` 孪生子 profile 链向这里,见下文「双文件系统契约」 |
|
|
49
|
-
| `permissions` | 不写(跟随 dsh-base) | 子 dsh 的权限档:`read-only` / `workspace-write` / `danger-full-access`,由 provisioning 写进子 profile 的 patch 层,见下文「权限边界」 |
|
|
50
|
-
| `model` | 不写(跟随宿主默认) | 每轮委派以它起子 dsh(`--model <provider/model>`),见下文「默认模型」 |
|
|
51
|
-
| `live` | `false` | 长驻驱动:每成员常驻一个 `--serve` 子 dsh 进程,委派 = 向活着的 runtime 发 turn(runtime 级优雅中断、事件推送镜像);关闭或通道不可用即回一次性 exec 路径 |
|
|
52
|
-
| `liveIdleMs` | `1800000`(30 分钟) | 长驻 runtime 的空闲回收时限 |
|
|
53
|
-
|
|
54
|
-
### 默认模型(`model`)
|
|
55
|
-
|
|
56
|
-
T30a 给三家 CLI harness 加了 `model` 插件配置键时,dsh 没拿到——因为无头子 dsh 的启动面上当时没有可以按次指定模型的位置。**现在有了**:headless 的 `--model <provider/model>` 覆盖子实例的默认模型选择,所以这个 harness 也有了同一把键。
|
|
57
|
-
|
|
58
|
-
**不写 = 今天的表现。**没有这个键时,本插件在 argv 上不加 `--model`:跑哪个模型由宿主实例自己的 `agentDefaultModel` 当前选择决定,与从前一模一样。
|
|
59
|
-
|
|
60
|
-
**写了 = 每轮委派以它起子 dsh。**新起一轮与续接(resume)同等对待,`--model` 排在 `--session-id` / `--resume` 之后。值写成 `provider/model`(与 `effectiveSettings.model` 报的形状相同);只写模型名则沿用宿主实例的 provider。按第一个 `/` 切分,所以模型 id 里再带斜杠也不会被切坏。
|
|
61
|
-
|
|
62
|
-
设置卡「默认模型」修改后续轮次使用的 provider 配置。共享选择器展示当前作用域的模型目录、发现来源和完整性,并保留按需填写模型 ID 的入口。清空选择后跟随有效配置及默认值链。保存不会打断当前轮次,也无需重载;共享选择器不可用时,卡片保留文本输入兜底。成员级模型和推理强度修改使用下文的持久控制面。
|
|
63
|
-
|
|
64
|
-
丰富目录还通过可用的公开 `resolveModelInfo` 保留适配器显示名、解析路由和原生推理选项。目录共用 core 缓存与刷新订阅,区分部分枚举,整体刷新失败保留成功数据。宿主目录不证明被单独修改的 scoped 运行时配置相同。共用模型菜单和成员 effort 控制已接入。运行中选择排到下一完整轮次(含工具续跑);core 统一持有当前/待生效配置、撤销与重试,冻结评测成员禁止变更。
|
|
65
|
-
|
|
66
|
-
**委派级的模型优先。**编排器可以经门面 `DelegationCallOptions.model` 给**某一次委派**点名模型,它排在这个键之前(固定顺序:会话覆盖 > 委派记录 > 本键 > 宿主默认选择 > CLI 内置)。首轮请求的值记进委派记录,resume 轮照它重发——resume 不接受 model 参数。常驻模式下带模型的轮次不再被拒绝:它成为该成员的**起始模型**,在 `--serve` 子 dsh 起进程时以 `--model` 绑定;若该成员的常驻 runtime 绑定的是另一个模型,先回收重生(子 dsh 会话从盘上 resume 续上),再以所点模型起新一轮。
|
|
67
|
-
|
|
68
|
-
**成员级切换(作曲器模型选择器)。**成员会话里可以按会话切换模型:一个内存中的会话级覆盖,优先级最高,宿主重启即失。切换在有轮次进行中时被拒绝;空闲时若成员有常驻 runtime 且绑定模型不同,切换即回收该 runtime——下一轮以新模型重生,子 dsh 会话本身延续。一次性(exec)驱动下没有常驻进程,覆盖直接决定下一轮的 `--model`。
|
|
69
|
-
|
|
70
|
-
**这不是评测的缺口。**评测 run 的条件在建立时冻结:run 跑到一半改这个键,下一轮的模型回读会发现声明模型 ≠ 实测模型,run 直接判为 misattributed 而失败(冻结决策 5)。
|
|
71
|
-
|
|
72
|
-
**评测快照(effectiveSettings)。** 本 harness 向注册表声明的公平性设置快照有 drive(exec/live)、端点未固定、CLI 版本(拿委派真正要 spawn 的那条 launch argv 去问 `--version`,按入口脚本路径+mtime 缓存——无头子 dsh 复制的就是父实例自己的 build)与已配置模型(读宿主 `agentDefaultModel` 的当前选择,`provider/model` 格式——无头子 dsh 继承它;服务缺位或选择不可读就不给字段,绝不猜值):`sandbox` 是子 profile 钉住的权限档,**配了才报**(缺位仍然是诚实的条件输入——这个作用域不钉边界,跑的就是 dsh-base 组成的那一档),端点即宿主实例的模型配置,本 provider 从不覆盖。`/dsh status` 与 `LocalAgentStatus` Remote 附带同一份快照。
|
|
73
|
-
|
|
74
|
-
## Compatibility
|
|
75
|
-
|
|
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 改为每次执行唯一。
|
|
29
|
+
本插件会跟着「多Agent协作」套件一起装好(各家本机 agent 共用同一个底座,单装用不起来);开发套件 `@khorsheed/dsh-dev-suite` 也包含它。装完重启 DSH,到「插件」页的「本机 Agent 委派」卡片里展开 DSH 一节,打开「DeepSeek 委派」,然后在会话里对 agent 说「把这个子任务交给本机的 DSH」。
|
|
78
30
|
|
|
79
31
|
## 已知限制
|
|
80
32
|
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
- **宿主 0.1.5 上 token 级增量不进子会话日志**——见 Compatibility 的降级说明;最终合并文本与逐 token 镜像完全一致。
|
|
84
|
-
|
|
85
|
-
## 实现原理
|
|
86
|
-
|
|
87
|
-
<details>
|
|
88
|
-
<summary>内部结构(点击展开)</summary>
|
|
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 实时翻转组合。
|
|
91
|
-
|
|
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` 续接同一会话。
|
|
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 路径。
|
|
95
|
-
|
|
96
|
-
**本地模型继承。** 目录来自主实例的公开模型 API,并限定为可传递的原生 DeepSeek 与官方 pi-ai API-key 路线。每轮只读取所选 provider 的有效配置与凭据引用;地址、模型定义及兼容选项通过私有启动环境传给子进程,不复制整份 profile 或凭据库。常驻进程在所选路线或 key 变化后于下一轮重建,续接原会话。OAuth-only 路线及其他适配器尚不支持这一传递方式,不作为可继承候选展示;显式指定时给出原因。容器/远程目标继续使用目标自身配置。官方「子智能体」的模型选择开关只约束官方工具,不控制 `subagent_dsh`。
|
|
33
|
+
- 没有单独的登录:子 DSH 只能用当前 DSH 里配好的模型凭据。
|
|
34
|
+
- `/dsh` 这组命令只能在网页会话里用。
|
|
97
35
|
|
|
98
|
-
|
|
99
|
-
|
|
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`)。
|
|
101
|
-
|
|
102
|
-
写死一个文件名的代价是实测过的:宿主升到 0.1.5 之后,一轮委派照样 settle 成 `completed`,而 observedModel、usage、toolCalls **三样一起变成缺位**,`/dsh sessions` 一条都列不出来——读不到日志与「这一轮什么都没产生」在下游长得一模一样。会话镜像与 `/dsh sessions` 因此走同一个解析函数:两个读者不可能对「历史在哪个文件里」有不同看法。旧线的 `session.jsonl(.zstd)` 继续认,历史目录照读。
|
|
103
|
-
|
|
104
|
-
**模型回读与 cwd 覆盖。** 每轮 settle 后,provider 从子会话事件自身读取实际模型:本轮最后一个 `assistant/message` 事件的 `message.source`,格式化为 `provider/model`(与 effectiveSettings 快照报告已配置模型的形状一致)随 `settled` 进度事件上报,并合并进 `delegations.jsonl` 的 `observedModel` 字段;取不到即缺位,绝不猜测。编排器还可以经门面 `DelegationCallOptions.cwd` 给本轮指定工作目录(记录进 `cwd` 字段);resume 轮解析出的目录若与首轮记录不一致,进程启动前即 fail loud——CLI 会话延续的是首轮所在目录的上下文。
|
|
105
|
-
|
|
106
|
-
**工具调用计数。** 每轮 settle 时,provider 顺带数出本轮的工具调用,随 `settled` 进度事件上报(`toolCalls: { count, byName }`)。数的是**本轮窗口**(模型与用量取的同一段 roundSpan)里的 `tool/call` 事件,按事件自带的工具名归类——因此 live 轮询已经镜像过的一轮,settle 那遍照样报得出真实计数。本轮一份,绝不累计;一次都没调用就整个字段缺位(缺席 ≠ 0)。
|
|
107
|
-
|
|
108
|
-
**命名 scope。** 带 scope 的委派跑 `<homesRoot>/dsh@<名>`:子 profile 随目录走——目录建立时就地 provision 一份,因此该 scope 的轮从它自己的 profile 起 sub-dsh、会话日志也写在它自己那儿。凭据不在目录里(dsh 通过宿主实例认证),所以命名 scope 换的是 profile 与会话记录,不是账号。只走 exec:长驻 `serve` 按成员绑的是缺省目录。
|
|
109
|
-
|
|
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,就是两个受试对象,差别是一份人能读的文件。
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
114
|
-
|
|
115
|
-
provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean', package: '@dataseek-eval/preset-eval-lean' } })
|
|
116
|
-
readSubProfilePreset(scopedHome) // 'eval-lean'——生成的层也是可解析的层
|
|
117
|
-
```
|
|
118
|
-
|
|
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 工具链里没有对应模块,本包不再写它,也不再读它。
|
|
120
|
-
|
|
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` 所在目录)。
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
125
|
-
|
|
126
|
-
// 显式请求:从实例 profile 里装的 preset 包重新同步副本,并把决定持久化进 scope
|
|
127
|
-
provisionDshScope(scopedHome, config, { preset: 'eval-lean', instanceProfileDir })
|
|
128
|
-
readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }——包名每次从副本自己的 package.json 读
|
|
129
|
-
// 之后任何一次 provisioning(重启后的 materialize、宿主轮的自愈)都不必再被告知
|
|
130
|
-
provisionDshScope(scopedHome, config, { instanceProfileDir })
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
三件事值得单独说:
|
|
134
|
-
|
|
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` 解析到副本。带绝对路径的包在快照这一步就被拒。
|
|
138
|
-
|
|
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`——与本字段出现之前逐字节相同。
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
import { provisionDshSubProfile, readSubProfilePermissions } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
143
|
-
|
|
144
|
-
provisionDshSubProfile(scopedHome, { permissions: 'danger-full-access' })
|
|
145
|
-
readSubProfilePermissions(scopedHome) // 'danger-full-access'——生成的层也是可解析的层
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
**为什么钉在作用域目录而不是环境变量。** dsh-base 那两行本来读 `DSH_PERMISSION_MODE`,但这个值属于受试对象:写进子 profile,它就随作用域目录走——包括被 bind 挂进容器单元的那一份,`home.sha` 已经把它哈希在内;走 spawn env 则每个变量名都要进单元的复合指纹,且与条件无关的键越多,「只差一个因子」越难说清。层落在 base 层之后,所以字面值压过那个环境变量,边界不再取决于谁起的进程。
|
|
149
|
-
|
|
150
|
-
**什么时候该钉 `danger-full-access`。** 只在**别的东西已经是边界**的时候:一次性评测单元、用完即毁的容器。在开发机上子 dsh 共享的是真实 home,`workspace-write` 才是对的默认值——这也是不写这一项时的表现。反过来,**单元里不钉就是跑不了 shell**:容器镜像通常既无 bubblewrap,内核也不给 Landlock(实测 `landlock-run` 的探针报 `unusable`),沙箱按设计 fail closed 而不是悄悄不设防,于是每一笔 bash 都拿到 `SANDBOX_UNAVAILABLE`;无头子 dsh 又没有审批通道,模型按提示升档重试也只会拿到「没有审批通道」。两条一起,选手在单元里就是没有 shell(I5·T39 的 G14 实测)。
|
|
151
|
-
|
|
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`。
|
|
153
|
-
|
|
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),本包零代码改动。
|
|
36
|
+
## Compatibility
|
|
155
37
|
|
|
156
|
-
|
|
38
|
+
需要 DSH 0.1.5-rc.1 或更新版本,已在 0.2.1-rc.1 上验证。
|
|
157
39
|
|
|
158
|
-
##
|
|
40
|
+
## 给开发者
|
|
159
41
|
|
|
160
|
-
|
|
42
|
+
- 配置项、子 profile 的准备方式、权限档、默认模型的取值、常驻驱动和容器内委派等细节,都在[完整参考](https://github.com/Khorsheed/dsh-plugins/blob/main/packages/local-agent-dsh/docs/reference.md)里。
|
|
43
|
+
- 单独安装要连同底座一起点名:`dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh`;子 DSH 由 `@khorsheed/dsh-local-agent-dsh-headless` 提供,会自动跟着装上。
|
|
44
|
+
- 隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins),问题与贡献请到该仓库;变更见 [CHANGELOG.md](CHANGELOG.md)。
|