@khorsheed/dsh-local-agent-dsh 0.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +150 -0
- package/README.i18n.yaml +6 -0
- package/README.md +151 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +1115 -0
- package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
- package/lib/dsh-cli-provider.js +3 -0
- package/lib/index.js +624 -0
- package/lib/invariant.js +33 -0
- package/lib/provision.js +602 -0
- package/lib/records.js +109 -0
- package/lib/session-log-DOsSz56-.js +88 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +200 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +45 -0
- package/lib/types/client/index.js +88 -0
- package/lib/types/client/locales.d.ts +76 -0
- package/lib/types/client/locales.js +67 -0
- package/lib/types/dsh-cli-provider.d.ts +213 -0
- package/lib/types/dsh-cli-provider.js +733 -0
- package/lib/types/index.d.ts +124 -0
- package/lib/types/index.js +347 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +37 -0
- package/lib/types/live-driver.d.ts +180 -0
- package/lib/types/live-driver.js +887 -0
- package/lib/types/live-switch.d.ts +76 -0
- package/lib/types/live-switch.js +100 -0
- package/lib/types/model-broker.d.ts +86 -0
- package/lib/types/model-broker.js +149 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +71 -0
- package/lib/types/provision.d.ts +382 -0
- package/lib/types/provision.js +656 -0
- package/lib/types/records.d.ts +41 -0
- package/lib/types/records.js +125 -0
- package/lib/types/session-log.d.ts +58 -0
- package/lib/types/session-log.js +83 -0
- package/lib/types/session-mirror.d.ts +155 -0
- package/lib/types/session-mirror.js +451 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +121 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# `@khorsheed/dsh-local-agent-dsh`
|
|
2
|
+
|
|
3
|
+
English | [中文](README.md)
|
|
4
|
+
|
|
5
|
+
**Live output migration.** Live runs always consume incremental output. The old `liveMirrorGranularity: event | token` key is accepted for existing profiles but ignored; changing it never changes a running process. Exec remains available for evaluation. The DSH headless process forwards native assistant frames into the shared plugin transient channel.
|
|
6
|
+
|
|
7
|
+
Delegate a task to dsh itself as a separate local CLI process, sibling to the kimi / codex / claude-code harnesses. It runs under its own scoped home, authenticates through the parent's API key, and resumes across rounds; a settings toggle (default off) turns the delegation tool on.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Delegate to dsh itself** — spawns a sub-dsh headless CLI process.
|
|
12
|
+
- **Scoped home** — its own `DSH_HOME` (`$DSH_HOME/local-agent/dsh`): profile, sessions, and state never mixed into the parent.
|
|
13
|
+
- **Resume across rounds** — pass the child session id back to continue the same session.
|
|
14
|
+
- **No separate login** — authenticates through the parent's `DEEPSEEK_API_KEY`; no device-code flow.
|
|
15
|
+
- **DeepSeek toggle, default off** — nothing model-visible until you flip the switch in Settings → 本地 Agent.
|
|
16
|
+
- **Model readback and per-cell working directory** — every settled round reads back the model from the sub-session event sources (`provider/model`) into the delegation record; orchestrators pass a `cwd` per cell, and a resume in a different directory is rejected.
|
|
17
|
+
- **Model readback and per-cell working directory** — every settled round reads back the model from the sub-session event sources (`provider/model`) into the delegation record; orchestrators pass a `cwd` per cell, and a resume in a different directory is rejected.
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
The family core and this bundle must be named in one command, then restart the profile:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No login step needed — `/dsh status` reports whether the parent's `DEEPSEEK_API_KEY` resolves.
|
|
27
|
+
|
|
28
|
+
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).
|
|
29
|
+
|
|
30
|
+
Uninstall:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
dsh plugin --profile web remove @khorsheed/dsh-local-agent-dsh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The scoped home (`$DSH_HOME/local-agent/dsh`) is kept on purpose — it holds the sub-dsh's own sessions; delete it to remove every trace.
|
|
37
|
+
|
|
38
|
+
## Config
|
|
39
|
+
|
|
40
|
+
| field | default | meaning |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `profileName` | `headless-local-agent-dsh` | sub-dsh profile under the scoped home |
|
|
43
|
+
| `apiKeyRef` | `DEEPSEEK_API_KEY` | credential reference the sub-dsh resolves |
|
|
44
|
+
| `cliLaunch` | parent's own launch | dsh launch argv prefix override |
|
|
45
|
+
| `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") |
|
|
46
|
+
| `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" |
|
|
47
|
+
| `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 |
|
|
48
|
+
| `liveIdleMs` | `1800000` (30 min) | idle lifetime of a resident runtime before reclaim |
|
|
49
|
+
|
|
50
|
+
### Default model (`model`)
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
**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.
|
|
55
|
+
|
|
56
|
+
**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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
**A delegation's own model outranks this key.** An orchestrator may name the model for ONE delegation through the facade's `DelegationCallOptions.model` (the fixed order: session override > delegation record > this key > host default selection > CLI built-in). The first round's request is recorded and every resume round re-requests it — `resume` takes no model of its own. In live mode such a round is no longer refused: the model becomes the member's **start model**, bound at the `--serve` spawn through `--model`; a resident runtime bound to a different model is retired first (the sub-dsh session resumes from disk) so the round respawns onto the asked-for model.
|
|
63
|
+
|
|
64
|
+
**Member-level switching (the composer model picker).** A member session can switch its model per session: an in-memory session-level override, the highest-priority layer, deliberately lost on a host restart. A switch is refused while a round is in flight; when idle and the member has a resident runtime bound to a different model, the switch retires that runtime — the next round respawns onto the new model while the sub-dsh session itself carries over. On the one-shot (exec) drive there is no resident process, so the override simply decides the next round's `--model`.
|
|
65
|
+
|
|
66
|
+
**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).
|
|
67
|
+
|
|
68
|
+
**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.
|
|
69
|
+
|
|
70
|
+
## Compatibility
|
|
71
|
+
|
|
72
|
+
- npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ public API compatible. 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. Older hosts stay on the previous release line.
|
|
73
|
+
- source line (deepseek-harness master): ✅ (verifiedHost: 0.1.5-rc.1)
|
|
74
|
+
|
|
75
|
+
## Known Limitations
|
|
76
|
+
|
|
77
|
+
- 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.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
<details>
|
|
82
|
+
<summary>Internals (click to expand)</summary>
|
|
83
|
+
|
|
84
|
+
**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 (Settings → 本地 Agent, 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.
|
|
85
|
+
|
|
86
|
+
**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`).
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
**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.
|
|
91
|
+
|
|
92
|
+
**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.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
96
|
+
|
|
97
|
+
provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean' } })
|
|
98
|
+
readSubProfilePreset(scopedHome) // 'eval-lean' — the generated layer is a parseable one
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
**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.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
107
|
+
|
|
108
|
+
// Explicit request: re-sync the copy from the deployment's preset root and persist the decision
|
|
109
|
+
provisionDshScope(scopedHome, config, { preset: 'eval-lean', presetRoot })
|
|
110
|
+
readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }
|
|
111
|
+
// Every later provisioning (a restart's materialization, a host round's self-heal) needs no telling
|
|
112
|
+
provisionDshScope(scopedHome, config, { presetRoot })
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Three things worth stating on their own:
|
|
116
|
+
|
|
117
|
+
- **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**.
|
|
118
|
+
- **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.
|
|
119
|
+
- **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.
|
|
120
|
+
|
|
121
|
+
**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.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { provisionDshSubProfile, readSubProfilePermissions } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
125
|
+
|
|
126
|
+
provisionDshSubProfile(scopedHome, { permissions: 'danger-full-access' })
|
|
127
|
+
readSubProfilePermissions(scopedHome) // 'danger-full-access' — the generated layer is a parseable layer
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**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.
|
|
131
|
+
|
|
132
|
+
**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).
|
|
133
|
+
|
|
134
|
+
**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.
|
|
135
|
+
|
|
136
|
+
**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.
|
|
137
|
+
|
|
138
|
+
**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).
|
|
139
|
+
|
|
140
|
+
**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`.
|
|
141
|
+
|
|
142
|
+
**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.
|
|
143
|
+
|
|
144
|
+
**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.
|
|
145
|
+
|
|
146
|
+
</details>
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
Part of the [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo (`packages/local-agent-dsh`). Issues and contributions welcome there.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record: the git blob hash of each side as of the
|
|
2
|
+
# last confirmed-consistent state. Both languages carry equal authority; after
|
|
3
|
+
# editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/local-agent-dsh/README.en.md
|
|
5
|
+
packages/local-agent-dsh/README.en.md: be0d04239eb29d54d9e6bb7a30362c44e2c3aa47
|
|
6
|
+
packages/local-agent-dsh/README.md: 3b86f2be9bacd1b4f38fb7e88b9113466366faaa
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# `@khorsheed/dsh-local-agent-dsh`
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) | 中文
|
|
4
|
+
|
|
5
|
+
**实时输出迁移。** live 轮次统一消费增量输出。旧 `liveMirrorGranularity: event | token` 配置继续兼容读取,但不再影响行为,也不会改变运行中的进程。评测继续保留 exec。DSH headless 进程把原生 assistant 帧转入共用的插件瞬时通道。
|
|
6
|
+
|
|
7
|
+
把任务委派给 dsh 自己——作为独立的本地 CLI 进程运行,与 kimi / codex / claude-code harness 平级。子 dsh 在自己的 scoped home 下运行,通过父级的 API key 认证,可跨轮续接;设置开关(默认关)打开后才启用委派工具。
|
|
8
|
+
|
|
9
|
+
## 特性
|
|
10
|
+
|
|
11
|
+
- **委派给 dsh 自己**——spawn 一个子 dsh headless CLI 进程。
|
|
12
|
+
- **Scoped home**——自己的 `DSH_HOME`(`$DSH_HOME/local-agent/dsh`):profile、会话与状态绝不混入父实例。
|
|
13
|
+
- **跨轮续接**——把子会话 id 传回即可续接同一个子 dsh 会话。
|
|
14
|
+
- **无需单独登录**——通过父级的 `DEEPSEEK_API_KEY` 认证,无 device-code 流程。
|
|
15
|
+
- **DeepSeek 开关,默认关**——在 设置 → 本地 Agent 打开开关之前,模型看不到任何委派工具。
|
|
16
|
+
- **模型回读与独立工作目录**——每轮从子会话事件的 source 回读实际模型(`provider/model`),写进委派记录;编排器可用 `cwd` 选项给每格独立目录,resume 换目录即拒绝。
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
家族核心与本 bundle 必须在同一条命令里指名,然后重启 profile:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
dsh plugin --profile web add @khorsheed/dsh-local-agent @khorsheed/dsh-local-agent-dsh
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
无需登录步骤;`/dsh status` 报告父级的 `DEEPSEEK_API_KEY` 凭据是否可解析。
|
|
26
|
+
|
|
27
|
+
tarball 安装(npm 上未发布的家族包)需要在 profile 的 `pnpm-workspace.yaml` 里把各家族包名 `overrides:` 钉到 `file:` tarball——tarball 内的家族边是 registry range,钉版让 headless 等成员以**传递**依赖解析(headless 自身不声明 `dsh.bundle`,即便被误装成直接依赖也不会被挂进组合,但无需如此)。
|
|
28
|
+
|
|
29
|
+
卸载:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile web remove @khorsheed/dsh-local-agent-dsh
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
scoped home(`$DSH_HOME/local-agent/dsh`)被有意保留——里面存着子 dsh 自己的会话;删除它即清除所有痕迹。
|
|
36
|
+
|
|
37
|
+
## 配置
|
|
38
|
+
|
|
39
|
+
| 字段 | 默认 | 含义 |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `profileName` | `headless-local-agent-dsh` | scoped home 下的子 dsh profile |
|
|
42
|
+
| `apiKeyRef` | `DEEPSEEK_API_KEY` | 子 dsh 解析的凭据引用 |
|
|
43
|
+
| `cliLaunch` | 父级自身启动 | dsh 启动 argv 前缀覆盖 |
|
|
44
|
+
| `headlessBundleDir` | 从安装解析 | 子 profile 符号链接指向的 headless bundle 目录(scoped home 被多个文件系统解析时**必须 pin**,见下文「容器内委派」) |
|
|
45
|
+
| `permissions` | 不写(跟随 dsh-base) | 子 dsh 的权限档:`read-only` / `workspace-write` / `danger-full-access`,由 provisioning 写进子 profile 的 patch 层,见下文「权限边界」 |
|
|
46
|
+
| `live` | `false` | 长驻驱动:每成员常驻一个 `--serve` 子 dsh 进程,委派 = 向活着的 runtime 发 turn(runtime 级优雅中断、事件推送镜像);关闭或通道不可用即回一次性 exec 路径 |
|
|
47
|
+
| `liveIdleMs` | `1800000`(30 分钟) | 长驻 runtime 的空闲回收时限 |
|
|
48
|
+
|
|
49
|
+
### 默认模型(`model`)
|
|
50
|
+
|
|
51
|
+
T30a 给三家 CLI harness 加了 `model` 插件配置键时,dsh 没拿到——因为无头子 dsh 的启动面上当时没有可以按次指定模型的位置。**现在有了**:headless 的 `--model <provider/model>` 覆盖子实例的默认模型选择,所以这个 harness 也有了同一把键。
|
|
52
|
+
|
|
53
|
+
**不写 = 今天的表现。**没有这个键时,本插件在 argv 上不加 `--model`:跑哪个模型由宿主实例自己的 `agentDefaultModel` 当前选择决定,与从前一模一样。
|
|
54
|
+
|
|
55
|
+
**写了 = 每轮委派以它起子 dsh。**新起一轮与续接(resume)同等对待,`--model` 排在 `--session-id` / `--resume` 之后。值写成 `provider/model`(与 `effectiveSettings.model` 报的形状相同);只写模型名则沿用宿主实例的 provider。按第一个 `/` 切分,所以模型 id 里再带斜杠也不会被切坏。
|
|
56
|
+
|
|
57
|
+
设置卡「默认模型」修改后续轮次使用的 provider 配置。共享选择器展示当前作用域的模型目录、发现来源和完整性,并保留按需填写模型 ID 的入口。清空选择后跟随有效配置及默认值链。保存不会打断当前轮次,也无需重载;共享选择器不可用时,卡片保留文本输入兜底。成员级模型和推理强度修改使用下文的持久控制面。
|
|
58
|
+
|
|
59
|
+
丰富目录还通过可用的公开 `resolveModelInfo` 保留适配器显示名、解析路由和原生推理选项。目录共用 core 缓存与刷新订阅,区分部分枚举,整体刷新失败保留成功数据。宿主目录不证明被单独修改的 scoped 运行时配置相同。共用模型菜单和成员 effort 控制已接入。运行中选择排到下一完整轮次(含工具续跑);core 统一持有当前/待生效配置、撤销与重试,冻结评测成员禁止变更。
|
|
60
|
+
|
|
61
|
+
**委派级的模型优先。**编排器可以经门面 `DelegationCallOptions.model` 给**某一次委派**点名模型,它排在这个键之前(固定顺序:会话覆盖 > 委派记录 > 本键 > 宿主默认选择 > CLI 内置)。首轮请求的值记进委派记录,resume 轮照它重发——resume 不接受 model 参数。常驻模式下带模型的轮次不再被拒绝:它成为该成员的**起始模型**,在 `--serve` 子 dsh 起进程时以 `--model` 绑定;若该成员的常驻 runtime 绑定的是另一个模型,先回收重生(子 dsh 会话从盘上 resume 续上),再以所点模型起新一轮。
|
|
62
|
+
|
|
63
|
+
**成员级切换(作曲器模型选择器)。**成员会话里可以按会话切换模型:一个内存中的会话级覆盖,优先级最高,宿主重启即失。切换在有轮次进行中时被拒绝;空闲时若成员有常驻 runtime 且绑定模型不同,切换即回收该 runtime——下一轮以新模型重生,子 dsh 会话本身延续。一次性(exec)驱动下没有常驻进程,覆盖直接决定下一轮的 `--model`。
|
|
64
|
+
|
|
65
|
+
**这不是评测的缺口。**评测 run 的条件在建立时冻结:run 跑到一半改这个键,下一轮的模型回读会发现声明模型 ≠ 实测模型,run 直接判为 misattributed 而失败(冻结决策 5)。
|
|
66
|
+
|
|
67
|
+
**评测快照(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 附带同一份快照。
|
|
68
|
+
|
|
69
|
+
## Compatibility
|
|
70
|
+
|
|
71
|
+
- npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 公开 API 兼容。生成中的内容走 local-agent 瞬时 Remote 与公开 Conversation 节点;后缀检查点负责恢复,最终原生消息保留转写与用量语义。浏览器 P95 另在 room 协调者提案中验收。更旧宿主留在前一发布线。
|
|
72
|
+
- 源码线(deepseek-harness master):✅(verifiedHost: 0.1.5-rc.1)
|
|
73
|
+
|
|
74
|
+
## 已知限制
|
|
75
|
+
|
|
76
|
+
- 无交互式或 device-code 登录流程——子 dsh 只能通过父级的 `DEEPSEEK_API_KEY` 凭据认证;`/dsh login` 报告该 harness 无登录流程。
|
|
77
|
+
|
|
78
|
+
## 实现原理
|
|
79
|
+
|
|
80
|
+
<details>
|
|
81
|
+
<summary>内部结构(点击展开)</summary>
|
|
82
|
+
|
|
83
|
+
**会话日志按代次解析,不写死文件名。** 子 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`)。
|
|
84
|
+
|
|
85
|
+
写死一个文件名的代价是实测过的:宿主升到 0.1.5 之后,一轮委派照样 settle 成 `completed`,而 observedModel、usage、toolCalls **三样一起变成缺位**,`/dsh sessions` 一条都列不出来——读不到日志与「这一轮什么都没产生」在下游长得一模一样。会话镜像与 `/dsh sessions` 因此走同一个解析函数:两个读者不可能对「历史在哪个文件里」有不同看法。旧线的 `session.jsonl(.zstd)` 继续认,历史目录照读。
|
|
86
|
+
|
|
87
|
+
**模型回读与 cwd 覆盖。** 每轮 settle 后,provider 从子会话事件自身读取实际模型:本轮最后一个 `assistant/message` 事件的 `message.source`,格式化为 `provider/model`(与 effectiveSettings 快照报告已配置模型的形状一致)随 `settled` 进度事件上报,并合并进 `delegations.jsonl` 的 `observedModel` 字段;取不到即缺位,绝不猜测。编排器还可以经门面 `DelegationCallOptions.cwd` 给本轮指定工作目录(记录进 `cwd` 字段);resume 轮解析出的目录若与首轮记录不一致,进程启动前即 fail loud——CLI 会话延续的是首轮所在目录的上下文。
|
|
88
|
+
|
|
89
|
+
**工具调用计数。** 每轮 settle 时,provider 顺带数出本轮的工具调用,随 `settled` 进度事件上报(`toolCalls: { count, byName }`)。数的是**本轮窗口**(模型与用量取的同一段 roundSpan)里的 `tool/call` 事件,按事件自带的工具名归类——因此 live 轮询已经镜像过的一轮,settle 那遍照样报得出真实计数。本轮一份,绝不累计;一次都没调用就整个字段缺位(缺席 ≠ 0)。
|
|
90
|
+
|
|
91
|
+
**命名 scope。** 带 scope 的委派跑 `<homesRoot>/dsh@<名>`:子 profile 随目录走——目录建立时就地 provision 一份,因此该 scope 的轮从它自己的 profile 起 sub-dsh、会话日志也写在它自己那儿。凭据不在目录里(dsh 通过宿主实例认证),所以命名 scope 换的是 profile 与会话记录,不是账号。只走 exec:长驻 `serve` 按成员绑的是缺省目录。
|
|
92
|
+
|
|
93
|
+
**按 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,就是两个受试对象,差别是一份人能读的文件。
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { provisionDshSubProfile, readSubProfilePreset } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
97
|
+
|
|
98
|
+
provisionDshSubProfile(scopedHome, { preset: { id: 'eval-lean' } })
|
|
99
|
+
readSubProfilePreset(scopedHome) // 'eval-lean'——生成的层也是可解析的层
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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 一次,这一层原样消失。
|
|
103
|
+
|
|
104
|
+
**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。
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { provisionDshScope, readScopeSubProfile } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
108
|
+
|
|
109
|
+
// 显式请求:从部署的 preset 根重新同步副本,并把决定持久化进 scope
|
|
110
|
+
provisionDshScope(scopedHome, config, { preset: 'eval-lean', presetRoot })
|
|
111
|
+
readScopeSubProfile(scopedHome) // { preset: 'eval-lean' }
|
|
112
|
+
// 之后任何一次 provisioning(重启后的 materialize、宿主轮的自愈)都不必再被告知
|
|
113
|
+
provisionDshScope(scopedHome, config, { presetRoot })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
三件事值得单独说:
|
|
117
|
+
|
|
118
|
+
- **preset 从哪儿解析出来**:*显式参数 → `<作用域目录>/sub-profile.json` → 插件 config*。中间那一档不是可选的便利:本模块**整份重写** `cordis.patch.yml`,而注册表在每个新宿主进程里第一次有人点名该 scope 时会重新 provision 它——手写进 patch 的 roster 层会就这样静默消失,下一次回读报「这个 scope 没有可读的 roster」。scope 自己那份声明是它活下来的方式,也是「两个 scope 各 roster 一个 preset」在一个**实例级** config 上唯一表达得出来的方式。
|
|
119
|
+
- **副本什么时候刷新**:只有带显式 `preset` 参数的那一次(评测的 `conditions provision`)会从部署的 preset 根重新同步。其余任何一次 provisioning 只在副本**不存在**时才复制,存在就原样留着并**报告**它是否仍与源逐字节相同——run 底下的受试对象不许被任何东西挪动。
|
|
120
|
+
- **作因子的 preset 不得写绝对路径**:同一份 preset 会被从三个目录读到(部署的 preset 根、scope 的副本、单元里的挂载点),绝对路径至少在其中两处是错的,而且 `skill-filesystem` 把读不到的根当空根,所以错得静音。要相对就用 loader 自己的表达式,官方 `cordis` preset 的写法:`!!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"`——`baseUrl` 是该组合文件自己的目录。带绝对路径的 preset 在快照这一步就被拒,消息里带这句写法。
|
|
121
|
+
|
|
122
|
+
**权限边界(`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`——与本字段出现之前逐字节相同。
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { provisionDshSubProfile, readSubProfilePermissions } from '@khorsheed/dsh-local-agent-dsh/provision'
|
|
126
|
+
|
|
127
|
+
provisionDshSubProfile(scopedHome, { permissions: 'danger-full-access' })
|
|
128
|
+
readSubProfilePermissions(scopedHome) // 'danger-full-access'——生成的层也是可解析的层
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**为什么钉在作用域目录而不是环境变量。** dsh-base 那两行本来读 `DSH_PERMISSION_MODE`,但这个值属于受试对象:写进子 profile,它就随作用域目录走——包括被 bind 挂进容器单元的那一份,`home.sha` 已经把它哈希在内;走 spawn env 则每个变量名都要进单元的复合指纹,且与条件无关的键越多,「只差一个因子」越难说清。层落在 base 层之后,所以字面值压过那个环境变量,边界不再取决于谁起的进程。
|
|
132
|
+
|
|
133
|
+
**什么时候该钉 `danger-full-access`。** 只在**别的东西已经是边界**的时候:一次性评测单元、用完即毁的容器。在开发机上子 dsh 共享的是真实 home,`workspace-write` 才是对的默认值——这也是不写这一项时的表现。反过来,**单元里不钉就是跑不了 shell**:容器镜像通常既无 bubblewrap,内核也不给 Landlock(实测 `landlock-run` 的探针报 `unusable`),沙箱按设计 fail closed 而不是悄悄不设防,于是每一笔 bash 都拿到 `SANDBOX_UNAVAILABLE`;无头子 dsh 又没有审批通道,模型按提示升档重试也只会拿到「没有审批通道」。两条一起,选手在单元里就是没有 shell(I5·T39 的 G14 实测)。
|
|
134
|
+
|
|
135
|
+
**容器内委派。** 编排器可以经门面 `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`。
|
|
136
|
+
|
|
137
|
+
**同一 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),本包零代码改动。
|
|
138
|
+
|
|
139
|
+
**DeepSeek 开关。** 与其他家族 harness 不同,本包默认不挂载任何模型可见的东西。互斥开关位于 dsh harness 行的动作区内(设置 → 本地 Agent,namespace `local-agent-dsh`,默认 off):OFF 时委派走官方 in-process subagent 工具;ON 时注册 `dsh` harness、`dsh-cli` 委派 provider 与家族工具 `subagent_dsh`,与官方工具并存——两种委派形态语义不同(in-process continuable vs. 独立 CLI 进程),家族工具描述让模型可以区分。开关经 settings watcher 实时翻转组合。
|
|
140
|
+
|
|
141
|
+
**委派。** 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` 续接同一会话。
|
|
142
|
+
|
|
143
|
+
**长驻驱动(`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 路径。
|
|
144
|
+
|
|
145
|
+
**认证与供给。** 无 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 构建。
|
|
146
|
+
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
## 开发
|
|
150
|
+
|
|
151
|
+
隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/local-agent-dsh`)。问题与贡献请移步该仓库。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# The dsh-local-agent-dsh bundle patch: the dsh harness controller. Unlike the
|
|
2
|
+
# other family bundles (kimi/codex/claude-code), this harness mounts nothing
|
|
3
|
+
# model-visible unless the user flips the DeepSeek switch in Settings → 本地
|
|
4
|
+
# Agent: the controller registers the `local-agent-dsh` settings namespace
|
|
5
|
+
# (default off) and, while it is ON, registers the `dsh` harness, the `dsh-cli`
|
|
6
|
+
# delegation provider, and the family delegation tool for it. While OFF — the
|
|
7
|
+
# default — the instance keeps exactly the current behavior (official
|
|
8
|
+
# in-process subagent tools only), so the model never sees both the dsh
|
|
9
|
+
# delegation tool and the official subagent tool at once.
|
|
10
|
+
#
|
|
11
|
+
# The sub-dsh half lives in the sibling bundle
|
|
12
|
+
# @khorsheed/dsh-local-agent-dsh-headless (its own profile under the scoped
|
|
13
|
+
# home), which this package resolves and provisions.
|
|
14
|
+
#
|
|
15
|
+
# This patch inserts only its own row: the family core row (`local-agent`,
|
|
16
|
+
# shared scoped-homes root) belongs to @khorsheed/dsh-local-agent's patch and
|
|
17
|
+
# is NOT mounted by this bundle. Depending on the core package makes its module
|
|
18
|
+
# resolvable, not its row active — `dsh plugin add` reconciles only the
|
|
19
|
+
# profile's direct dependencies, so install the core explicitly alongside this
|
|
20
|
+
# bundle.
|
|
21
|
+
|
|
22
|
+
- insert:
|
|
23
|
+
- id: local-agent-dsh
|
|
24
|
+
name: '@khorsheed/dsh-local-agent-dsh'
|