mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge origin/master into worktree/web-plugin-config
The web-app bundle gained dsh-app-boot on master beside this branch's dependency list; both rows stand, and verify-cordis-config confirms every patch row still resolves from that manifest.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md
|
||||
2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f
|
||||
2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880
|
||||
@@ -1,9 +1,12 @@
|
||||
# Agent Note: `dsh run` owns one-shot headless execution
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
English | [中文](2026-08-08-dsh-run-headless-command.zh.md)
|
||||
|
||||
> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless <task...>`, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry.
|
||||
@@ -1,9 +1,12 @@
|
||||
# Agent Note: `dsh run` 负责一次性 headless 执行
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
[English](2026-08-08-dsh-run-headless-command.md) | 中文
|
||||
|
||||
> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless <task...>` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。
|
||||
|
||||
## 问题
|
||||
|
||||
通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。
|
||||
@@ -253,6 +253,9 @@
|
||||
"feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa",
|
||||
"feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e",
|
||||
"feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03",
|
||||
"feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b",
|
||||
"feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c",
|
||||
"feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb",
|
||||
"process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
|
||||
"process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
|
||||
"process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc
|
||||
|
||||
@@ -34,27 +34,27 @@ Config discovery has two channels and fails loudly when both are missing: the `D
|
||||
|
||||
### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms.
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms.
|
||||
|
||||
The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`.
|
||||
|
||||
### Naming lineage
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`.
|
||||
`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
|
||||
## Disposition of worker-style plugins
|
||||
|
||||
@@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c
|
||||
|
||||
## Testing
|
||||
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
|
||||
|
||||
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
|
||||
|
||||
|
||||
@@ -34,27 +34,27 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
### 构建管线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。
|
||||
|
||||
exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。
|
||||
|
||||
### 命名血统
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。
|
||||
`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
|
||||
## 工作线程插件
|
||||
|
||||
@@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个
|
||||
|
||||
## 测试
|
||||
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1
|
||||
|
||||
@@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
|
||||
|
||||
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities:
|
||||
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation)
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation)
|
||||
- Launching inside Electron with the same Web technologies as `dsh web`
|
||||
|
||||
That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly.
|
||||
@@ -31,7 +31,7 @@ Directories layer as follows:
|
||||
- **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
|
||||
- `apps/` holds the externally exported applications, assembled from Client / Host mixtures.
|
||||
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer.
|
||||
- A future Electron application reuses the same web client packages over an IPC fetch carrier.
|
||||
|
||||
```
|
||||
@@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct
|
||||
2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
|
||||
3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports.
|
||||
|
||||
The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch.
|
||||
The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch.
|
||||
|
||||
## Message protocol
|
||||
|
||||
@@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
|
||||
|
||||
| Subclass | Package | doFetch | Purpose |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly |
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
|
||||
| IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged |
|
||||
|
||||
@@ -9,7 +9,7 @@ Status: implemented
|
||||
## Problem
|
||||
|
||||
需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力:
|
||||
- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留)
|
||||
- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留)
|
||||
- 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动
|
||||
|
||||
那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。
|
||||
@@ -29,7 +29,7 @@ Status: implemented
|
||||
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
|
||||
- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。
|
||||
- `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
|
||||
- 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。
|
||||
|
||||
```
|
||||
@@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
|
||||
2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
|
||||
3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。
|
||||
|
||||
现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。
|
||||
现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。
|
||||
|
||||
## 消息协议
|
||||
|
||||
@@ -213,7 +213,7 @@ export type ResponseValue<K> =
|
||||
|
||||
| 子类 | 所在包 | doFetch | 用途 |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core |
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
|
||||
| IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 5cf4a227f388a1ac8315594af4e0256864ef17f5
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: b5a52b3d01840337c0091310e50d8fac34245519
|
||||
|
||||
@@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`
|
||||
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
|
||||
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
|
||||
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path).
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh --profile headless` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path).
|
||||
|
||||
@@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`
|
||||
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
|
||||
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
|
||||
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh --profile headless` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
|
||||
2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92
|
||||
2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c
|
||||
2026-08-03-per-session-agent-presets.md: 5a82f0220058c10892b819a83499c817aa9be6ad
|
||||
2026-08-03-per-session-agent-presets.zh.md: 0fb3330014eb703c854f70c9e5ed552256651c31
|
||||
|
||||
@@ -18,7 +18,7 @@ Composition splits into two planes, decided by what must be shared rather than b
|
||||
|
||||
| Plane | Instances | Contents |
|
||||
|---|---|---|
|
||||
| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host |
|
||||
| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host |
|
||||
| Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy |
|
||||
|
||||
Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane.
|
||||
@@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
|
||||
|
||||
**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
|
||||
|
||||
**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.
|
||||
**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.
|
||||
|
||||
**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off.
|
||||
|
||||
@@ -78,3 +78,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
|
||||
**Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions.
|
||||
|
||||
**Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one.
|
||||
|
||||
**Give product subagents global enable settings and a separate settings page.** The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools.
|
||||
|
||||
**Ship one preset for every Codex and Claude Code combination.** Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result.
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
| 平面 | 实例数 | 内容 |
|
||||
|---|---|---|
|
||||
| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 |
|
||||
| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主 |
|
||||
| agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 |
|
||||
|
||||
模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。
|
||||
@@ -56,7 +56,7 @@ Status: implemented
|
||||
|
||||
**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。
|
||||
|
||||
**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。
|
||||
**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。
|
||||
|
||||
**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。
|
||||
|
||||
@@ -79,3 +79,7 @@ Status: implemented
|
||||
**把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。
|
||||
|
||||
**把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。
|
||||
|
||||
**给产品 subagent 增加全局启用设置与独立设置页。** 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。
|
||||
|
||||
**为 Codex 与 Claude Code 的每种组合交付一份 preset。** 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
|
||||
2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b
|
||||
2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da
|
||||
2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2
|
||||
2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77
|
||||
|
||||
@@ -10,11 +10,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
## Decision
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`.
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md).
|
||||
|
||||
The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile <name>] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile <name>` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects.
|
||||
The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
@@ -24,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th
|
||||
|
||||
- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan.
|
||||
- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
|
||||
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
|
||||
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
|
||||
- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -10,11 +10,9 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
|
||||
|
||||
随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile <name>] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile <name>` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。
|
||||
随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
|
||||
|
||||
@@ -24,7 +22,7 @@ Status: implemented
|
||||
|
||||
- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。
|
||||
- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
|
||||
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
|
||||
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
|
||||
- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md
|
||||
2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed
|
||||
2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
|
||||
2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073
|
||||
2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Apps own their command line through `ctx.cmdlineArgs`
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-app-owned-command-line.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume <session>` had nowhere to be parsed, and `dsh --profile web --help` printed the launcher's help rather than the web app's.
|
||||
|
||||
## Decision
|
||||
|
||||
The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help.
|
||||
|
||||
The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row.
|
||||
|
||||
The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset.
|
||||
|
||||
The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
|
||||
|
||||
Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup.
|
||||
|
||||
## Why Loader owns the ordering
|
||||
|
||||
Four framework facts shape the mechanism:
|
||||
|
||||
- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context.
|
||||
- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services.
|
||||
- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services.
|
||||
- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver.
|
||||
|
||||
This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it.
|
||||
- **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared.
|
||||
- **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context.
|
||||
- **The launcher running each bundle's command function before boot** (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary `cmdlineArgs`-injected provider keeps one protocol and remains dumpable and patchable.
|
||||
- **A launcher-enforced command-line owner**: rejecting zero or multiple readers would arbitrate overlaps such as `-h`, but `get()` is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition.
|
||||
- **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change.
|
||||
- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments.
|
||||
- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown.
|
||||
- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load.
|
||||
- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row.
|
||||
- Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`.
|
||||
- `--dump-config` never runs app command-line providers, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-app-owned-command-line.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume <session>` 没有地方可供解析,而 `dsh --profile web --help` 打印的是启动器的 help,而不是 web 应用的 help。
|
||||
|
||||
## 决策
|
||||
|
||||
启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。
|
||||
|
||||
新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
|
||||
|
||||
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
|
||||
|
||||
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
|
||||
|
||||
还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。
|
||||
|
||||
## 为什么由 Loader 持有顺序
|
||||
|
||||
四条框架事实塑造了这套机制:
|
||||
|
||||
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。
|
||||
- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。
|
||||
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
|
||||
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。
|
||||
|
||||
这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。
|
||||
- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。
|
||||
- **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。
|
||||
- **由启动器在 boot 之前运行每个组合包的命令函数**(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的普通提供方只保留一套协议,并且仍可 dump、可 patch。
|
||||
- **由启动器强制指定命令行所有者**:拒绝零个或多个读取方可以裁决 `-h` 等重叠项,但 `get()` 是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。
|
||||
- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。
|
||||
|
||||
## 后果
|
||||
|
||||
- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。
|
||||
- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。
|
||||
- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。
|
||||
- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
|
||||
- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。
|
||||
- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。
|
||||
- `--dump-config` 从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md
|
||||
2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676
|
||||
2026-08-09-headless-direct-core-entry-point.md: b705df2e6d88e096ee3ba50a6156b815dbd98b98
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: 439f4c21a2ce1741e8d483bc307508550da1bec7
|
||||
|
||||
@@ -20,11 +20,11 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl
|
||||
|
||||
`loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched.
|
||||
|
||||
This note owns the headless transport and completion contracts. [`dsh run` owns one-shot headless execution](../feature/2026-08-08-dsh-run-headless-command.md) owns the command grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default.
|
||||
This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default.
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh run` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal.
|
||||
Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh run` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`.
|
||||
`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`.
|
||||
|
||||
ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free.
|
||||
|
||||
@@ -20,11 +20,11 @@ Status: implemented
|
||||
|
||||
`loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。
|
||||
|
||||
本 Agent Note 负责 headless 的传输与完成约定。[`dsh run` 负责一次性 headless 执行](../feature/2026-08-08-dsh-run-headless-command.md)负责命令语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。
|
||||
本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。
|
||||
|
||||
## 验证
|
||||
|
||||
包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。
|
||||
包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -39,6 +39,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。
|
||||
`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。
|
||||
|
||||
ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md
|
||||
2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39
|
||||
2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75
|
||||
2026-08-09-layered-skill-registry.md: 73897c3cb7e0055ff59221b7ea47c5d6ced06991
|
||||
2026-08-09-layered-skill-registry.zh.md: 655780d4ef154434d6debf478134d3293d6c564f
|
||||
|
||||
@@ -24,7 +24,7 @@ The composition moves with it: the web-app bundle re-enables the base `skill` re
|
||||
|
||||
**A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only.
|
||||
|
||||
**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`.
|
||||
**Layer visibility and consumption stay separate choices.** A `minimal` agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`.
|
||||
|
||||
**Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和
|
||||
|
||||
**部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。
|
||||
|
||||
**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。
|
||||
**层可见性与消费仍是两个独立选择。** `minimal` agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。
|
||||
|
||||
**提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
|
||||
2026-08-10-product-subagent-providers-in-shared-host.md: 33b6eb6cf7a6c19e9ea71cdb7dc8881e8052ef24
|
||||
2026-08-10-product-subagent-providers-in-shared-host.zh.md: fd78c7a3fee4e4ee30d27d87c752e1a23576fd85
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Product subagent providers live in the shared profile host
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself.
|
||||
|
||||
The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts.
|
||||
|
||||
## Decision
|
||||
|
||||
Every shipped Profile loads the fixed `codex` and `claude-code` providers once through the base bundle's host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry.
|
||||
|
||||
This decision supersedes only the opt-in composition placement recorded by the provider-contract note. That note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
|
||||
|
||||
The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation.
|
||||
|
||||
The current base dependency closure still includes the Claude Agent SDK's optional platform CLI payload even though production resolves the host `claude`. Removing that unused payload belongs to the separate product installation-closure follow-up; this placement decision neither installs it dynamically nor treats it as the production executable.
|
||||
|
||||
## Verification
|
||||
|
||||
The base Loader test proves both provider names register exactly once and no product process starts during Profile boot. Real Agent Preset composition covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure, but a copied or agent-authored Preset row is not usable unless the person also discovers and edits a second composition layer. It leaves the general Preset entry incomplete for these otherwise ordinary tools.
|
||||
|
||||
**Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state.
|
||||
|
||||
**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime.
|
||||
|
||||
**Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state.
|
||||
|
||||
## Consequences
|
||||
|
||||
A user manages both products through the same Agent Preset authoring path as other plugins, and each new session receives exactly the tools its chosen preset contributes. Every Profile carries two dormant provider registrations, so unused products consume package and module-loading footprint but no product process, login, model call, or product home.
|
||||
|
||||
The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is the current Claude SDK optional-payload installation cost, which stays explicitly deferred rather than being hidden behind another enable state or installer lifecycle.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 产品 subagent 提供方位于共享 profile 宿主
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-product-subagent-providers-in-shared-host.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。
|
||||
|
||||
归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。
|
||||
|
||||
## 决策
|
||||
|
||||
每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。
|
||||
|
||||
本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
|
||||
|
||||
这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。
|
||||
|
||||
当前 base 依赖闭包仍包含 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷,尽管生产环境解析的是宿主提供的 `claude`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。
|
||||
|
||||
## 验证
|
||||
|
||||
base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。
|
||||
|
||||
**存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。
|
||||
|
||||
**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。
|
||||
|
||||
**交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。
|
||||
|
||||
## 后果
|
||||
|
||||
用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。
|
||||
|
||||
宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
|
||||
2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661
|
||||
2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350
|
||||
2026-08-03-cli-signal-shutdown-escalation.md: 173d06482cd8a1fcbb985763cc313e3f9b170bc6
|
||||
2026-08-03-cli-signal-shutdown-escalation.zh.md: efa52524199906cf636cb2b55cb4249857dc1348
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound.
|
||||
The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh --profile headless`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound.
|
||||
|
||||
A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。
|
||||
默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh --profile headless`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。
|
||||
|
||||
随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md
|
||||
2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3
|
||||
2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: The minimal preset owns the complete RL agent composition
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected.
|
||||
|
||||
The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product.
|
||||
|
||||
## Decision
|
||||
|
||||
The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract.
|
||||
|
||||
The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text.
|
||||
|
||||
The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process.
|
||||
|
||||
## Verification
|
||||
|
||||
System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path.
|
||||
|
||||
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other.
|
||||
|
||||
**Disable every known prompt contributor in the preset.** Rejected because host rows are process-wide and new contributors would reopen the prompt. A final complete-section constraint expresses the negative guarantee at the registry that assembles the prompt.
|
||||
|
||||
**Filter sections only with a prepended waterfall listener.** Rejected because another prepended wrapper can run outside it and append after the filter. Enforcement after the complete waterfall has stable final authority.
|
||||
|
||||
**Mount PTY services on the Web host.** Rejected because only the minimal agent consumes them. An entry-local `pty` realm gives the services the same lifetime and scope as their sole consumer without publishing a process-global service from a preset.
|
||||
|
||||
## Consequences
|
||||
|
||||
The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: minimal preset 拥有完整的 RL agent 组合
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-minimal-preset-owns-rl-composition.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智能体):进程级 `core-web.cordis.yml` patch,以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后,preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset,而 preset 测试启动时没有 overlay,因此两者都没有覆盖用户实际选择的组合。
|
||||
|
||||
这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash,而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩(compaction)策略。保留两个所有者,会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。
|
||||
|
||||
## 决策
|
||||
|
||||
随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。
|
||||
|
||||
preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。
|
||||
|
||||
进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 只会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。
|
||||
|
||||
## 验证
|
||||
|
||||
系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。
|
||||
|
||||
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 为内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。
|
||||
|
||||
**在 preset 中禁用每个已知的提示词贡献方。** 被拒绝,因为宿主行属于整个进程,新的贡献方也会重新开放提示词。由组装提示词的注册表实施最终 complete 段约束,才能表达这项否定保证。
|
||||
|
||||
**仅使用前置 waterfall 监听器筛选段落。** 被拒绝,因为另一个前置包装层可以在该监听器外执行,并在筛选后追加内容。在整个 waterfall 结束后实施约束,才能稳定拥有最终决定权。
|
||||
|
||||
**在 Web 宿主上挂载 PTY 服务。** 被拒绝,因为只有 minimal agent 消费这些服务。entry 本地的 `pty` realm 与唯一消费方具有相同的生命周期和作用域,无需由 preset 发布进程级全局服务。
|
||||
|
||||
## 后果
|
||||
|
||||
RL 提示词固定不变,不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
|
||||
2026-07-29-persistent-bash-str-replace-editor.md: c4750e30370bfd253064c39cb1adc0f5b2baa60d
|
||||
2026-07-29-persistent-bash-str-replace-editor.zh.md: 83159d9792fd9fadaaa342cc289300b35da34e4a
|
||||
2026-07-29-persistent-bash-str-replace-editor.md: 2c077a08e6027245779a0db364c83d17a9c74fce
|
||||
2026-07-29-persistent-bash-str-replace-editor.zh.md: f642f2100cbc40ddf688400c5e6124ca9a6ff72d
|
||||
|
||||
@@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m
|
||||
|
||||
Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable.
|
||||
|
||||
The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface for the Claude SWE-compatible RL contract. It pins native tool mode and makes the complete system prompt `DSH_SYSTEM_PROMPT` when set or `You are a helpful software engineer assistant.` otherwise, with no harness identity, source-checkout section, Web orientation, Workspace instructions, or tool-mode guidance. It disables every other model-facing consumer, so the model receives exactly the persistent `bash` and `str_replace_editor` schemas, while the Web host, browser, Workspace, persistence, sandbox, and permission stack remains in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox.
|
||||
The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,4 +32,4 @@ The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.
|
||||
|
||||
## Consequences
|
||||
|
||||
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
|
||||
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。
|
||||
|
||||
已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay 会在常规 Web 界面之上组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。它固定使用原生工具模式;完整的系统提示词在设置 `DSH_SYSTEM_PROMPT` 时采用其值,否则采用 `You are a helpful software engineer assistant.`,且不包含 harness 身份、源码 checkout 提示词段、Web 界面定位、Workspace 指令或工具模式指引。它会禁用其他所有面向模型的消费方,使模型恰好只收到持久 `bash` 和 `str_replace_editor` 两个 schema,同时保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。
|
||||
随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -32,4 +32,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。
|
||||
Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
|
||||
2026-07-31-even-out-shipped-tool-rosters.md: 0195620055da5e570d2f54792d950a88bab8d652
|
||||
2026-07-31-even-out-shipped-tool-rosters.zh.md: ab6982e33c4a0a25cbc2fde386456840ce99d9c5
|
||||
2026-07-31-even-out-shipped-tool-rosters.md: 7647506e5d9c39d64b686ab18923f9681a48cd87
|
||||
2026-07-31-even-out-shipped-tool-rosters.zh.md: f04a59c9f00455b00f70975ad9b6bd4defd3d847
|
||||
|
||||
@@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask
|
||||
|
||||
## Decision
|
||||
|
||||
The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands.
|
||||
The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`.
|
||||
|
||||
Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
|
||||
|
||||
**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md).
|
||||
**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. The later session-search and single-editor decisions own their respective default-roster exceptions. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md).
|
||||
|
||||
### What stays unmounted, and why
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。
|
||||
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。
|
||||
|
||||
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
|
||||
|
||||
**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。
|
||||
**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。后续的 session-search 与单一编辑器决策分别负责对应的默认清单例外。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。
|
||||
|
||||
### 什么保持不挂,以及为什么
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md
|
||||
2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a
|
||||
2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845
|
||||
2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c
|
||||
2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8
|
||||
|
||||
@@ -10,7 +10,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made
|
||||
|
||||
## Decision
|
||||
|
||||
The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies.
|
||||
The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies.
|
||||
|
||||
The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。
|
||||
交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。
|
||||
|
||||
`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.md: 2063b99a7b0ca34f434b3e56628f3ce765d90d81
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 71d4a8e9fc060f8c96483b22641fd67ee1f71c08
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.md: ccc96d6c998c4ab958a7eea1e502d036d16ec90d
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d
|
||||
|
||||
@@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance
|
||||
|
||||
## Decision
|
||||
|
||||
The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments.
|
||||
The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [shared-profile-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md) supersedes the original opt-in composition placement. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection and background execution are not model arguments.
|
||||
|
||||
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation.
|
||||
|
||||
@@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp
|
||||
|
||||
## Claude Code provider
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
|
||||
`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
|
||||
|
||||
The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own.
|
||||
|
||||
@@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea
|
||||
|
||||
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
|
||||
|
||||
The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts.
|
||||
The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`.
|
||||
|
||||
The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
|
||||
|
||||
@@ -87,7 +87,7 @@ The project owner's distribution authorization is scoped to the official `@anthr
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence.
|
||||
Users delegate through two stable foreground tools backed by the official product integrations. Their Profile placement and per-Preset exposure are owned by the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); this note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence.
|
||||
|
||||
Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。
|
||||
harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。
|
||||
|
||||
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。
|
||||
|
||||
@@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
|
||||
|
||||
## Claude Code 提供方
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
|
||||
`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
|
||||
|
||||
公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
|
||||
|
||||
@@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实
|
||||
|
||||
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
|
||||
|
||||
Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。
|
||||
Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。
|
||||
|
||||
带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
|
||||
|
||||
@@ -79,7 +79,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1
|
||||
|
||||
**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。
|
||||
|
||||
**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。
|
||||
**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。
|
||||
|
||||
**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。
|
||||
|
||||
@@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1
|
||||
|
||||
## 后果
|
||||
|
||||
用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 约定委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。
|
||||
用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。
|
||||
|
||||
每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
|
||||
2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b
|
||||
2026-07-20-remove-stdio-and-echo-agents.zh.md: 013135eff5d1dbbc570561e70c751ff4de289989
|
||||
2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad
|
||||
2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88
|
||||
|
||||
@@ -19,7 +19,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com
|
||||
The remaining application roles are explicit:
|
||||
|
||||
- `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`.
|
||||
- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver.
|
||||
- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver.
|
||||
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations.
|
||||
|
||||
The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents.
|
||||
@@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt
|
||||
|
||||
TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references.
|
||||
|
||||
The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior.
|
||||
The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个
|
||||
保留的应用角色均有明确归属:
|
||||
|
||||
- `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。
|
||||
- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。
|
||||
- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。
|
||||
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。
|
||||
|
||||
SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。
|
||||
@@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换
|
||||
|
||||
TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。
|
||||
|
||||
构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。
|
||||
构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md
|
||||
2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9
|
||||
2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6
|
||||
2026-08-08-remove-cli-demo.md: 31879e8284daf5f34af6731b09fce743f9eb5391
|
||||
2026-08-08-remove-cli-demo.zh.md: d4dfef1ba8c4d27cd667e319519cfa1ace74baef
|
||||
|
||||
@@ -6,29 +6,29 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands.
|
||||
After [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands.
|
||||
|
||||
The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract.
|
||||
|
||||
## Decision
|
||||
|
||||
Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown.
|
||||
Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh --profile headless`; the product command owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown.
|
||||
|
||||
`examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability.
|
||||
- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery.
|
||||
- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh --profile headless`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability.
|
||||
- **Move JSON and stream-JSON flags onto `dsh --profile headless`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery.
|
||||
- **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe.
|
||||
- **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts.
|
||||
|
||||
## Consequences
|
||||
|
||||
This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation.
|
||||
This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh --profile headless` for one-shot execution and must choose an existing protocol surface when they need structured automation.
|
||||
|
||||
The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough.
|
||||
The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh --profile headless`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten.
|
||||
Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh --profile headless`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten.
|
||||
|
||||
@@ -6,29 +6,29 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。
|
||||
在 [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。
|
||||
|
||||
回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性约定。
|
||||
|
||||
## 决策
|
||||
|
||||
彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。
|
||||
彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh --profile headless` 的直接别名保留;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由产品命令负责。
|
||||
|
||||
`examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。
|
||||
- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。
|
||||
- **保留 `dsh-cli-demo` 作为 `dsh --profile headless` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。
|
||||
- **把 JSON 和 stream-JSON 标志移到 `dsh --profile headless`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。
|
||||
- **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。
|
||||
- **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。
|
||||
|
||||
## 后果
|
||||
|
||||
这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。
|
||||
这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh --profile headless` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。
|
||||
|
||||
仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。
|
||||
仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh --profile headless`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。
|
||||
聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh --profile headless`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md
|
||||
2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4
|
||||
2026-08-10-default-presets-single-editor.zh.md: 22bbc19feadfb71fe3a9480bc60b8777e1fb50c2
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: One editor family in general-purpose presets
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-default-presets-single-editor.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`edit` filesystem tools and `str_replace_editor`. The two interfaces overlap for ordinary file inspection and editing, so every request carried an additional tool schema without adding a distinct default capability. The `minimal` preset has a different composition contract: its exact two-tool roster intentionally includes `str_replace_editor` beside persistent `bash`.
|
||||
|
||||
## Decision
|
||||
|
||||
The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly.
|
||||
|
||||
This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep both editing interfaces in the general-purpose presets.** Rejected because the overlapping model-visible schemas increase tool choice without supplying a separate default operation.
|
||||
|
||||
**Remove `str_replace_editor` from every shipped composition.** Rejected because the `minimal` preset intentionally exposes that schema as one of its two tools, and explicit deployments remain valid consumers of the standalone plugin.
|
||||
|
||||
## Consequences
|
||||
|
||||
General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 通用 preset 只提供一套编辑工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-default-presets-single-editor.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`standard`、`code` 和 `cordis` preset 同时提供 `read`/`write`/`edit` 文件系统工具与 `str_replace_editor`。两套接口在常规文件查看和编辑上重叠,导致每次请求都携带额外的工具 schema,却没有增加独立的默认能力。`minimal` preset 具有不同的组合约定:它固定的双工具清单有意在持久 `bash` 之外提供 `str_replace_editor`。
|
||||
|
||||
## 决策
|
||||
|
||||
`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。
|
||||
|
||||
此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在通用 preset 中保留两套编辑接口。** 不予采用,因为重叠的模型可见 schema 增加了工具选择,却没有提供不同的默认操作。
|
||||
|
||||
**从所有交付组合中移除 `str_replace_editor`。** 不予采用,因为 `minimal` preset 有意将该 schema 作为两个工具之一,显式部署仍是该独立插件的有效消费方。
|
||||
|
||||
## 后果
|
||||
|
||||
通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。
|
||||
12
.github/workflows/build-exe-for-python-sdk.yml
vendored
12
.github/workflows/build-exe-for-python-sdk.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
sdk-wheel:
|
||||
needs: plan
|
||||
name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -113,8 +113,8 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build:
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
path: dist-python
|
||||
|
||||
- name: Install only the SDK into a clean venv and run zero-config
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
python -m venv "$RUNNER_TEMP/dsh-sdk-smoke"
|
||||
"$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \
|
||||
--find-links dist-python \
|
||||
deepseek-harness=="$VERSION"
|
||||
deepseek-harness-sdk=="$VERSION"
|
||||
"$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \
|
||||
--scenario sdk-default
|
||||
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
esac
|
||||
docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c '
|
||||
/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk
|
||||
/tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION"
|
||||
/tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION"
|
||||
/tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
'
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ sdk-wheel:
|
||||
- uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE"
|
||||
- python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM"
|
||||
- python -m venv .wheel-smoke
|
||||
- .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION"
|
||||
- .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION"
|
||||
- .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
- |
|
||||
if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then
|
||||
@@ -55,7 +55,7 @@ sdk-wheel:
|
||||
linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
|
||||
*) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;;
|
||||
esac
|
||||
docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default"
|
||||
docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default"
|
||||
fi
|
||||
artifacts:
|
||||
paths: [release/$PLATFORM/*.whl]
|
||||
@@ -112,7 +112,7 @@ publish-python:
|
||||
- python -m pip install twine==6.2.0
|
||||
script:
|
||||
- test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4
|
||||
- test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl"
|
||||
- test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl"
|
||||
- test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl"
|
||||
- test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl"
|
||||
- test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write README.md
|
||||
README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb
|
||||
README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65
|
||||
README.md: 9b8944027572072e08a39bd9e482996f8128224c
|
||||
README.zh.md: a1b2b6a36c4baac8a49b88a42c9f17c986287ff1
|
||||
|
||||
@@ -56,7 +56,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer
|
||||
Run one task, print the final answer, and exit:
|
||||
|
||||
```sh
|
||||
dsh run "summarize this workspace"
|
||||
dsh --profile headless "summarize this workspace"
|
||||
```
|
||||
|
||||
### Automation and SDKs
|
||||
|
||||
@@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)
|
||||
运行一项任务,打印最终答案后退出:
|
||||
|
||||
```sh
|
||||
dsh run "summarize this workspace"
|
||||
dsh --profile headless "summarize this workspace"
|
||||
```
|
||||
|
||||
### 自动化与 SDK
|
||||
|
||||
@@ -180,7 +180,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend |
|
||||
| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` |
|
||||
| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` |
|
||||
| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
|
||||
| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: dd29f7fc03a783079ea3194de99589c1f545be5b
|
||||
README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc
|
||||
README.md: 4fae5338a89ce12c2620e123530acf883ae9efff
|
||||
README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab
|
||||
|
||||
@@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. |
|
||||
| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). |
|
||||
| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `dsh web` | Alias of `--profile web`. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
|
||||
## App arguments
|
||||
|
||||
The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
|
||||
|
||||
|
||||
@@ -9,18 +9,30 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 |
|
||||
| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 |
|
||||
| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh web` | `--profile web` 的别名。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
|
||||
## 应用参数
|
||||
|
||||
启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profile
|
||||
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
|
||||
|
||||
@@ -110,6 +110,10 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_subagent_spawn
|
||||
plugin_dsh_base_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
|
||||
cfg --> plugin_dsh_base_subagent_fork
|
||||
plugin_dsh_base_subagent_codex["subagent-codex<br/>@deepseek-ai/dsh-subagent-codex"]
|
||||
cfg --> plugin_dsh_base_subagent_codex
|
||||
plugin_dsh_base_subagent_claude_code["subagent-claude-code<br/>@deepseek-ai/dsh-subagent-claude-code"]
|
||||
cfg --> plugin_dsh_base_subagent_claude_code
|
||||
plugin_dsh_base_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
|
||||
cfg --> plugin_dsh_base_tool_subagent_control
|
||||
plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
|
||||
@@ -215,6 +219,8 @@ flowchart LR
|
||||
| `subagent` | `@deepseek-ai/dsh-subagent` |
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` |
|
||||
| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` |
|
||||
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
|
||||
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
|
||||
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -63,11 +63,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -195,6 +190,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -57,11 +57,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -182,6 +177,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre
|
||||
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
|
||||
|
||||
### Native product subagents
|
||||
|
||||
Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field.
|
||||
|
||||
Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:
|
||||
|
||||
```yaml
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product.
|
||||
|
||||
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
# The `minimal` agent preset: the two-tool benchmark surface.
|
||||
# The `minimal` agent preset: a fixed-prompt, two-tool coding surface.
|
||||
#
|
||||
# The native model surface is exactly persistent `bash` plus
|
||||
# `str_replace_editor`. Everything else a session could reach — skills, goals,
|
||||
# plan mode, delegation, workflows, todo, web — is simply absent rather than
|
||||
# disabled, because a preset composes what an agent has instead of subtracting
|
||||
# from a shared default.
|
||||
#
|
||||
# The host composition is unchanged: this agent still runs inside the same
|
||||
# sandbox, approval, persistence, and model routing as any other session.
|
||||
# The persona is the complete system prompt, so global identity, Web surface,
|
||||
# tool guidance, and later assembly listeners cannot add prompt text. The model
|
||||
# composes only the persistent `bash` and `str_replace_editor` tools.
|
||||
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
text: You are a helpful software engineer assistant.
|
||||
complete: true
|
||||
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
#
|
||||
# `run_in_background` is off because this preset mounts no `tool-tasks`. The
|
||||
# host registry already refuses a start for an owner no attached control
|
||||
# surface serves, so this is not the safety boundary — it is the model-facing
|
||||
# one: an agent that could never collect a task should not be offered the
|
||||
# parameter at all, and disabling it drops the parameter from the schema.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
# The PTY registry is an agent-owned service, so it lives in an entry-local
|
||||
# realm. The backend still consumes the host sandbox policy and subprocess
|
||||
# implementation, while the tool registers into this agent's scoped catalog.
|
||||
- id: persistent-shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
pty: true
|
||||
config:
|
||||
enableRunInBackground: false
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
description: |-
|
||||
Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
|
||||
|
||||
# The editor requires absolute paths unconditionally.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# Model capacity comes from routed model metadata; this block states the
|
||||
# compaction policy explicitly.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
tokenMeter: true
|
||||
compact: true
|
||||
config:
|
||||
- id: token-meter
|
||||
name: '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainTokens: 20480
|
||||
summarizationProvider: ''
|
||||
summarizationModel: ''
|
||||
maxTokens: 8192
|
||||
compactionRetries: 1
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
name: 极简模式
|
||||
description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
order: 3
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -56,11 +56,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -194,6 +189,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Opt-in Web shell for the RL core agent contract. The model receives exactly
|
||||
# the configured persona plus the native `bash` and `str_replace_editor`
|
||||
# schemas; the Web host, browser shell, persistence, and permission stack stay.
|
||||
|
||||
# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's
|
||||
# surface context removes its GUI orientation, managed shell variables, and the
|
||||
# launcher's source-checkout section through one configuration contract.
|
||||
# Workspace instructions are model-visible user context rather than a system
|
||||
# section, but RL core disables them as part of the same prompt contract.
|
||||
- id: system-prompt
|
||||
config:
|
||||
includeHarnessIdentity: false
|
||||
persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'
|
||||
|
||||
- id: web-runtime
|
||||
config:
|
||||
surfaceContext: false
|
||||
|
||||
- id: workspace-context
|
||||
disabled: true
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
mode: native
|
||||
|
||||
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
|
||||
# always-registered exit_plan_mode tool even while the session is not planning.
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
# These consumers are shared defaults on the ordinary shipped surfaces, but
|
||||
# this opt-in profile keeps exactly its two named tools.
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The matching browser controls must not offer surfaces whose tool this
|
||||
# overlay omits: the panels would render for a capability the model does not
|
||||
# have. Turning the row off no longer removes a tool — `ui-question`'s host
|
||||
# half is empty and `tool-ask-user` is composed per preset — so this is a UI
|
||||
# decision now, not a capability one.
|
||||
- id: ui-plan
|
||||
disabled: true
|
||||
|
||||
- id: ui-question
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
# This backend consumes the existing Web sandbox and permission policy.
|
||||
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
|
||||
# Its 300s send wait matches the persistent Bash command timeout instead of
|
||||
# pty-local's 30s default. An open persistent shell fences permission-mode
|
||||
# changes until it closes.
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
# The editor consumes the Web fs-sandbox provider and therefore retains
|
||||
# the selected session permission mode.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
@@ -27,6 +27,8 @@
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
README.md: 7f0bd7b0bd50482b3ba7ee95adf6aaf1defb6d28
|
||||
README.zh.md: 55f23c5644b8b063ef12fb56fa587ff5d2eb21a2
|
||||
README.md: fd0647347312051a1814a5e3464b34032ae70dfc
|
||||
README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
|
||||
## Profile boot
|
||||
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch <path>` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile <name> "<task>"` command instead of reaching the row's raw required-field error.
|
||||
### App arguments
|
||||
|
||||
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
|
||||
|
||||
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
|
||||
|
||||
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
|
||||
|
||||
The shipped apps own these command lines:
|
||||
|
||||
| Profile | Arguments |
|
||||
|---|---|
|
||||
| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` |
|
||||
| `headless` | the task text, as the positional argument |
|
||||
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
|
||||
Inspect the composed tree without booting it:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
|
||||
## One-shot run
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row.
|
||||
|
||||
The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare
|
||||
|
||||
## Web alias
|
||||
|
||||
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
|
||||
@@ -59,9 +69,7 @@ All modes treat the invoking directory as the default workspace root, load appli
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
|
||||
|
||||
`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback.
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
|
||||
## Profile 启动
|
||||
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch <path>` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
|
||||
组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
|
||||
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。
|
||||
### 应用参数
|
||||
|
||||
启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。
|
||||
|
||||
一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。
|
||||
|
||||
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
|
||||
|
||||
随附的各应用持有这些命令行:
|
||||
|
||||
| Profile | 参数 |
|
||||
|---|---|
|
||||
| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` |
|
||||
| `headless` | 任务文本,作为位置参数 |
|
||||
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
|
||||
可在不启动的情况下检查组合出的配置树:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
|
||||
## 一次性运行
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。
|
||||
|
||||
启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
|
||||
|
||||
## Web 别名
|
||||
|
||||
`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
|
||||
@@ -59,9 +69,7 @@ dsh web --dump-config
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。
|
||||
|
||||
`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
||||
* overlays. `run` owns one-shot task execution, defaulting to the headless
|
||||
* profile; `web` is a hardcoded alias for `--profile web` that adds the Web
|
||||
* flag family; `plugin` manages a profile's plugin dependencies by forwarding
|
||||
* to pnpm. Commander owns help, version, and parse errors.
|
||||
* Commander adapter for the `dsh` command line.
|
||||
*
|
||||
* The launcher parses only what it owns — which profile to boot, which extra
|
||||
* patch overlays to apply, and the config dumps — and hands **everything after
|
||||
* its own flags** to the booted tree verbatim, where injected app plugins parse
|
||||
* their own flag families and print their own `--help` (see
|
||||
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
|
||||
* token this parser does not recognize starts the inner arguments, so
|
||||
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
|
||||
* and `dsh --profile web -h` prints the web app's help, not this one's.
|
||||
*
|
||||
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
|
||||
* plugin dependencies by forwarding to pnpm.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/** Boot a named profile. */
|
||||
/** Boot a named profile and hand it the invocation's inner arguments. */
|
||||
interface ProfileInvocation {
|
||||
mode: 'profile'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/** Run one task through a profile mounting the headless runner. */
|
||||
interface RunInvocation {
|
||||
mode: 'run'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
/** Non-blank task text joined from the variadic positional arguments. */
|
||||
task: string
|
||||
/** Everything after the launcher's own flags, verbatim, for injected app plugins. */
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Print a composed profile tree and exit without booting. */
|
||||
@@ -37,21 +36,6 @@ interface DumpConfigInvocation {
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
|
||||
* unvalidated pass-throughs to the webserver schema; absent values leave the
|
||||
* shipped web bundle values intact.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
/** Extra authorities for the /api browser-trust fence. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
|
||||
interface PluginInvocation {
|
||||
mode: 'plugin'
|
||||
@@ -61,31 +45,63 @@ interface PluginInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
/** Launcher flags shared by the default command and the `web` alias. */
|
||||
interface BootOptions {
|
||||
patch?: string[]
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}
|
||||
|
||||
/** Raw run-subcommand options straight from Commander. */
|
||||
interface RunOptions {
|
||||
profile: string
|
||||
patch?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
|
||||
* variadic — a variadic `--patch` would swallow a following positional task.
|
||||
* variadic — a variadic `--patch` would swallow the inner arguments.
|
||||
*/
|
||||
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
|
||||
|
||||
/** The launcher's own help text; each app prints its own. */
|
||||
const HELP_EXAMPLES = `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh --profile headless "run the tests" answer one task, print the result, and exit
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
|
||||
dsh --profile web --help the web app's own flags and help
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
`
|
||||
|
||||
/**
|
||||
* Resolve a boot or dump invocation from the launcher flags and the leftover
|
||||
* inner arguments.
|
||||
* @param program - the command whose options were parsed (the root, or the `web` alias).
|
||||
* @param profile - the profile these flags boot.
|
||||
* @param options - the launcher flags commander collected.
|
||||
* @param args - the leftover arguments, in argv order.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
|
||||
return { mode: 'profile', profile, patches, args }
|
||||
}
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
// The dump is boot-free: it never runs app command-line providers, so it
|
||||
// cannot show what those flags would decide, and printing a tree that differs
|
||||
// from the same invocation's boot would mislead.
|
||||
if (args.length > 0) {
|
||||
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
return { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve argv into one invocation, or print and exit for help, version, or an
|
||||
* error.
|
||||
@@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
// Annotated, not inferred: the actions below call back into `program`, and an
|
||||
// inferred type would be circular through its own chain.
|
||||
const program: Command = new Command()
|
||||
program
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh run "run the tests" answer one task, print the result, and exit
|
||||
dsh run --profile custom "run the tests" run one task through a custom one-shot profile
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
dsh web --port 8080 the web alias with its flag family
|
||||
`)
|
||||
.addHelpText('after', HELP_EXAMPLES)
|
||||
.exitOverride()
|
||||
// The launcher's flags come first and end at the first token it does not
|
||||
// know; everything from there on belongs to the booted app, including
|
||||
// its -h. `dsh -h` with no profile still prints this help, below.
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
|
||||
.action((options: {
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}) => {
|
||||
const profile = options.profile ?? program.error('error: --profile <name> is required')
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
return
|
||||
.action((args: string[], options: BootOptions & { profile?: string }) => {
|
||||
// With the app owning -h, the launcher's own help is what a bare
|
||||
// `dsh -h` (no profile to hand it to) must print.
|
||||
if (options.profile === undefined) {
|
||||
if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
|
||||
program.error('error: --profile <name> is required')
|
||||
}
|
||||
resolved = { mode: 'profile', profile, patches }
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
resolved = resolveBoot(program, profile, options, args)
|
||||
})
|
||||
|
||||
/** Reject parent options supplied before a subcommand. */
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}>()
|
||||
const parent = program.opts<BootOptions & { profile?: string }>()
|
||||
if (parent.profile !== undefined || parent.patch !== undefined
|
||||
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
|
||||
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
|
||||
}
|
||||
}
|
||||
|
||||
const run = program.command('run').description('run one task through a profile mounting the headless runner')
|
||||
run
|
||||
.option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.argument('<task...>', 'task text')
|
||||
.action((task: string[], options: RunOptions) => {
|
||||
rejectParentOptions('run')
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
const joined = task.join(' ')
|
||||
if (joined.trim() === '') program.error('error: run needs a non-blank task')
|
||||
resolved = { mode: 'run', profile, patches, task: joined }
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
|
||||
const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
|
||||
web
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the web app (see: dsh web --help)')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
|
||||
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
.action((args: string[], options: BootOptions) => {
|
||||
rejectParentOptions('web')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
// The dump is boot-free and does not derive flag patches; silently
|
||||
// dropping them would print a tree that differs from the same
|
||||
// invocation's boot.
|
||||
if (options.host !== undefined || options.port !== undefined || options.dev === true
|
||||
|| options.trustedHost !== undefined) {
|
||||
program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
||||
return
|
||||
}
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
resolved = {
|
||||
mode: 'web',
|
||||
patches,
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
resolved = resolveBoot(web, 'web', options, args)
|
||||
})
|
||||
|
||||
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
|
||||
|
||||
@@ -33,24 +33,10 @@ switch (invocation.mode) {
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
args: invocation.args,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'run': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
task: invocation.task,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
const { runPlugin } = await import('./plugin.ts')
|
||||
process.exit(runPlugin(invocation.profile, invocation.args))
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
|
||||
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
|
||||
* switch), mount the tree over the profile's empty root config, keep the
|
||||
* profile patch layer live, and wire fail-loud plus bounded shutdown.
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
|
||||
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
||||
* tree over the profile's empty root config, keep the profile patch layer
|
||||
* live, and wire fail-loud plus bounded shutdown.
|
||||
*
|
||||
* App flags are not the launcher's business: the invocation's inner arguments
|
||||
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
||||
* plugin may read the same immutable snapshot.
|
||||
* @module @deepseek-ai/dsh/profile-boot
|
||||
*/
|
||||
|
||||
@@ -12,7 +16,7 @@ import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import {
|
||||
boot,
|
||||
composeEntries,
|
||||
@@ -25,7 +29,7 @@ import {
|
||||
watchUserPatches,
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
|
||||
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
|
||||
@@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
|
||||
/** Harness-home directory holding locally authored agent presets. */
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
import { resolveWindowsShellLayer } from './windows-shell.ts'
|
||||
@@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
|
||||
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
|
||||
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
||||
|
||||
/** The one-shot runner row a `dsh run` task requires and configures. */
|
||||
/** The one-shot runner row: its presence means this composition exits by itself. */
|
||||
const HEADLESS_ROW_ID = 'headless-runner'
|
||||
|
||||
/** The empty root entry list every profile tree patches over. */
|
||||
@@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile {
|
||||
return profile
|
||||
}
|
||||
|
||||
/** Read-only row index of a profile composition before launcher flag patches. */
|
||||
export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
|
||||
|
||||
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
|
||||
interface ComposedProfile {
|
||||
profile: Profile
|
||||
@@ -116,14 +118,13 @@ interface ComposedProfile {
|
||||
windowsShellPatches: PatchOptions[]
|
||||
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
|
||||
homePatches: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
|
||||
overlayAndFlags: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
|
||||
overlays: PatchOptions[]
|
||||
/**
|
||||
* id → row of the pre-flag composition (bundles + user layers + overlays),
|
||||
* for flag merges and row checks. Flag patches must not insert rows the
|
||||
* launcher consults here (they only override values and insert dev glue).
|
||||
* id → row of the composed tree (bundles + user layers + overlays), for the
|
||||
* launcher's own row checks.
|
||||
*/
|
||||
rows: ProfileRows
|
||||
rows: ReadonlyMap<string, EntryOptions>
|
||||
}
|
||||
|
||||
/** The full patch stack of one composed profile, in application order. */
|
||||
@@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
...composed.windowsShellPatches,
|
||||
...composed.profile.patches,
|
||||
...composed.homePatches,
|
||||
...composed.overlayAndFlags,
|
||||
...composed.overlays,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
* is Windows), the profile's user layer, the home-level user layer
|
||||
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
|
||||
* every profile, so it outranks the per-profile layer), `--patch` overlays,
|
||||
* then flag patches derived from the composed rows, then the telemetry
|
||||
* switch.
|
||||
* then the telemetry switch.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
|
||||
* @returns the profile, its patch layers, and the composed row index.
|
||||
*/
|
||||
function composeProfile(
|
||||
name: string,
|
||||
patchFiles: readonly string[],
|
||||
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
|
||||
): ComposedProfile {
|
||||
const profile = prepareProfile(name)
|
||||
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
|
||||
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
||||
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
||||
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
|
||||
const rows = new Map<string, { name?: string; config?: unknown }>()
|
||||
const rows = new Map<string, EntryOptions>()
|
||||
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
|
||||
// The agent-preset roots are an assembly fact of every dsh launcher, not a
|
||||
// patch author's choice: the shipped set sits beside this app's config and
|
||||
// the user's own under the Harness home. Resolved per boot ($DSH_HOME may
|
||||
// differ per run) and only patched when the composed tree actually mounts
|
||||
// the roster — a one-shot `dsh run` composes agents from the same roster
|
||||
// `dsh web` offers.
|
||||
const composedOverlays = [...overlays]
|
||||
// Preset roots belong to every dsh composition that mounts the roster.
|
||||
if (rows.has('agent-presets')) {
|
||||
overlayAndFlags.push({
|
||||
composedOverlays.push({
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
|
||||
@@ -184,24 +177,20 @@ function composeProfile(
|
||||
})
|
||||
}
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
||||
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
|
||||
return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows }
|
||||
if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
|
||||
return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
|
||||
}
|
||||
|
||||
/** Options for {@link runProfile}. */
|
||||
export interface RunProfileOptions {
|
||||
/** This run's frozen environment snapshot, provided before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The profile name to boot. */
|
||||
profile: string
|
||||
/** `--patch` overlay paths, in argv order. */
|
||||
patchFiles: readonly string[]
|
||||
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
|
||||
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
|
||||
/** `dsh run` task text; requires the composition to mount the headless runner row. */
|
||||
task?: string
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
|
||||
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
||||
args: readonly string[]
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
@@ -211,29 +200,16 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void
|
||||
|
||||
/**
|
||||
* Boot one profile invocation end to end and leave process lifetime to the
|
||||
* mounted plugins (or to the one-shot runner when `task` is present).
|
||||
* @param options - profile name, overlays, flag patches, and the optional task.
|
||||
* mounted plugins (or to a one-shot runner the composition mounts).
|
||||
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
||||
* @returns the settled root context and the shutdown controller.
|
||||
*/
|
||||
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
||||
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
|
||||
if (options.task !== undefined) {
|
||||
if (!composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
|
||||
+ '(the headless profile does)',
|
||||
)
|
||||
}
|
||||
composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
|
||||
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
// The inverse misuse: a one-shot composition booted without its task
|
||||
// would otherwise die in the runner row's schema with a raw "required"
|
||||
// error naming no fix.
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
|
||||
+ `dsh run --profile ${options.profile} "<task>"`,
|
||||
)
|
||||
}
|
||||
const composed = composeProfile(options.profile, options.patchFiles)
|
||||
// A one-shot composition ends by itself, which changes what a signal means
|
||||
// and makes watching the user's patch layer pointless.
|
||||
const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
|
||||
const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
@@ -243,9 +219,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted entry point can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
// settles: an inserted provider can publish before sibling rows finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
@@ -253,7 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
|
||||
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
||||
// Recomposition for the live user layers: bundle layers below, overlays
|
||||
// and flag patches above, so a user edit can never displace them. BOTH
|
||||
// above, so a user edit can never displace them. Parsed app arguments are
|
||||
// not in here at all — they live in app-provided services that survive a
|
||||
// recomposition. BOTH
|
||||
// user files are re-read per generation (the HMR watcher hands us only the
|
||||
// changed file's patches, which one of the reads duplicates — fresh reads
|
||||
// keep the two watchers from stitching in each other's stale copy).
|
||||
@@ -267,19 +244,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
...composed.windowsShellPatches,
|
||||
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
...composed.overlayAndFlags,
|
||||
...composed.overlays,
|
||||
])
|
||||
// One-shot runs exit through the runner; watching would only hold the
|
||||
// process open after its exit request.
|
||||
const watchProfilePatch = options.task === undefined
|
||||
const watchProfilePatch = !oneShot
|
||||
// Cloned for the same insert-aliasing reason as composeLive: the boot
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
// Before any config-tree entry mounts, so a plugin that resolves a
|
||||
// user-facing value at construction already sees this run's layers.
|
||||
// Before any config-tree entry mounts, so plugins resolve all launch-time
|
||||
// environment values from the same immutable provenance snapshot.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
// The command line and bounded exit request are launcher facts available
|
||||
// to every app plugin that injects the argument snapshot.
|
||||
provideCmdline(hostCtx, {
|
||||
args: options.args,
|
||||
exit: code => void shutdown.shutdown(code),
|
||||
})
|
||||
if (oneShot) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
@@ -287,11 +270,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
}
|
||||
hostCtx.provide('headlessIo', io)
|
||||
}
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup or this post-boot
|
||||
// watcher setup is still in flight. Loader presence and fiber state own
|
||||
// A surface can dispose the whole tree while boot or this post-boot watcher
|
||||
// setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
|
||||
* plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
|
||||
* becoming a patch over the composed profile
|
||||
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
|
||||
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
|
||||
* flag patches and the LAN-trust snapshot.
|
||||
* @module @deepseek-ai/dsh/web
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
||||
* authorities an all-interfaces bind is reachable by on the LAN.
|
||||
* @returns the addresses in interface order (possibly empty).
|
||||
*/
|
||||
function lanIPv4Addresses(): string[] {
|
||||
return Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
}
|
||||
|
||||
/**
|
||||
* One LAN-trust resolution for one invocation, sampled exactly once: the
|
||||
* machine's LAN IP literals when the effective bind is all-interfaces, and
|
||||
* the `trustedHosts` value built from them plus the explicit extras. The
|
||||
* single sample is deliberate — display must advertise only addresses the
|
||||
* fence was configured with, so the web-app row receives this same snapshot.
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, so an IP-literal Host is safe on any port, and
|
||||
* the bound port may be OS-assigned, unknowable pre-boot.
|
||||
* @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
|
||||
* @param extra - `--trusted-host` values, in argv order.
|
||||
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
||||
*/
|
||||
export function resolveLanTrust(
|
||||
bindHost: string | undefined,
|
||||
extra: readonly string[],
|
||||
): { lanAddresses: string[]; trustedHosts: string[] } {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** The `dsh web` flag family, already parsed by the argument adapter. */
|
||||
export interface WebFlags {
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the web alias's flag patches over an already-composed profile tree.
|
||||
* Patches replace a row's whole config, so each patched row's composed values
|
||||
* are re-read and merged under the overrides.
|
||||
* @param rows - the composed row index from {@link composeProfile}.
|
||||
* @param flags - the parsed flag family.
|
||||
* @returns the flag patch list, in application order.
|
||||
*/
|
||||
function deriveWebFlagPatches(
|
||||
rows: ProfileRows,
|
||||
flags: WebFlags,
|
||||
): PatchOptions[] {
|
||||
const overrides = new Map<string, Record<string, unknown>>()
|
||||
const put = (entryId: string, key: string, value: unknown): void => {
|
||||
const bag = overrides.get(entryId) ?? {}
|
||||
bag[key] = value
|
||||
overrides.set(entryId, bag)
|
||||
}
|
||||
if (flags.host !== undefined) put('webserver', 'host', flags.host)
|
||||
if (flags.port !== undefined) put('webserver', 'port', flags.port)
|
||||
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
||||
if (trustedHosts.length > 0) {
|
||||
// Additive over the composed value: a cordis.patch.yml-configured fence
|
||||
// authority must survive the derived LAN literals and flag extras — a
|
||||
// silent drop of security-relevant fence configuration.
|
||||
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
|
||||
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
|
||||
}
|
||||
// mode and lanAddresses are launcher-derived on every boot (--dev also
|
||||
// inserts the client-hmr row), never pass-throughs of composed values.
|
||||
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
||||
put('web-runtime', 'lanAddresses', lanAddresses)
|
||||
// The agent-preset roots are patched by the shared profile boot: they are
|
||||
// an assembly fact of every dsh launcher, and `dsh run` composes agents
|
||||
// from the same roster this alias offers.
|
||||
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
||||
const composed = rows.get(id)
|
||||
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
|
||||
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
|
||||
})
|
||||
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
|
||||
return patches
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composed Web runtime keeps its model- and shell-visible surface
|
||||
* context. The bundle schema defaults the field to true, so only an explicit
|
||||
* false suppresses both the bundle contributions and the launcher-owned
|
||||
* source-checkout section.
|
||||
* @param rows - the composed Web profile rows before launcher flag patches.
|
||||
* @returns true unless the web-runtime row explicitly disables surface context.
|
||||
*/
|
||||
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the browser UI from the web profile. Host/port flags are passed
|
||||
* through only when given (absent, the composed profile values
|
||||
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
|
||||
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||
* after Loader settlement.
|
||||
* @param flags - the parsed `dsh web` flag family.
|
||||
* @param environment - this run's frozen environment snapshot.
|
||||
*/
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
prepare: (ctx: Context, rows: ProfileRows) => {
|
||||
if (!webSurfaceContextEnabled(rows)) return
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -21,22 +21,28 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
it('routes profile boots and the web alias, handing the rest to the app', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
|
||||
expect(parse(['run', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' })
|
||||
expect(parse(['run', '--', '--profile', 'is', 'task', 'text']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
|
||||
expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
|
||||
})
|
||||
|
||||
it('ends the launcher flags at the first token it does not own', () => {
|
||||
// App flags, including its -h, and positionals reach the app verbatim.
|
||||
expect(parse(['--profile', 'tui', '--resume', 'abc']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
|
||||
expect(parse(['--profile', 'web', '-h']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
|
||||
// Launcher flags placed after that boundary belong to the app too.
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
|
||||
})
|
||||
|
||||
it('routes the plugin pnpm forwarder', () => {
|
||||
@@ -64,18 +70,12 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
|
||||
expect(exitCode(['run'])).toBe(1)
|
||||
expect(exitCode(['run', ''])).toBe(1)
|
||||
expect(exitCode(['run', '--profile', '', 'task'])).toBe(1)
|
||||
expect(exitCode(['run', '--patch=', 'task'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
|
||||
expect(exitCode(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -87,21 +87,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||
// would print a tree that differs from the same invocation's boot.
|
||||
// A dump never runs app command-line providers, so it cannot show what
|
||||
// those flags would decide; printing a tree that differs from the same
|
||||
// invocation's boot would mislead.
|
||||
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
|
||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
it('keeps its own help for an invocation with no app to hand it to', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['-h'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
|
||||
async function runBuiltBin(
|
||||
@@ -129,8 +128,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
@@ -145,8 +144,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
|
||||
fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
@@ -194,8 +193,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
interface StartupFixture {
|
||||
home: string
|
||||
ready: string
|
||||
echo: string
|
||||
interrupt: string
|
||||
/** An always-running row's echo, used to observe that a user patch reload landed. */
|
||||
witness: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
|
||||
* a row that reads its app-owned service through a `!!js` config expression.
|
||||
* Both plugin modules resolve
|
||||
* `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
|
||||
* fallback, exactly as an installed out-of-tree bundle does.
|
||||
*/
|
||||
function createStartupFixture(): StartupFixture {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
|
||||
const profileDir = join(home, 'profiles', 'startup')
|
||||
// Written straight into the installed location: a row module resolves its
|
||||
// own imports from where it is installed, and only inside the profile does
|
||||
// Node's parent walk reach the installation fallback these plugins need.
|
||||
const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'startup.mjs'), [
|
||||
"import { Command } from 'commander'",
|
||||
"import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
|
||||
"export const name = 'fixture-startup'",
|
||||
"export const inject = ['cmdlineArgs']",
|
||||
'export function apply(ctx) {',
|
||||
" const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
|
||||
' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))',
|
||||
' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'waiting.mjs'), [
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'startup-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
||||
' ctx.effect(() => () => { clearInterval(heartbeat) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'witness.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'reload-witness'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: startup-fixture',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
|
||||
' inject: [fixtureStartup]',
|
||||
' config:',
|
||||
// Lazy interpolation runs only after the provider's service is injected.
|
||||
" generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
|
||||
' - id: fixture-startup',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
|
||||
' - id: reload-witness',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-startup-bundle',
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-startup',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
||||
return {
|
||||
home,
|
||||
ready: join(home, 'ready'),
|
||||
echo: join(home, 'config-echo'),
|
||||
interrupt: join(home, 'interrupt'),
|
||||
witness: join(home, 'witness'),
|
||||
}
|
||||
}
|
||||
|
||||
function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -203,46 +315,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const help = await runBuiltBin(['--help'])
|
||||
expect(help.code).toBe(0)
|
||||
expect(help.stdout).toContain('dsh --profile web')
|
||||
expect(help.stdout).toContain('dsh run "run the tests"')
|
||||
expect(help.stdout).toContain('dsh plugin --profile')
|
||||
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('prints run help without initializing the selected profile', async () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
|
||||
const home = join(parent, 'not-created')
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
|
||||
expect(existsSync(home)).toBe(false)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const web = await runBuiltBin(['--profile', 'web', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(web.code).toBe(0)
|
||||
expect(web.stderr).toBe('')
|
||||
expect(web.stdout).toContain('Usage: dsh --profile web')
|
||||
expect(web.stdout).toContain('--port <port>')
|
||||
expect(web.stdout).not.toContain('dsh web: http://')
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(headlessHelp.code).toBe(0)
|
||||
expect(headlessHelp.stderr).toBe('')
|
||||
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
|
||||
|
||||
const missingTask = await runBuiltBin(['--profile', 'headless'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(missingTask.code).toBe(1)
|
||||
expect(missingTask.stderr).toContain('a task is required')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
successText: 'published headless profile reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published dsh run reached the mock')
|
||||
expect(result.stdout).toBe('published headless profile reached the mock')
|
||||
expect(result.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
@@ -318,9 +447,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// An HMR main-watcher initial scan that refreshes the include
|
||||
// mid-initial-apply deadlocks the failing apply's rollback against the
|
||||
// refresh drain: dsh exits 13 with no diagnostic instead of settling
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
@@ -337,9 +466,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
const child = startProfileLifecycle(fixture)
|
||||
const child = startProfileLifecycle(fixture, ['--unclaimed'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
requestProfileShutdown(child, fixture)
|
||||
@@ -405,6 +534,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
// The consumer started once, already carrying the flag value: the
|
||||
// launcher never saw --generation, and the app provider resolved it first.
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, [])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
// active, so each config expression reads the same invocation value (a
|
||||
// served port does not move back to its composed fallback).
|
||||
const fixture = createStartupFixture()
|
||||
const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
// Both rows: the waiting one carries the flag value, and the witness is
|
||||
// what a reload will re-mount. They start independently, so neither
|
||||
// marker implies the other.
|
||||
await waitForFile(fixture.ready)
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
// An edit to an unrelated row: the witness re-mounts, which is how this
|
||||
// test knows the whole tree was recomposed.
|
||||
rmSync(fixture.witness)
|
||||
writeFileSync(profilePatch, [
|
||||
'- id: reload-witness',
|
||||
' config:',
|
||||
' generation: reloaded',
|
||||
'',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
try {
|
||||
const result = await startStartupProfile(fixture, ['--help'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Usage: fixture')
|
||||
expect(result.stdout).toContain('--generation')
|
||||
expect(existsSync(fixture.ready)).toBe(false)
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
||||
@@ -491,18 +697,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
}, 30_000)
|
||||
|
||||
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['--profile', 'headless', '--dump-default-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
@@ -543,16 +748,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
it('shows the RL Web patch disabling runtime surface context', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['web', '--patch', coreWebOverlay, '--dump-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).toContain('surfaceContext: false')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['run', 'never complete'],
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
|
||||
@@ -5,14 +5,16 @@ import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -23,6 +25,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
||||
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
|
||||
|
||||
/**
|
||||
* Boot the shipped Web composition, minus the rows that would bind a port,
|
||||
@@ -90,12 +101,26 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, patches)
|
||||
return await boot('dsh-test', rootConfig, patches, (bootCtx) => {
|
||||
provideCmdline(bootCtx, { args: [], exit: () => {} })
|
||||
})
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
function enablePresetTool(composition: string, id: string): string {
|
||||
const row = ` - id: ${id}\n`
|
||||
const start = composition.indexOf(row)
|
||||
if (start < 0) throw new Error(`missing preset row ${id}`)
|
||||
const end = composition.indexOf('\n - id:', start + row.length)
|
||||
const disabled = composition.indexOf(' disabled: true\n', start)
|
||||
if (disabled < 0 || (end >= 0 && disabled > end)) {
|
||||
throw new Error(`preset row ${id} is not disabled`)
|
||||
}
|
||||
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
||||
@@ -135,7 +160,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
|
||||
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
||||
'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
@@ -144,14 +169,30 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('composes exactly two tools from `minimal`', async () => {
|
||||
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Exactly what the preset lists — nothing arrives from the host.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections).toEqual([
|
||||
{ name: 'deployment:persona', text: MINIMAL_PROMPT },
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
|
||||
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
|
||||
.toContain('Absolute path')
|
||||
const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact')
|
||||
expect(compact).toBeDefined()
|
||||
expect((compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
@@ -191,6 +232,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
// And it keeps the standard agent's own tools rather than replacing them.
|
||||
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
||||
expect(tools).not.toContain('str_replace_editor')
|
||||
|
||||
// The preset's own authoring skill registers into ITS layer of the host
|
||||
// registry: the cordis agent's view carries it, the global view does not.
|
||||
@@ -217,9 +259,9 @@ describe('the shipped Web composition', () => {
|
||||
// the capabilities — so the assembly is what carries the claim.
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor')
|
||||
expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(sdk).toContain('str_replace_editor')
|
||||
expect(sdk).not.toContain('str_replace_editor')
|
||||
expect(sdk).toContain('web_search')
|
||||
|
||||
// The presentation is this agent's alone: the deployment default is
|
||||
@@ -340,18 +382,96 @@ describe('the shipped Web composition', () => {
|
||||
|
||||
expect(await readFile(path, 'utf8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('gives each session its own persona', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-persona'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
describe('product subagent rows in user presets', () => {
|
||||
let productCtx: Context
|
||||
const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
||||
const userRoot = join(root, 'presets')
|
||||
const settingsFile = join(root, 'settings.yaml')
|
||||
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
for (const id of ids) {
|
||||
let composition = standard
|
||||
if (id === 'products-codex' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
||||
}
|
||||
if (id === 'products-claude' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
||||
}
|
||||
const directory = join(userRoot, id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
||||
}
|
||||
productCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await productCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('composes none, either product, or both without changing the shared host registry', async () => {
|
||||
const expected = new Map<string, string[]>([
|
||||
['products-none', []],
|
||||
['products-codex', ['subagent_codex']],
|
||||
['products-claude', ['subagent_claude_code']],
|
||||
['products-both', ['subagent_claude_code', 'subagent_codex']],
|
||||
])
|
||||
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
|
||||
'spawn', 'fork', 'codex', 'claude-code',
|
||||
]))
|
||||
|
||||
for (const [id, productTools] of expected) {
|
||||
const handle = await productCtx.agents.create({
|
||||
sessionId: SessionId(`preset-${id}`),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(productCtx, handle.agent)
|
||||
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
|
||||
.toEqual(productTools)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('applies a product-row edit only to later sessions on the preset', async () => {
|
||||
const preset = await productCtx.agentPresets.resolve('products-none')
|
||||
const original = await readFile(preset.path, 'utf8')
|
||||
const existing = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-existing'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
|
||||
.toContain('You are a coding agent powered by')
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
|
||||
|
||||
const later = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-later'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
|
||||
} finally {
|
||||
await later.dispose()
|
||||
}
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
await existing.dispose()
|
||||
await writeFile(preset.path, original)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/cmdline"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bundle/base"
|
||||
},
|
||||
@@ -47,6 +50,9 @@
|
||||
{
|
||||
"path": "../../packages/core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
|
||||
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
|
||||
expect(metadata).toContain('name: 我的模式')
|
||||
expect(metadata).toContain('description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。')
|
||||
expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
|
||||
expect(metadata).not.toContain('order:')
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.'
|
||||
|
||||
describe('core Web profile', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
|
||||
beforeAll(async () => {
|
||||
const systemPrompt = process.env.DSH_SYSTEM_PROMPT
|
||||
Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
try {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
} finally {
|
||||
if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt
|
||||
}
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
|
||||
})
|
||||
|
||||
it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => {
|
||||
agentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agentHandle.agent.whenIdle()
|
||||
|
||||
const requestHeader = agentHandle.agent.session.requestHeader()
|
||||
if (requestHeader === undefined) throw new Error('the core Web agent issued no model request')
|
||||
|
||||
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
|
||||
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
|
||||
const signal = new AbortController().signal
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-bash-smoke'),
|
||||
name: 'bash',
|
||||
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
prompt: requestHeader.system,
|
||||
tools: requestHeader.tools?.map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "CORE_WEB_BASH_OK",
|
||||
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 CORE_WEB_EDITOR_OK
|
||||
2",
|
||||
"prompt": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent))
|
||||
|
||||
const entries = [...scaffold.ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined()
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
|
||||
it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => {
|
||||
const previous = process.env.DSH_SYSTEM_PROMPT
|
||||
process.env.DSH_SYSTEM_PROMPT = 'RL prompt override'
|
||||
let overrideScaffold: WebScaffold | undefined
|
||||
let overrideAgent: AgentHandle | undefined
|
||||
try {
|
||||
overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
overrideAgent = await overrideScaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-override'),
|
||||
meta: { cwd: overrideScaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
overrideAgent.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await overrideAgent.agent.whenIdle()
|
||||
expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override')
|
||||
} finally {
|
||||
try {
|
||||
await overrideAgent?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
await overrideScaffold?.close()
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
else process.env.DSH_SYSTEM_PROMPT = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
115
apps/web/tests/minimal-preset.snapshot.ts
Normal file
115
apps/web/tests/minimal-preset.snapshot.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.'
|
||||
|
||||
describe('minimal agent preset', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
let disposeInjectedPrompt: () => void
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE })
|
||||
disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({
|
||||
name: 'test:injected-prompt',
|
||||
order: 999,
|
||||
text: 'THIS TEXT MUST NOT REACH THE MODEL.',
|
||||
})
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('minimal-preset-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
try {
|
||||
disposeInjectedPrompt?.()
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed')
|
||||
})
|
||||
|
||||
it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
|
||||
agentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agentHandle.agent.whenIdle()
|
||||
|
||||
const requestHeader = agentHandle.agent.session.requestHeader()
|
||||
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
|
||||
|
||||
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
|
||||
await mkdir(stateDir)
|
||||
const signal = new AbortController().signal
|
||||
await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-bash-state-setup'),
|
||||
name: 'bash',
|
||||
arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-bash-state-read'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt')
|
||||
await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n')
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
prompt: requestHeader.system,
|
||||
tools: requestHeader.tools?.map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "PERSISTED:{{cwd}}/persistent-state",
|
||||
"editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 MINIMAL_EDITOR_OK
|
||||
2",
|
||||
"prompt": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name)))
|
||||
.toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name)))
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,6 @@ import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import Group from '@deepseek-ai/cordis-plugin-group'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
@@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
|
||||
@@ -459,6 +459,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
ctx.baseUrl = pathToFileURL(profileDir).href + '/'
|
||||
// This direct Loader harness supplies the same root-path capability as app-boot.
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
// A host with no command line still provides one: the web bundle's startup
|
||||
// row releases the rows waiting on it, and with no arguments each starts on
|
||||
// the values this scaffold composed above. An exit request can only come
|
||||
// from a rejected argument, which a fixed empty list has none of.
|
||||
provideCmdline(ctx, {
|
||||
args: [],
|
||||
exit: (code) => {
|
||||
throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
|
||||
},
|
||||
})
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// `cordis:group` beside it, exactly as `boot()` registers it: a group row is
|
||||
@@ -469,9 +479,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
if (surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) })
|
||||
}
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(rootConfig).href, patches },
|
||||
|
||||
@@ -38,7 +38,6 @@ const EXPECTED_TOOLS = [
|
||||
'read',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
|
||||
@@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web',
|
||||
// Launcher flags come first: the first token the launcher does not own
|
||||
// starts the web app's own arguments.
|
||||
// Pin the in-browser picker: the shipped `-auto` row would resolve to
|
||||
// the native OS chooser on this bind, and no page can drive that.
|
||||
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
'--port', String(port),
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
@@ -65,7 +65,7 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "设为默认: 我的模式"':
|
||||
- text: 我的模式 自定义 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
- text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: my-agent
|
||||
- 'button "查看路径: 我的模式"':
|
||||
- img
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
- text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.
|
||||
- img
|
||||
- menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
|
||||
- menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions."
|
||||
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"}
|
||||
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user