Merge remote-tracking branch 'origin/master' into agent/web-fetch-fakeip-469be4db70d0
This commit is contained in:
commit
adcc7fc933
2144 files changed
+53821
-7991
No files matched your search
@@ -0,0 +1,2 @@
|
||||
2026-10-14-built-in-plugin-feature-previews.md: ae9b32340d9b1e77a99465445a05ccee05df0e79
|
||||
2026-10-14-built-in-plugin-feature-previews.zh.md: 9229b0835a23a0eefffa1bd157efece333aebfa9
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Built-in plugin feature previews
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
English | [中文](2026-10-14-built-in-plugin-feature-previews.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 内置插件功能配图
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
[English](2026-10-14-built-in-plugin-feature-previews.md) | 中文
|
||||
|
||||
@@ -2020,6 +2020,9 @@
|
||||
"feature/2026-09-28-desktop-login-shell-environment.i18n.yaml": "sha256:6129edeb0a7ecf087911193ca5a15223a730ad01e30d2ead7739d01f589fafa7",
|
||||
"feature/2026-09-28-desktop-login-shell-environment.md": "sha256:cd133b1ef1a178828d30b9db176a91597eedf9e67802551356f34d3e0f80206d",
|
||||
"feature/2026-09-28-desktop-login-shell-environment.zh.md": "sha256:1422667a44aacc51736f31b328a8e171a39cb6b5168d6993e980e4324b23b98f",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.i18n.yaml": "sha256:4769fd7fa4881a38fa8e2682b2ea62ed5a5ec6588e87f225c2b106e1bd308200",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.md": "sha256:093e00da4cebf732861c1e5c47465fb00359eb89a86dc212f3f90b43f9b80cec",
|
||||
"feature/2026-10-14-built-in-plugin-feature-previews.zh.md": "sha256:56adb70945f68b073357c437bbec9e69f110587cf302291f2f135d9fcb5537e7",
|
||||
"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",
|
||||
@@ -2077,6 +2080,9 @@
|
||||
"process/2026-07-06-generated-config-catalog.i18n.yaml": "sha256:48e64ccfc7622559a9b964e2741ad82a9fa37243a0d5c472f0482d9c748d7739",
|
||||
"process/2026-07-06-generated-config-catalog.md": "sha256:c8654a4e084bfbf4f7d8eb78239dc1d5d62e1384b2aed309fa2526a4d6de0f04",
|
||||
"process/2026-07-06-generated-config-catalog.zh.md": "sha256:5ab8d964f12e0cf9a04251e988f0b2a114d7ae8ea7ed337c00f625213cb1dc94",
|
||||
"process/2026-07-06-node-engine-floor.i18n.yaml": "sha256:65d73bf7a6c2fb7609ff3a8f18a435e83bb488e005365d065d184942958bfb35",
|
||||
"process/2026-07-06-node-engine-floor.md": "sha256:b62c8e8dd008b0ab7874b5d57c20c629ffb71e4b4a62922afaf6a1039d25898e",
|
||||
"process/2026-07-06-node-engine-floor.zh.md": "sha256:3cd685e59f913ef700b3e2ed3b941c2266bb2de706810cbe570f4832a26a5034",
|
||||
"process/2026-07-06-parallel-github-ci-gates.i18n.yaml": "sha256:0f6ece268d9a51bc20cb8eb929f26d8838761603a64eb08dc24521198f10da36",
|
||||
"process/2026-07-06-parallel-github-ci-gates.md": "sha256:6249bd7396ae7f2d0dc671879ce21cefab33a47ace6ef17a25a70e8650b815af",
|
||||
"process/2026-07-06-parallel-github-ci-gates.zh.md": "sha256:cf7edb9bcf97ab1d4e452330c0df0b127a664509ec3f11597ace3eabeb663a5b",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Frozen bilingual archive consistency record: Git blob hashes after archive metadata insertion.
|
||||
2026-07-06-node-engine-floor.md: 4e399b2a71bb4a76a28bc4b643a761ce65c55865
|
||||
2026-07-06-node-engine-floor.zh.md: abd4913688cc4b3af02fa2f0b06d4b8206652d13
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Raise the Node LTS engine floor to 22.19
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-15
|
||||
|
||||
English | [中文](2026-07-06-node-engine-floor.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 将 Node LTS 引擎下限提升至 22.19
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-15
|
||||
|
||||
[English](2026-07-06-node-engine-floor.md) | 中文
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: b432f80a424c3e49
|
||||
zh: faa26925fdd98387
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection:
|
||||
en: dff78c46432311a1
|
||||
zh: 58d5db4c3c85b886
|
||||
en: 4e4ec068c2a22383
|
||||
zh: b7030d0f00bfdb74
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema:
|
||||
en: ec3703b7e7c6b23d
|
||||
zh: 325755e6e90092da
|
||||
|
||||
@@ -60,6 +60,7 @@ flowchart LR
|
||||
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
|
||||
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
|
||||
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
|
||||
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
|
||||
@@ -151,6 +152,8 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
|
||||
|
||||
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
|
||||
|
||||
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once.
|
||||
|
||||
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
|
||||
|
||||
## Search request and result schema
|
||||
|
||||
@@ -60,6 +60,7 @@ flowchart LR
|
||||
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
|
||||
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
|
||||
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
|
||||
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
|
||||
@@ -151,6 +152,8 @@ interface WebRuntime {
|
||||
|
||||
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
|
||||
|
||||
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。
|
||||
|
||||
`ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
|
||||
|
||||
## 搜索请求与结果 schema
|
||||
|
||||
+4
-4
@@ -21,11 +21,11 @@
|
||||
en: 985e0eb63f3d1fd6
|
||||
zh: 337cc44fadc6227e
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts:
|
||||
en: 921085cb9d894688
|
||||
zh: 12d99fe153d36738
|
||||
en: 9d885e8d58bc8bbb
|
||||
zh: 41a9779fbdea8a1a
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development:
|
||||
en: 02e6b36f9f92c83a
|
||||
zh: edd8b2db7a787483
|
||||
en: b2a740a1ab7a5002
|
||||
zh: a48be6ca6fc65912
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage:
|
||||
en: 31a5aecb8bf828bb
|
||||
zh: 2dffb6fd2e0f8194
|
||||
|
||||
+2
-2
@@ -48,13 +48,13 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de
|
||||
|
||||
### 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-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
[`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-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five 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 `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, lightweight download metadata and Office skills, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`.
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, lightweight download metadata and Office skills, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/runtime-bootstrap.mjs` on system Node 24.9 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` 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. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, lightweight resource metadata and skills, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published.
|
||||
|
||||
|
||||
+2
-2
@@ -48,13 +48,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 24.9 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、轻量资源元数据与 skills 及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。
|
||||
|
||||
|
||||
+4
-4
@@ -21,8 +21,8 @@
|
||||
en: 0a4240d88cca240a
|
||||
zh: 8f5f1527caf571cd
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
|
||||
en: 7dac236d6add3c3c
|
||||
zh: 2a43a923a0646bb0
|
||||
en: 15fb56befd328339
|
||||
zh: 0ffb10ada7e52805
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
|
||||
en: 37e2fa1fe5cb2b95
|
||||
zh: 80e513db87709cb4
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 94ca0d09e6fd1e3a
|
||||
zh: d5949a2bdec795ba
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
|
||||
en: 6a96d9ac06c222e5
|
||||
zh: b2814ab58348e831
|
||||
en: 16e1f2467c559315
|
||||
zh: a714ec6ff2722cf1
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
|
||||
en: b8f036f50cff7cd8
|
||||
zh: 6d623c4a1f4314b1
|
||||
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
@@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering
|
||||
|
||||
## Consequences
|
||||
|
||||
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
|
||||
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
|
||||
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
|
||||
|
||||
## Consequences
|
||||
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
|
||||
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
en: 55846ac57e07eeda
|
||||
zh: 3de03730d4b28863
|
||||
/agent-note-dsh-source-launch-through-the-tsx-esm-hook/problem:
|
||||
en: 68385f7e47d3ac1f
|
||||
zh: 285ca290832e2939
|
||||
en: d13aa56d01c1181d
|
||||
zh: 837427ed6963c39d
|
||||
/agent-note-dsh-source-launch-through-the-tsx-esm-hook/decision:
|
||||
en: 12adf8ed56b4d5c3
|
||||
zh: fbbb9add935730c0
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
|
||||
The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The supported engines range includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
|
||||
|
||||
Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
|
||||
[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。受支持的 engines 范围包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
|
||||
|
||||
启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。
|
||||
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
en: a244f6a88fdabf3d
|
||||
zh: e4feedb7297f4865
|
||||
/agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs:
|
||||
en: c5bcd6eb76bf3d42
|
||||
zh: 9df2fbb364643186
|
||||
en: 4479d41466723100
|
||||
zh: 3a7f398e0d0070a5
|
||||
/agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry:
|
||||
en: 75e977fe0fb71d59
|
||||
zh: 00760c79da173cb7
|
||||
|
||||
@@ -207,17 +207,9 @@ Client Program
|
||||
|
||||
`remote-client` is the Host Program's second emitter, not a third Program or the Client's local face. It contains no Host Cordis merge, Service class, Context class, or implementation code, and it does not enter the Host-local reflection registry.
|
||||
|
||||
The Host lib build performs strict Host analysis and emits both the Host-local and Remote consumer artifacts. The Client lib then consumes the Remote DTS. The complete order is:
|
||||
Source-only Host analysis writes the Host-local and Remote consumer artifacts before package compilation. [The generation-order decision](../process/2026-10-18-source-first-metadata-builds.md) owns placement and its tradeoffs; [Development](../../../../docs/development.md#typescript-project-layout) owns the command sequence.
|
||||
|
||||
```text
|
||||
Host lib build
|
||||
→ 生成 typert.host.{js,d.ts}
|
||||
→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map}
|
||||
→ 完成 Client lib 和 typert.client 产物
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
The top-level `build`, declaration-only `typecheck`, and serial `build:lib` share metadata preparation before package compilation. `build:lib` remains the complete serial lib build for `build:bench`; its Host half `build:lib:host` serves the vendor release workflows and retains aggregate checking. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
|
||||
Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs.
|
||||
|
||||
|
||||
@@ -207,17 +207,9 @@ Client Program
|
||||
|
||||
`remote-client` 是 Host Program 的第二个 emitter,不是第三个 Program,也不是 Client 本地 face。它不包含 Host Cordis merge、Service class、Context class 或实现代码,不进入 Host 本地 reflection registry。
|
||||
|
||||
Host lib 构建负责完成严格 Host 分析并产出 Host 本地 artifact 与 Remote 消费端 artifact;Client lib 随后消费 Remote DTS。完整顺序为:
|
||||
仅源码的 Host 分析在包编译之前写出 Host 本地 artifact 与 Remote 消费端 artifact。[生成顺序决策](../process/2026-10-18-source-first-metadata-builds.zh.md)负责生成位置及其取舍;[开发指南](../../../../docs/development.zh.md#typescript-project-layout)负责命令序列。
|
||||
|
||||
```text
|
||||
Host lib build
|
||||
→ 生成 typert.host.{js,d.ts}
|
||||
→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map}
|
||||
→ 完成 Client lib 和 typert.client 产物
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
顶层 `build`、仅生成声明的 `typecheck` 与串行 `build:lib` 都在包编译之前准备元数据。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建;其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用,并保留 aggregate 检查。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
|
||||
即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
|
||||
|
||||
|
||||
+6
-6
@@ -6,14 +6,14 @@
|
||||
en: 41f30d473ff53ce2
|
||||
zh: 6851c4414f5331c1
|
||||
/agent-note-session-projections-as-a-required-reader-seam/problem:
|
||||
en: c0c779c717cad546
|
||||
zh: 5d2916ac86bf5d84
|
||||
en: fca00eeb7a8d9abd
|
||||
zh: d7a9766f5322dff9
|
||||
/agent-note-session-projections-as-a-required-reader-seam/decision:
|
||||
en: a1c50d2f43334a88
|
||||
zh: d7736994733454b4
|
||||
en: 8d38ed9356085c7e
|
||||
zh: e15ede51415192f3
|
||||
/agent-note-session-projections-as-a-required-reader-seam/alternatives-considered:
|
||||
en: aea3da939c90d240
|
||||
zh: 21e91c481cdd23b7
|
||||
/agent-note-session-projections-as-a-required-reader-seam/consequences:
|
||||
en: 73325513ec70fbb2
|
||||
zh: 610bf34b9f9db420
|
||||
en: ab45eb87f7c22cdb
|
||||
zh: e65a97991a45164f
|
||||
+5
-7
@@ -6,19 +6,17 @@ English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule.
|
||||
An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value.
|
||||
|
||||
## Decision
|
||||
|
||||
This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md).
|
||||
|
||||
Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics.
|
||||
Every host state owner treats the projection registry and its required key as mandatory state. Owners declare `sessionProjections` as a required injection and await acquisition before exposing service methods; other consumers depend on those services. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics.
|
||||
|
||||
A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent.
|
||||
Owners acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; business service methods expose the host data each consumer needs.
|
||||
|
||||
The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state.
|
||||
|
||||
`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
|
||||
`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,6 +28,6 @@ The registry provides `stateOf(session, key)` for one typed host state and keeps
|
||||
|
||||
## Consequences
|
||||
|
||||
- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value.
|
||||
- Missing projection composition fails during activation; it never degrades to a default value.
|
||||
- Host consumers avoid repeated whole-registry snapshots and log scans.
|
||||
- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set.
|
||||
+5
-7
@@ -6,19 +6,17 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。
|
||||
可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。
|
||||
|
||||
## 决策
|
||||
|
||||
本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。
|
||||
|
||||
每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。
|
||||
每个 host 状态所有者都把投影注册表及其所需 key 视为必需状态。所有者将 `sessionProjections` 声明为必需注入,并在提供服务方法前等待获取完成;其他消费方依赖这些服务。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。
|
||||
|
||||
领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。
|
||||
所有者通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;业务服务方法提供各消费方所需的 host 数据。
|
||||
|
||||
注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。
|
||||
|
||||
`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
|
||||
`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,6 +28,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。
|
||||
- 缺少投影组合会在激活期间失败,绝不会降级为默认值。
|
||||
- host 消费方避免重复的全注册表快照和日志扫描。
|
||||
- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。
|
||||
@@ -6,14 +6,14 @@
|
||||
en: b19652a0e1cc1826
|
||||
zh: 4d3511e4f0db5b9d
|
||||
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/problem:
|
||||
en: 70e5f3ed33010c98
|
||||
zh: 5437d943953f5a40
|
||||
en: 6379f0a2522f4c46
|
||||
zh: c45ffeb5089be274
|
||||
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision:
|
||||
en: efe23161a54f70c5
|
||||
zh: e24ee097918b2724
|
||||
en: 92526818ff4870c2
|
||||
zh: 8c54ff3f4941d5ad
|
||||
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered:
|
||||
en: df44eb15557b95fe
|
||||
zh: b757e53da4c0a99f
|
||||
en: df6c078f239a11f5
|
||||
zh: 914da26836aa373b
|
||||
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences:
|
||||
en: 6a4acbab915f1bd1
|
||||
zh: 2c8ffd62ba74ff96
|
||||
|
||||
@@ -10,7 +10,7 @@ Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`. Every other tool
|
||||
|
||||
The repository had briefly had an answer and lost it without noticing. PR #971 set `NODE_USE_ENV_PROXY=1` in `bin/dsh`; eleven days later the “cleanup: remove managed source installer” change deleted that launcher wholesale, taking the flag with it. What survived was one sentence in `apps/cli/reference/README.md` telling the reader to set a variable that nothing consumed any more.
|
||||
|
||||
That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in `$DSH_HOME/.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — while `engines` admits `^22.19.0`, where the variable does not exist and setting it warns about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says.
|
||||
That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in `$DSH_HOME/.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — the runtime floor at adoption was Node 22.19, where the variable did not exist and setting it warned about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -48,7 +48,7 @@ This accepts a documented seam. Such a context matches bypass entries by Node's
|
||||
|
||||
**SDK transports need independent verification.** The OTLP exporter selects `http-exporter-transport` on Node, which posts through `node:http` and bypasses the global fetch dispatcher; a `globalThis.fetch` reference in its browser implementation does not prove Node routing. The [E2B removal](../simplification/2026-09-11-remove-e2b-providers.md) retires a second SDK transport integration without changing this requirement.
|
||||
|
||||
**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — inside the engines range, so 22.19, 22.20, and 24.0–24.4 would stay direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that works on some runtimes. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working.
|
||||
**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — above the runtime floor at adoption, so Node 22.19, 22.20, and 24.0–24.4 stayed direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that worked on some runtimes. The Node 24.9 floor removes this API gap; routing still needs independent transport integration and must preserve compression. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working.
|
||||
|
||||
Weighed against that, telemetry is the one outbound channel whose loss costs the user nothing: no tool, no model request, and no session depends on it, and an export that cannot connect is already dropped silently. A user behind a mandatory proxy is left exactly where they were before this change rather than regressed. `egress.spec.ts` now asserts the exclusion — an SDK upgrade that moved the exporter onto `fetch` would start routing telemetry through a proxy silently, and that case is what makes it visible.
|
||||
|
||||
@@ -58,7 +58,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `$DSH_HOME/.env`, absent on the lowest supported Node, and bypassed by `web-fetch-http` regardless. It is also what the repository already claimed to do.
|
||||
**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected at adoption on the three measurements above: invisible to `$DSH_HOME/.env`, absent on the Node 22.19 floor, and bypassed by `web-fetch-http` regardless. The environment-snapshot and explicit-dispatcher limitations remain on the current runtime floor.
|
||||
|
||||
**Thread a policy value to every call site.** DeepSeek-Reasonix does this across 98 sites, buying a per-provider opt-out. Rejected: that opt-out exists for a need this harness does not have, and nine sites changed by hand means the tenth is forgotten — Pi's changelog records OAuth and Bedrock as two separate after-the-fact fixes of exactly that kind. The isolation argument for it is real, and is answered instead by handling worker threads explicitly and by proving disposal restores the previous dispatcher.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
|
||||
|
||||
仓库曾短暂拥有过答案,又在无人察觉时弄丢了。PR #971 在 `bin/dsh` 里设置了 `NODE_USE_ENV_PROXY=1`;十一天后的“cleanup: remove managed source installer”改动整体删除了那个启动器,把该标志一并带走。留下的只有 `apps/cli/reference/README.md` 里的一句话,让读者去设置一个已经无人消费的变量。
|
||||
|
||||
即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在 `$DSH_HOME/.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——而 `engines` 允许 `^22.19.0`,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。
|
||||
即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在 `$DSH_HOME/.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——采用此决策时的运行时下限为 Node 22.19,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -48,7 +48,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
|
||||
|
||||
**SDK 传输需要独立验证。** OTLP 导出器在 Node 上选择 `http-exporter-transport`,通过 `node:http` 投递并绕过全局 fetch dispatcher;其浏览器实现中的 `globalThis.fetch` 引用不能证明 Node 路由。[E2B 移除决策](../simplification/2026-09-11-remove-e2b-providers.zh.md)撤下另一项 SDK 传输集成,但不改变这一要求。
|
||||
|
||||
**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——落在 engines 范围之内,因此 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在部分运行时生效的路径保留 `createNodeHttpAgent` 导出。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件。
|
||||
**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——高于采用此决策时的运行时下限,因此 Node 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在当时部分运行时生效的路径保留 `createNodeHttpAgent` 导出。Node 24.9 下限消除了这一 API 缺口;代理路由仍需要独立集成传输,并保留压缩。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件。
|
||||
|
||||
与之相比,遥测是唯一一条丢失了对用户毫无代价的出网通道:没有任何工具、模型请求或会话依赖它,而连不上的导出本就被静默丢弃。处在强制代理后的用户,只是停留在本次改动之前的状态,而不是被弄坏。`egress.spec.ts` 现在断言这一排除——若某次 SDK 升级把导出器挪到 `fetch` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西。
|
||||
|
||||
@@ -58,7 +58,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。
|
||||
**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 采用此决策时基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在 Node 22.19 下限上不存在、且无论如何被 `web-fetch-http` 绕过。环境快照和显式 dispatcher 的限制在当前运行时下限上仍然存在。
|
||||
|
||||
**把策略值传递到每一个调用点。** DeepSeek-Reasonix 在 98 处这样做,换来每提供方的 opt-out。被否决:该能力服务于本 Harness 并不具备的需求,而手工改九处意味着第十处会被遗忘——Pi 的变更日志正记录了 OAuth 与 Bedrock 两次事后补漏。它关于隔离性的论点确实成立,本方案改为显式处理 worker 线程、并以「dispose 后还原前一个 dispatcher」的断言来回应。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: edefff7cd986a509
|
||||
zh: a270839fa86976a2
|
||||
/agent-note-guided-plugin-installation/decision:
|
||||
en: 30f1e636973df06a
|
||||
zh: 0f874b88a5d4d7f5
|
||||
en: 5448b9f8aaa9a569
|
||||
zh: 9024c5328c5da3bd
|
||||
/agent-note-guided-plugin-installation/alternatives-considered:
|
||||
en: be64b541f4d81e7c
|
||||
zh: 8600531c08ab4288
|
||||
|
||||
@@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
|
||||
|
||||
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
|
||||
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed.
|
||||
|
||||
**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
|
||||
|
||||
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
|
||||
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
|
||||
|
||||
**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
en: 607a1afd89892d15
|
||||
zh: a0aa85124340458c
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/security-and-validation:
|
||||
en: 46a0c0b3ce1e5671
|
||||
zh: 7743707de42b1d1d
|
||||
en: 2426665452a6a37b
|
||||
zh: 282572d66e17a237
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/in-process-and-worker-carriers:
|
||||
en: df4ae60d8403cdbb
|
||||
zh: 70c6024a2a9a1e84
|
||||
@@ -102,8 +102,8 @@
|
||||
en: 26dcd8bebdc8d974
|
||||
zh: e74bcef7c549e70c
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/placement:
|
||||
en: ecfaff2b9b2ef021
|
||||
zh: 85b392d79364e0c3
|
||||
en: 40c838816d0941ed
|
||||
zh: 4f5428a040b33b74
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/key-sequences:
|
||||
en: 130e4f9ec7296a10
|
||||
zh: 2a5aa1302a8db61a
|
||||
|
||||
@@ -309,6 +309,7 @@ Credit frames wait until a consumer that needs sustained high-volume uplink appe
|
||||
| Peer | Admitted at upgrade as one `PeerScope`; `invocation.peer` of every stream on that socket is that Peer |
|
||||
| Item validation | With an uplink codec, every item is decoded strictly, and a failure fails the whole stream with the existing `gateway/input-invalid` (details `{ endpoint, field: 'uplink' }`); without a codec, only JSON-safety validation applies |
|
||||
| Frame size | A single frame keeps the `maxPayload` of `ws`; the inbox limit bounds the accumulated total |
|
||||
| Frame compression | Each connection negotiates `permessage-deflate` when the Gateway config `websocketCompression` is `'deflate'` (the default); frames below 1,024 characters are sent uncompressed. See [the compression decision](2026-10-18-websocket-permessage-deflate.md) |
|
||||
|
||||
### In-process and worker carriers
|
||||
|
||||
@@ -392,6 +393,8 @@ export type RemoteStreamOpener = (
|
||||
// index.ts Config addition
|
||||
/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */
|
||||
readonly streamInboxBytes?: number
|
||||
/** Per-message WebSocket compression; 'none' disables the extension (default 'deflate'). */
|
||||
readonly websocketCompression?: 'none' | 'deflate'
|
||||
```
|
||||
|
||||
`prepareInvocation`:
|
||||
@@ -453,7 +456,7 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec |
|
||||
| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream<Out, In>` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle<Out, In>`; the parameter loop accepts only business parameters and a final `signal` |
|
||||
| `dsh-client-connection` (`peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The `Peer` class; `connection.browserPeer` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` |
|
||||
| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes` and `Config.websocketCompression`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue |
|
||||
| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle |
|
||||
| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs |
|
||||
|
||||
@@ -309,6 +309,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox
|
||||
| Peer | 升级时接纳为一个 `PeerScope`,该 socket 上所有流的 `invocation.peer` 都是它 |
|
||||
| 项校验 | 有 uplink codec 时逐项严格解码,失败即整条流以既有的 `gateway/input-invalid` 失败(details `{ endpoint, field: 'uplink' }`);无 codec 时只做 JSON 安全校验 |
|
||||
| 帧大小 | 单帧沿用 `ws` 的 `maxPayload`;inbox 上限限制累计量 |
|
||||
| 帧压缩 | Gateway 配置 `websocketCompression` 为 `'deflate'`(默认)时,每条连接协商 `permessage-deflate`;少于 1024 个字符的帧不压缩。见[压缩决定](2026-10-18-websocket-permessage-deflate.zh.md) |
|
||||
|
||||
### 进程内与 worker 载体
|
||||
|
||||
@@ -392,6 +393,8 @@ export type RemoteStreamOpener = (
|
||||
// index.ts Config addition
|
||||
/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */
|
||||
readonly streamInboxBytes?: number
|
||||
/** Per-message WebSocket compression; 'none' disables the extension (default 'deflate'). */
|
||||
readonly websocketCompression?: 'none' | 'deflate'
|
||||
```
|
||||
|
||||
`prepareInvocation`:
|
||||
@@ -453,7 +456,7 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec |
|
||||
| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream<Out, In>` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle<Out, In>`;参数循环只认业务参数与末位 `signal` |
|
||||
| `dsh-client-connection`(`peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | `Peer` 类;`connection.browserPeer` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` |
|
||||
| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes` 与 `Config.websocketCompression`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 |
|
||||
| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 |
|
||||
| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 |
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: 6c8583bdefc5e36c
|
||||
zh: 0928b4f2876bbfe4
|
||||
en: d6328ba2b218f2ac
|
||||
zh: 3731561313b1ad5e
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
|
||||
en: d6f40bf02df37ab7
|
||||
zh: e5b85ce9d41e8754
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
|
||||
en: 38d27583533cd028
|
||||
zh: 9bf11f5489c1aa43
|
||||
en: 3064da835e892958
|
||||
zh: bfcf967b6acc6f85
|
||||
+5
-1
@@ -12,12 +12,14 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the Official group displays that metadata. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles.
|
||||
|
||||
An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. Browser and computer providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior.
|
||||
An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. External browser and Cua Driver providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior.
|
||||
|
||||
Bundles insert ordinary Host rows; optional model tools register in the global tool layer, so every preset, including minimal, receives them. Plugin management, profile composition, and reload need no bundle-specific behavior. Search, Ralph, and terminals supply their own isolated services in a Host group. Search opens one in-memory index on demand; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Switching a bundle registers or removes its tools for live Agents, and later user patch layers retain precedence. A replacement title provider may register once the previous provider's disposal starts; the previous provider's late results never commit.
|
||||
|
||||
Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` catalog; the [on-demand native-bundle decision](2026-10-05-official-on-demand-bundles.md) owns the cost, version, and offline-discovery trade-offs.
|
||||
|
||||
Computer Use is a shipped optional bundle in the experimental group. Its bundle uses the explicit naming exception `@deepseek-ai/dsh-cua-bundle`, alongside `@deepseek-ai/dsh-cua`. These names preserve profile references when the capability moves package groups; the experimental classification still controls isolation and promotion. Its repository-owned native component is prepared when the provider loads, without an external Cua Driver runtime.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Ship every provider as an optional bundle.** Composes and displays correctly, but grows every install by provider runtimes for a capability most installations never switch on; voice input's `sherpa-onnx-node` is the one accepted precedent.
|
||||
@@ -31,3 +33,5 @@ Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES`
|
||||
## Consequences
|
||||
|
||||
Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Bundle rows are ordinary Host rows with their usual switches. Individual and combined composition tests cover the Host rows and every preset's tool catalog; live switching and recorded Sessions cover activation and model-visible behavior.
|
||||
|
||||
Computer Use keeps its permission service and native provider on the Host and registers tools in Agent scopes whose bound preset is `ptc`. Existing Agent events and `createScope()` maintain current, new and resumed Agents. Preset declarations remain unchanged. Only the explicit detail-page action changes the new-session default through the existing settings form; the activation switch does not configure PTC execution.
|
||||
+5
-1
@@ -12,12 +12,14 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
|
||||
可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。浏览器与电脑提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。
|
||||
可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。外部浏览器与 Cua Driver 提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。
|
||||
|
||||
Bundle 插入普通 Host 行;可选模型工具注册到全局工具层,因此包括 minimal 在内的所有预设都会获得它们。插件管理、profile 组合与重载无需针对 bundle 的特殊行为。搜索、Ralph 与终端在 Host 组内提供自己的隔离服务。搜索按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。切换 bundle 会为运行中的 Agent 注册或移除其工具,后续用户 patch 层继续优先。前一个标题提供方开始清理后,替代提供方即可注册;前一个提供方的迟到结果不会提交。
|
||||
|
||||
Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,其成本、版本与离线发现取舍由[按需原生 bundle 决策](2026-10-05-official-on-demand-bundles.zh.md)管理。
|
||||
|
||||
Computer Use 是 experimental 包组内随安装提供的可选组合包。组合包与 `@deepseek-ai/dsh-cua` 一起采用显式命名例外 `@deepseek-ai/dsh-cua-bundle`,在能力移动分组时保留配置引用。依赖隔离和晋升仍由实验性分类约束。提供方加载时准备仓库自有的本机组件,无需外部 Cua Driver 运行时。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把所有提供方都做成可选 bundle。** 组合与展示都正确,但会为多数安装从不启用的能力把提供方运行时塞进每次安装;语音输入的 `sherpa-onnx-node` 是唯一被接受的先例。
|
||||
@@ -31,3 +33,5 @@ Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,
|
||||
## 后果
|
||||
|
||||
官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。Bundle 行是普通 Host 行,使用常规开关。独立与组合测试覆盖 Host 行及每个预设的工具目录;实时切换与已记录 Session 覆盖激活和模型可见行为。
|
||||
|
||||
Computer Use 将权限服务和原生提供方保留在 Host,在已绑定 `ptc` preset 的 Agent 作用域中注册工具。现有 Agent 事件和 `createScope()` 维护当前、新建和恢复的 Agent,preset 声明保持不变。只有详情页的显式操作通过现有设置表单修改新会话默认模式,启用开关不配置 PTC 执行方式。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-24-shared-time-vocabulary.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors:
|
||||
en: ae04747237709d0c
|
||||
zh: a3731d98d5e940f9
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem:
|
||||
en: abd0263d14861926
|
||||
zh: e1736cd08e87f795
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
|
||||
en: 52b45f1ac62e65ab
|
||||
zh: 7f83cd4fb8dd09a6
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
|
||||
en: 1737484c099fab52
|
||||
zh: 303b144c52d167bd
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
|
||||
en: 6ec74e7ac065bd5e
|
||||
zh: 04d029059dea732b
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Shared time vocabulary for wall-clock selectors
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-24-shared-time-vocabulary.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-util-time` existed for one job and one caller shape: `canonicalClientTimeZone` validated a caller-reported zone at a wire boundary and answered `undefined`, which `dsh-api-session-controller` and `dsh-subagent` mapped onto their own domain errors. Two other consumers had grown parts of the same vocabulary privately. Schedule held a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and its own IANA zone canonicalizer in `packages/schedule/schedule/src/domain.ts`, throwing `ScheduleInputError` instead of answering a result. `dsh-time-context` held a second copy of the IANA zone profile in `packages/context/time-context/src/request-zone.ts`, with its own regular expression and its own `Intl.DateTimeFormat` canonicalization, to enforce the stricter policy that a request may only name a zone another process resolves back to the same identity. The IANA profile and the `Intl` canonicalization step therefore existed three times in all, so a change to that profile had to be made and verified once per copy, and the next consumer of a wall-clock selector had no shared entry point to reach for.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
|
||||
|
||||
The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split.
|
||||
|
||||
Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound.
|
||||
|
||||
`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers.
|
||||
|
||||
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls.
|
||||
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads.
|
||||
|
||||
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the shared module private to Schedule.** A Schedule-local `time` module beside `domain.ts` would shrink that file without touching a published package, its lockfile entry, its README, or its dependency set. It was rejected because the duplication this change exists to remove is the duplicate *profile*, not the duplicate line count: the zone regular expression, the canonical `Intl` step, and the instant profile would still exist twice, and the next boundary that accepts a wall-clock selector would copy a third.
|
||||
|
||||
**Give the library its own error type.** A `TimeInputError` carrying the reason would let callers rethrow instead of mapping, shortening each call site. It was rejected because `util/time`'s existing contract is that each boundary owns its refusal vocabulary, and Schedule's messages reach the model through the tool result `{ code, message }` and the client's message card; a shared error class would put one caller's wording into another caller's surface.
|
||||
|
||||
**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication.
|
||||
|
||||
**Brand the millisecond count instead of building a class.** `Branded<number>` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home.
|
||||
|
||||
**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.
|
||||
|
||||
**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.
|
||||
|
||||
## Consequences
|
||||
|
||||
The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner.
|
||||
|
||||
The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
|
||||
|
||||
A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation.
|
||||
|
||||
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built.
|
||||
|
||||
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: 面向墙钟选择器的共享时间词汇
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-24-shared-time-vocabulary.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的其余部分被另外两个消费方各自私下养大。Schedule 在 `packages/schedule/schedule/src/domain.ts` 里持有严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及自己的 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。`dsh-time-context` 在 `packages/context/time-context/src/request-zone.ts` 里持有第二份 IANA 时区剖面,配有自己的正则与自己的 `Intl.DateTimeFormat` 规范化,用来执行更严格的策略:一轮请求只能报出另一个进程会解析回同一标识的时区。因此 IANA 剖面与那一步 `Intl` 规范化合计存在三份,该剖面的任何变更都要逐份改、逐份验;而下一个需要墙钟选择器的消费方没有共享入口可用。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
|
||||
|
||||
本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。
|
||||
|
||||
Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。
|
||||
|
||||
`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。
|
||||
|
||||
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。
|
||||
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。
|
||||
|
||||
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把共享模块保持为 Schedule 私有。** 在 `domain.ts` 旁抽出一个 Schedule 私有的 `time` 模块可以缩小它,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。
|
||||
|
||||
**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的消息会通过工具结果的 `{ code, message }` 与客户端的消息卡片到达模型;共享错误类会把一个调用方的措辞带进另一个调用方的界面。
|
||||
|
||||
**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。
|
||||
|
||||
**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded<number>`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。
|
||||
|
||||
**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。
|
||||
|
||||
**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。
|
||||
|
||||
## Consequences
|
||||
|
||||
时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。
|
||||
|
||||
代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
|
||||
|
||||
值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。
|
||||
|
||||
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。
|
||||
|
||||
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
|
||||
@@ -9,11 +9,11 @@
|
||||
en: b32a7ce6483b9b48
|
||||
zh: ec0938ad032b99d8
|
||||
/agent-note-application-consent-for-native-computer-use/decision:
|
||||
en: 92247ff599c1103f
|
||||
zh: 26845420a9711c83
|
||||
en: 36c560c24c376f12
|
||||
zh: 5e4ff72f66574e38
|
||||
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
|
||||
en: 4693dcd217c4fedb
|
||||
zh: 1dc9bf9067314006
|
||||
en: 80cd5fd93a42d937
|
||||
zh: 2a0e241b1b8c2e1d
|
||||
/agent-note-application-consent-for-native-computer-use/consequences:
|
||||
en: 0146a69ab2086edc
|
||||
zh: 4257bc1ab4432c3f
|
||||
en: 4bda3366af65250a
|
||||
zh: 7c83561b4db07284
|
||||
@@ -10,29 +10,38 @@ macOS privileges authorize an execution host, not a user's choice of target appl
|
||||
|
||||
## Decision
|
||||
|
||||
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
|
||||
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. The existing question UI presents the offered labels; the executor rejects custom text or answers that do not select exactly one offered label. Permission-page icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
|
||||
|
||||
A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive task access only, tied to a process instance and Agent connection lifetime. Labels do not establish identity.
|
||||
System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice.
|
||||
|
||||
A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive session access only, tied to a process instance. Labels do not establish identity.
|
||||
|
||||
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
|
||||
|
||||
Selections of the same application within one task are serialized, so a pending question does not produce competing decisions. Saved-grant additions read the current configuration inside one mutation queue. Refusals prevent another prompt within the task; an explicitly restored saved grant takes precedence at dispatch.
|
||||
Session decisions are kept in memory by Session ID and application identity. Agent disposal and helper replacement release native handles without discarding these decisions; provider disposal or DSH restart clears them. This scope covers follow-up turns without relying on the model to decide when a user intent has ended.
|
||||
|
||||
Removing a saved grant invalidates Agent connections retaining the application's handles and cancels active operations; Agent/provider disposal awaits native connection cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator.
|
||||
Selections of the same application within one session are serialized, so a pending question does not produce competing decisions. Saved-grant additions and revocations share one mutation queue and read the current grant list inside it. Provider shutdown cancels writes still waiting for HMR admission; admitted writes settle before disposal completes. Refusals prevent another prompt until the user permits asking again; a restored saved grant takes precedence in both dispatch and the permission-page projection.
|
||||
|
||||
Revocation invalidates the Agent connections that retain the application's handles, then awaits native cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Trust model instructions or hide controls.** Retained objects and direct tool calls can still reach native operations. The executor must enforce the decision at dispatch.
|
||||
|
||||
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; task-only access supports unsigned development applications without that promise.
|
||||
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise.
|
||||
|
||||
**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers.
|
||||
**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. The permission UI observes the provider and its owning settings namespace without another authorization store or event channel.
|
||||
|
||||
**Disable permission buttons based on the browser address.** A user may operate the DSH host through screen sharing and complete authorization there. Port forwarding and proxies make the address an unreliable indication of whether the browser and DSH run on the same computer.
|
||||
|
||||
**Use the nearest GUI ancestor as the permission target.** A source-built Desktop app can sit between Terminal and the helper while macOS still attributes Accessibility and Screen Recording to Terminal. Process responsibility supplies the system’s attribution; ancestry alone does not. The optional SPI adds compatibility risk, so lookup failure omits the application instead of guessing.
|
||||
|
||||
**Keep the native context after revocation.** Its application lookup can return an earlier handle and retain window snapshots. Closing the Agent connection discards its native context through existing transport cleanup; preserving unrelated handles would require a native per-application retirement protocol.
|
||||
|
||||
## Consequences
|
||||
|
||||
Revoking one application also invalidates other application handles on the same Agent connection. Those applications require fresh selection; saved grants remain reusable, while task grants expire. Other tasks with independent connections remain unaffected unless they also hold access to the revoked application.
|
||||
Refusing or revoking one application also invalidates other application handles on the same Agent connection. Those applications require fresh selection; session and saved grants remain reusable. Other sessions with independent connections remain unaffected unless they also hold access to the revoked application.
|
||||
|
||||
Explicit enablement may start the shared helper for a read-only permission probe. The helper captures the application identified by macOS process responsibility before readiness releases the launcher, retaining its name and icon after reparenting. The optional native query is dynamically resolved; unavailable attribution uses generic system-prompt guidance. The macOS prompt remains authoritative. Enabling does not request macOS authorization or inspect application content.
|
||||
|
||||
Application decisions and failure results are exercised through policy regressions and the recorded CUA scenario. Desktop binary distribution, signing, and packaged-host permissions remain outside the source-launch workflow.
|
||||
@@ -10,29 +10,38 @@ macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应
|
||||
|
||||
## 决策
|
||||
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。
|
||||
|
||||
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得任务授权,许可绑定进程实例和 Agent 连接生命周期。显示名称不用于确定身份。
|
||||
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
|
||||
|
||||
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得会话授权,许可绑定进程实例。显示名称不用于确定身份。
|
||||
|
||||
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
|
||||
|
||||
同一任务对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增在同一写入队列内读取当前配置。拒绝会阻止任务内的再次询问;用户显式恢复的长期许可在执行检查时优先于旧拒绝记录。
|
||||
会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。
|
||||
|
||||
删除长期许可会使保留目标应用句柄的 Agent 连接失效,并取消活动操作;Agent 或提供方销毁时等待对应的原生连接清理。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
|
||||
同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。
|
||||
|
||||
撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。
|
||||
|
||||
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;任务授权支持未签名的开发应用,但不承诺跨进程复用。
|
||||
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
|
||||
|
||||
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。
|
||||
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。
|
||||
|
||||
**根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。
|
||||
|
||||
**把最近的 GUI 祖先当作授权对象。** 源码构建的 Desktop 可以位于 Terminal 和 helper 之间,但 macOS 仍将辅助功能和屏幕录制归属于 Terminal。责任进程记录提供系统认定的归属,仅凭父进程链无法确定。可选内部接口有兼容性风险,因此查询失败时省略应用信息,不再猜测。
|
||||
|
||||
**撤销后保留原生状态。** 原生应用查找可能返回旧句柄并保留窗口快照。关闭 Agent 连接可以通过现有传输清理释放其原生状态;保留其他应用的句柄则需要新增按应用销毁状态的原生协议。
|
||||
|
||||
## 影响
|
||||
|
||||
撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;长期许可仍可复用,任务许可随之失效。其他任务使用独立连接,除非也持有被撤销应用的许可,否则不受影响。
|
||||
拒绝或撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;会话许可和长期许可仍可复用。其他会话使用独立连接,除非也持有被撤销应用的许可,否则不受影响。
|
||||
|
||||
显式启用可以启动共享 helper 进行只读权限检查。helper 在发布就绪状态、让启动器退出之前,记录 macOS 责任进程对应的应用,因此父进程改变后仍能提供应用名称和图标。可选的本机查询通过动态加载解析,无法取得归属时显示通用系统弹窗指引。授权归属仍以 macOS 提示为准。启用不会申请 macOS 授权或检查应用内容。
|
||||
|
||||
授权决定和失败结果由策略回归测试及 CUA 录制会话验证。桌面安装包的二进制分发、签名和打包宿主权限不属于源码启动流程。
|
||||
+2
-2
@@ -33,5 +33,5 @@
|
||||
en: 939cedceed200cb2
|
||||
zh: c7bb02d0da400c8d
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/consequences:
|
||||
en: 1b9e31b4e773fac5
|
||||
zh: c140ce07fc64bd55
|
||||
en: 5a0120a6a0d48912
|
||||
zh: 649e7bc118bda1a2
|
||||
+1
-1
@@ -113,5 +113,5 @@ Tests need no API key and make no model calls.
|
||||
|
||||
- With HMR, supported package operations publish the updated private dependencies and remove unused mappings after plugins stop. Without HMR, a pending startup-bundle disablement defers later publications until restart, preserving running plugins' mappings.
|
||||
- In the supported thread, package.json configuration reads use the changed fields; JSON modules retain their own reload behavior. The separate module, loader-thread, and Worker limitations above still apply.
|
||||
- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 22, 24, and 26.
|
||||
- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 24 and 26.
|
||||
- The implementation depends on several Node internal interfaces, and these tests must run again for Node upgrades.
|
||||
+1
-1
@@ -113,5 +113,5 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
|
||||
|
||||
- 有 HMR 时,支持的包操作发布更新后的私有依赖,并在插件停止后移除不再需要的映射。没有 HMR 时,待生效的启动 bundle 停用会将后续发布延迟到重启,保留运行中插件的映射。
|
||||
- 在支持的线程内,package.json 配置读取使用更新后的字段;JSON 模块保留自己的重载行为。上述模块、loader 线程及 Worker 的独立限制仍然适用。
|
||||
- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。
|
||||
- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 24、26 上与原生读取逐项比较。
|
||||
- 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 84858bc0e1e268ab
|
||||
zh: 39ebb2979968d6cc
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/decision:
|
||||
en: ca3153ae96994baa
|
||||
zh: de6c7d925559efbb
|
||||
en: 043e48fc04003f7d
|
||||
zh: 15842c3db3fc83f4
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
|
||||
en: db8d77cd773ad646
|
||||
zh: ab2f03b8fc58e952
|
||||
|
||||
@@ -14,7 +14,7 @@ A notice is neither body text nor a user message: folding and bottom advancement
|
||||
|
||||
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`.
|
||||
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange.
|
||||
- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive.
|
||||
- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and with the default Replies After Collapse choice every reply row also stays outside the whole-Turn disclosure ([keep every Assistant reply](../feature/2026-10-15-chat-keep-assistant-replies.md)).
|
||||
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
|
||||
|
||||
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。
|
||||
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。
|
||||
- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。
|
||||
- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;在默认的「收起后显示的回复」选项下,每个回复行也留在整轮折叠之外([整轮折叠保留全部 Assistant 回复](../feature/2026-10-15-chat-keep-assistant-replies.zh.md))。
|
||||
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
|
||||
|
||||
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-session-projection-registration-handles.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers:
|
||||
en: 693349255afbaec8
|
||||
zh: 13bcbf3f40b93b2b
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/problem:
|
||||
en: 47ba548b869be190
|
||||
zh: c2e5038a56fe741c
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
|
||||
en: be0d17bfbd1b59d4
|
||||
zh: 7a23bccdf95c21ff
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
|
||||
en: e7c66403fab991d2
|
||||
zh: 1580ec29116c7d42
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
|
||||
en: 31ce6f02dd3bf39d
|
||||
zh: bcd29b2146f689b5
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Asynchronous projection registration and retained readers
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-session-projection-registration-handles.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Host readers coupled to registry key lookup lose access when a plugin unloads, although an Agent may still need projected state while its asynchronous cleanup drains. Repeated batch snapshots materialize unrelated client views. A synchronous registration API also makes every consumer assume that acquisition can never require asynchronous initialization.
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
|
||||
|
||||
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
|
||||
|
||||
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
|
||||
|
||||
Plugins compiled against the synchronous API keep running during migration. The awaitable registration is also the original Cordis effect disposer, so calling it releases the registration and a composing effect adopts its ownership; that call is deprecated. Registry-level `stateOf(session, key)` remains a deprecated read returning `unknown`; it loses access after the final release and logs one `ctx.logger.warn` per key per registry. Registration itself does not warn, because an ignored result is indistinguishable from one awaited later. A legacy plugin may never observe the registration, so the registry attaches its own rejection handler: once the current macrotask and its microtasks finish, a rejection nobody observed through `then`, `catch`, or `finally` is logged as an error while the plugin stays active, and one whose owner is no longer loading or active is dropped. TypeScript consumers migrate their declarations; the host state map is not restored.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
|
||||
|
||||
**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness.
|
||||
|
||||
**Register the same units for each Agent.** The definitions already contain per-Session cells. Additional references and asynchronous Agent initialization add a creation race without providing independent data ownership.
|
||||
|
||||
**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break.
|
||||
|
||||
**Require every external plugin to migrate immediately.** Compiled plugins fail at runtime on the missing disposer and keyed read, although the projection keys and state fields they read are unchanged.
|
||||
|
||||
**Return a new wrapper around the disposer.** Cordis composition adopts effects by their returned identity. A wrapper leaves the original registration owned by the fiber, so owner disposal can release it before the composed cleanup finishes.
|
||||
|
||||
**Dispose the plugin after an ignored rejection.** This approximates the old failed activation, but it needs a startup observer, runs only after the plugin's services became visible, and ends in `DISPOSED` rather than a retryable `FAILED`. Logging keeps the failure visible without a second lifecycle path.
|
||||
|
||||
## Consequences
|
||||
|
||||
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 异步投影注册与保留读取句柄
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-session-projection-registration-handles.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
依赖注册表键查询的 Host 读取方会在插件卸载时失去访问能力,但 Agent 可能仍需在异步清理排空期间读取投影状态。重复的批量快照会具象化无关 Client 视图。同步注册 API 也使每个消费者假定获取过程永远不需要异步初始化。
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
|
||||
|
||||
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
|
||||
|
||||
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
|
||||
|
||||
按同步 API 编译的插件在迁移期间继续运行。可等待的注册结果同时就是原有的 Cordis effect 释放函数:调用它会释放注册,组合 effect 也会接管其所有权;该调用已弃用。注册表级 `stateOf(session, key)` 作为已弃用的读取保留,返回 `unknown`;最后一次释放后它失去访问,并且每个 registry 对每个键输出一次 `ctx.logger.warn`。注册本身不产生警告,因为被忽略的结果与稍后才等待的结果无法区分。旧插件可能从不观察注册结果,因此 registry 挂上自己的拒绝处理函数:当前宏任务及其微任务结束时,没有任何调用方通过 `then`、`catch` 或 `finally` 观察的拒绝会被记录为错误,插件保持激活;owner 已不在加载或激活状态的拒绝会被丢弃。TypeScript 消费方仍需迁移声明;宿主状态映射不再恢复。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
|
||||
|
||||
**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。
|
||||
|
||||
**为每个 Agent 注册相同单元。** 定义已经包含逐 Session 单元。额外引用和异步 Agent 初始化只会引入创建竞态,不提供独立数据所有权。
|
||||
|
||||
**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。
|
||||
|
||||
**要求所有外部插件立即迁移。** 已编译的插件会因缺失的释放函数和按键读取在运行时失败,尽管它们读取的投影键与状态字段都没有变化。
|
||||
|
||||
**返回包装释放函数的新函数。** Cordis 组合按返回值身份接管 effect。包装函数会让原注册继续归 fiber 所有,因此 owner 卸载可能在组合清理结束前释放它。
|
||||
|
||||
**被忽略的拒绝出现后卸载插件。** 这近似于旧 API 的激活失败,但需要启动观察器,只能在插件服务已经可见后执行,并以 `DISPOSED` 而非可重试的 `FAILED` 结束。记录日志能让失败可见,而无需第二条生命周期路径。
|
||||
|
||||
## Consequences
|
||||
|
||||
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 972bbe45fe8ac28e
|
||||
zh: 593b851c142894e1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
|
||||
en: f44b784545c6d9a2
|
||||
zh: 3eb6fcb9f348dec4
|
||||
en: 913d1dceab36d4a1
|
||||
zh: 049521c5127f3478
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
|
||||
en: b8278678170f1507
|
||||
zh: 113a51ba5cf27de1
|
||||
|
||||
@@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su
|
||||
- `finish`: install the final room.
|
||||
|
||||
Content added during the batch updates the remaining movement by starting a new segment.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow.
|
||||
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
|
||||
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
|
||||
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
|
||||
|
||||
@@ -38,7 +38,7 @@ Status: implemented
|
||||
- `finish`:安装最终补白。
|
||||
|
||||
批次中途新增的内容按分段规则更新剩余运动。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。
|
||||
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
|
||||
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
|
||||
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-registry-bundle-updates.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md
|
||||
/agent-note-registry-bundle-updates:
|
||||
en: 9b55699b0d5b3c7b
|
||||
zh: 729a564af144048d
|
||||
/agent-note-registry-bundle-updates/problem:
|
||||
en: 47524f94e08192b5
|
||||
zh: 335104b6fda38f03
|
||||
/agent-note-registry-bundle-updates/decision:
|
||||
en: 9201e6cc6cad6b8d
|
||||
zh: be2332897037c43a
|
||||
/agent-note-registry-bundle-updates/alternatives-considered:
|
||||
en: 299b8e211b9c21bf
|
||||
zh: 72bd106e96b42605
|
||||
/agent-note-registry-bundle-updates/consequences:
|
||||
en: 57c30a10a233c3e0
|
||||
zh: 87cd51e2df8696bd
|
||||
/agent-note-registry-bundle-updates/verification:
|
||||
en: ab3442415bff92a6
|
||||
zh: 89d477c7d14f6098
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Registry bundle updates
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-14-registry-bundle-updates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Third-party bundles installed from a registry had no update path. The plugin page offered **Update** only for Official bundles, whose target is the running DSH version, so a person learned of a newer third-party release outside DSH and replaced it by uninstalling and installing again. With HMR an install over an installed package is refused, and a manual removal followed by a failed installation leaves the plugin uninstalled.
|
||||
|
||||
## Decision
|
||||
|
||||
**Versions are read through `pnpm view` in the profile directory.** `listVersions` runs `pnpm view <name> versions time dist-tags` over the same registry plan as `inspect`, so the `.npmrc` chain, proxy, and authentication that the installation uses also decide what the list shows, and a registry that cannot be reached moves the lookup to the next one. pnpm prints a range lookup as its highest match only, so each listed version takes its own `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh` on the registry that answered. `versionListLimit` bounds how many newer versions are read and `versionLookupConcurrency` bounds the subprocesses in flight. Compatibility reuses `evaluatePluginCompatibility` with the profile's exemptions, so the list and the installation judge peers the same way.
|
||||
|
||||
**The Host owns the update and its recovery.** `updateBundle` views the target first and refuses a missing version, one that declares no bundle, or one whose `engines.dsh` or DSH peers reject the running version before anything changes. With HMR it then calls `removeBundle` and `installBundle(name@version, { saveExact, registry, enabled })`; when that installation fails, is cancelled, or installs but cannot start, it removes a target that stayed and installs the removed version again from the registry it came from, else over the configured plan. The reinstallation carries no request id, so a cancellation of the update cannot stop it. Without HMR it installs over the running version, which `installBundle` allows there and reports as `restart-required`. `plugin-manager/update-state` announces each step for the dialog's progress list.
|
||||
|
||||
**Eligibility is a listed fact.** `BundleInfo.updatable` marks a third-party profile dependency saved as a registry spec under its own name and not as an `npm:` alias, with a semver version and no plugin management; the page shows the Versions section only for those, and both methods refuse every other bundle. The `plugin_manager` tool, which has no update action, omits the field.
|
||||
|
||||
**Updates only move forward.** The page lists versions newer than the installed one and `updateBundle` refuses any other, so an update never asks a plugin to read data a newer version wrote.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fetch the abbreviated packument over HTTP.** One request returns every version's `peerDependencies` and `deprecated`, but the Host would have to resolve registry, scope, authentication, and proxy settings from the `.npmrc` chain itself and keep that resolution identical to pnpm's. A list from a registry pnpm would not install from shows versions that cannot be installed.
|
||||
|
||||
**`pnpm outdated`.** It reports current, wanted, and latest only, judges no DSH peers, and with exact saves wanted equals current.
|
||||
|
||||
**Orchestrate removal and installation in the browser.** The Official update did so. A browser that closes or loses its connection between the two calls leaves the package removed with nobody to reinstall it, and the target check before removal would be a second, bypassable copy of the Host's rule.
|
||||
|
||||
**List versions while reading bundles.** `listBundles` is offline; adding registry lookups would make opening the plugin page a network operation with one subprocess per version.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Checking a package costs one `pnpm view` plus one per listed version, each bounded by `inspectTimeoutMs`.
|
||||
- A target saved by an update, and a reinstallation after a failed one, are exact versions even when the profile had saved a range; with HMR the bundle joins the end of the selection.
|
||||
- Removal and installation take the profile lock separately, so another operation can run between them; an installation that rejects, for example on a lock timeout, still triggers the reinstallation.
|
||||
- A version whose dependencies need a new build-script approval fails to install and is reinstalled at the previous version; its scripts are approved through an ordinary installation.
|
||||
- A reinstallation that also fails leaves the package uninstalled, and the dialog says so.
|
||||
|
||||
## Verification
|
||||
|
||||
| Coverage | Location |
|
||||
|---|---|
|
||||
| Listing, registry fallback, refusals, compatibility, prereleases, deprecation, and the version limit | `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| Update through removal and installation, reinstallation after a failed, cancelled, or rejected target, a failed reinstallation, refusal before any change, removal failure, and the no-HMR path | `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| `pnpm view` answer parsing | `packages/boot/plugin-manager/tests/versions.spec.ts` |
|
||||
| Version checks, update state, and step progress in the store | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
|
||||
| Versions section, toolbar check, and every update outcome | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: 注册表组合包更新
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-14-registry-bundle-updates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。
|
||||
|
||||
## 决策
|
||||
|
||||
**版本通过在 profile 目录中运行 `pnpm view` 读取。** `listVersions` 按与 `inspect` 相同的注册表计划运行 `pnpm view <name> versions time dist-tags`,因此安装所用的 `.npmrc` 链、代理与认证同样决定列表显示什么,连不上的注册表会使查询转向下一个。pnpm 对范围查询只打印最高的匹配版本,因此每个列出的版本在作答的注册表上单独运行一次 `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh`。`versionListLimit` 限制读取的新版本个数,`versionLookupConcurrency` 限制同时运行的子进程个数。兼容性复用 `evaluatePluginCompatibility` 与 profile 的豁免,因此列表与安装以同样方式判断 peer。
|
||||
|
||||
**更新及其恢复由 Host 负责。** `updateBundle` 先查询目标版本,在任何改动之前拒绝不存在的版本、未声明组合包的版本,以及 `engines.dsh` 或 DSH peer 拒绝当前运行版本的版本。有 HMR 时,它随后调用 `removeBundle` 与 `installBundle(name@version, { saveExact, registry, enabled })`;该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从被移除版本原来的注册表重新安装它,没有记录时按配置的计划安装。重新安装不带请求 id,因此取消更新无法中止它。没有 HMR 时,它直接安装在运行中的版本之上,`installBundle` 在这种情况下允许这样做并报告 `restart-required`。`plugin-manager/update-state` 通告每一步,供对话框的进度列表使用。
|
||||
|
||||
**更新资格是列出的事实。** `BundleInfo.updatable` 标记以自身包名、按注册表 spec 保存(不是 `npm:` 别名)、带 semver 版本且不承载插件管理的第三方 profile 依赖;页面只为这些组合包显示版本区块,两个方法都拒绝其他组合包。没有更新操作的 `plugin_manager` 工具省略该字段。
|
||||
|
||||
**更新只向前。** 页面只列出比已安装版本新的版本,`updateBundle` 拒绝其他版本,因此更新不会让插件读取更新版本写入的数据。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**通过 HTTP 获取精简 packument。** 一次请求即可返回每个版本的 `peerDependencies` 与 `deprecated`,但 Host 必须自行从 `.npmrc` 链解析注册表、scope、认证与代理设置,并保持与 pnpm 的解析一致。来自 pnpm 不会从中安装的注册表的列表,会显示无法安装的版本。
|
||||
|
||||
**`pnpm outdated`。** 它只报告 current、wanted 与 latest,不判断 DSH peer,而在确切版本保存下 wanted 等于 current。
|
||||
|
||||
**在浏览器中编排移除与安装。** 官方组合包的更新就是这样做的。浏览器在两次调用之间关闭或断开连接时,包已被移除且无人重新安装,而移除前的目标检查会成为 Host 规则的第二份、可被绕过的副本。
|
||||
|
||||
**在读取组合包时列出版本。** `listBundles` 不联网;加入注册表查询会让打开插件页变成网络操作,且每个版本一个子进程。
|
||||
|
||||
## 后果
|
||||
|
||||
- 检查一个包需要一次 `pnpm view`,再加上每个列出版本一次,每次都受 `inspectTimeoutMs` 限制。
|
||||
- 更新保存的目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。
|
||||
- 移除与安装分别获取 profile 锁,其他操作可能在两者之间运行;安装被拒绝(例如锁等待超时)时,同样会触发重新安装。
|
||||
- 依赖需要新的构建脚本批准的版本会安装失败,并重新安装为原版本;其脚本通过普通安装批准。
|
||||
- 重新安装也失败时,包处于未安装状态,对话框会如实说明。
|
||||
|
||||
## 验证
|
||||
|
||||
| 覆盖 | 位置 |
|
||||
|---|---|
|
||||
| 列表、注册表回退、拒绝、兼容性、预发布、弃用与版本上限 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| 经移除与安装的更新、目标失败、取消或被拒绝后的重新安装、重新安装失败、改动前的拒绝、移除失败与无 HMR 路径 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| `pnpm view` 答复解析 | `packages/boot/plugin-manager/tests/versions.spec.ts` |
|
||||
| store 中的版本检查、更新状态与步骤进度 | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
|
||||
| 版本区块、工具栏检查与每种更新结果 | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-16-separate-instruction-chain-budgets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.md
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains:
|
||||
en: 8ad05eb6bd0511b9
|
||||
zh: bd0658fa58edc70d
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/problem:
|
||||
en: 77f3bb05053ce59f
|
||||
zh: 4a1759900b71cc44
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/decision:
|
||||
en: 5dd811039ee2fef3
|
||||
zh: c9c1dc27890ff204
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/alternatives-considered:
|
||||
en: 157837c9fbc377cb
|
||||
zh: 32fb2d8427529772
|
||||
/agent-note-separate-byte-budgets-for-the-user-global-and-project-instruction-chains/consequences:
|
||||
en: 186092d64376de0c
|
||||
zh: 92fe504ff77cba71
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Agent Note: Separate byte budgets for the user-global and project instruction chains
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-16-separate-instruction-chain-budgets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-agent-instructions` rendered the user-global and project files as one chain under one `maxBytes`, the rule of the [original workspace-context decision](../../archived/feature/2026-06-24-workspace-context.md). Rendering omits the broadest files first, and user-global files are the broadest, so a large project chain removed them: under the shipped 65,536-byte budget, 20 KiB user-global, 30 KiB project root, and 20 KiB nested files rendered without the user-global file.
|
||||
|
||||
## Decision
|
||||
|
||||
The renderer splits the loaded files into the user-global and project chains, keeping their order, and renders each under the same `maxBytes`: omit the broadest files, truncate the last file, then fall back to the compact intro, to the notice with an empty heading, and to the notice alone. The project chain keeps the result it has when rendered alone; user-global files are omitted or truncated only beyond their own budget, except in the fallback below.
|
||||
|
||||
When all files fit `maxBytes` together, or only one chain has files, the output is unchanged byte for byte. Otherwise one `<system-reminder>` frame of at most twice `maxBytes` holds the merged notice when a file was omitted or truncated, one intro when a chain kept its frame, and the rendered sections in loaded order, with user-global files first in a baseline. If a chain renders without a frame and the merged frame would exceed that bound, all files render as one chain under one `maxBytes`, which can omit a user-global file that fit its own budget; this needs a budget of a few hundred bytes or a notice that nearly fills or exceeds `maxBytes`. Reconciliation batches apply the same split.
|
||||
|
||||
The `maxBytes` key and the baseline identity are unchanged, so a resumed session keeps its visible baseline and receives a user-global file that baseline omitted as one additional-instructions transition.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One budget that gives user-global files priority.** A large user-global file then truncates or omits the project root file, and bytes near the limit decide which chain loses content.
|
||||
|
||||
**One budget with a user-global share.** The project chain still shrinks, and no measurement supports a share size.
|
||||
|
||||
**A configuration field for the second chain.** No deployment needs different sizes, and the field would join the baseline identity, so resumed sessions would resend their baselines.
|
||||
|
||||
**The Codex layout: an unbounded user-global file and a root-first project chain.** Output has no bound, and the project chain keeps its broadest files, not its most specific ones.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A baseline or batch holding both chains can reach twice `maxBytes`; deployments that sized `maxBytes` as a total budget halve it, per the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.1/agent-instructions-chain-budgets/guide.md).
|
||||
- User-global instructions survive a large project chain.
|
||||
- The `agent-instructions-budget` snapshot shows a truncated user-global file beside the whole project file; `agent-instructions-budget-chains` shows two chains that fit only separately.
|
||||
- The package README, `Config.maxBytes` JSDoc, and configuration catalog state the per-chain bound.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Agent Note: 用户全局指令链与项目指令链分别使用字节预算
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-16-separate-instruction-chain-budgets.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-agent-instructions` 曾把用户全局文件与项目文件作为一条指令链,在一个 `maxBytes` 内渲染,这是[最初的工作区上下文决策](../../archived/feature/2026-06-24-workspace-context.md)定下的规则。渲染会最先省略最宽泛的文件,而用户全局文件最宽泛,因此较大的项目指令链会移除它们:在默认的 65,536 字节预算下,20 KiB 的用户全局文件、30 KiB 的项目根文件与 20 KiB 的嵌套文件一起渲染时,用户全局文件被省略。
|
||||
|
||||
## 决策
|
||||
|
||||
渲染器把已加载的文件分成用户全局指令链与项目指令链,各自保持原有顺序,并分别在同一个 `maxBytes` 内渲染:先省略最宽泛的文件,再截断最后一个文件,然后依次退回到精简引言、带空标题的通知和仅含通知的文本。项目指令链的结果与它单独渲染时相同;用户全局文件只有超出自身预算时才会被省略或截断,下文的回退情形除外。
|
||||
|
||||
所有文件合起来能放进 `maxBytes`,或只有一条链有文件时,输出逐字节不变。其他情况下,一个至多为 `maxBytes` 两倍的 `<system-reminder>` 框架包含合并后的通知(仅当有文件被省略或截断时)、一段引言(仅当某条链保留了框架时),以及按加载顺序排列的渲染段落,因此基线中用户全局文件在前。若某条链在没有框架的情况下渲染,且合并后的框架会超过该上限,则所有文件作为一条链在一个 `maxBytes` 内渲染,这可能省略一个本可放进自身预算的用户全局文件;这只会在预算只有几百字节,或通知接近或超过 `maxBytes` 时发生。对账批次采用同样的拆分。
|
||||
|
||||
`maxBytes` 配置键与基线标识都不变,因此恢复的会话保留其可见基线;该基线曾省略的用户全局文件会以一次附加指令变更的形式加入。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**一个预算,并优先保留用户全局文件。** 较大的用户全局文件会截断或省略项目根文件,而且临界值附近的字节差异就决定哪条链丢失内容。
|
||||
|
||||
**一个预算,并为用户全局文件划出份额。** 项目指令链仍会缩小,而且没有测量数据支撑份额的大小。
|
||||
|
||||
**为第二条链增加配置字段。** 没有部署需要为两条链设定不同大小,而且该字段会进入基线标识,使恢复的会话重新发送基线。
|
||||
|
||||
**采用 Codex 的布局:不设上限的用户全局文件,加上从根目录开始保留的项目指令链。** 输出没有上限,项目指令链会保留最宽泛的文件,而不是最具体的文件。
|
||||
|
||||
## 影响
|
||||
|
||||
- 同时包含两条链的基线或批次最多可达 `maxBytes` 的两倍;把 `maxBytes` 当作总预算设定的部署应将其减半,见[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.1/agent-instructions-chain-budgets/guide.zh.md)。
|
||||
- 用户全局指令不会因项目指令链较大而被移除。
|
||||
- `agent-instructions-budget` snapshot 显示被截断的用户全局文件与完整的项目文件;`agent-instructions-budget-chains` 显示两条只能各自放下的链。
|
||||
- 包 README、`Config.maxBytes` 的 JSDoc 与配置目录说明了按链计算的上限。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-16-steering-batch-start-in-definition-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-steering-batch-start-in-definition-state.md
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state:
|
||||
en: 6f27c4f6133966ba
|
||||
zh: 36b1e3bf1b809f09
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/problem:
|
||||
en: 6901df0549909883
|
||||
zh: 3e7f9698a26fe568
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/decision:
|
||||
en: 581f8768c22fda63
|
||||
zh: 1db7d743dbe65e96
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/alternatives-considered:
|
||||
en: 479dc0bcce2c7674
|
||||
zh: a9fa5ed6eaa40919
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/consequences:
|
||||
en: 66b21a77e1fa35f1
|
||||
zh: 8a3e13e94a27a3bf
|
||||
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/testing:
|
||||
en: 4b0da9e7eeb3906a
|
||||
zh: 35ea5569f39939d6
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Precompute the steering batch start in Conversation Node Definition state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-16-steering-batch-start-in-definition-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Chat keeps a 44px slot, the height of the running status row, above the first steering row of each claim that lands after the running Turn's process output, so admitting steering swaps the status and the bubble without moving the bubble ([conversation-nodes README](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md)). Deciding that a steering row starts such a batch needs two facts its own `user/message` event does not carry: whether it is the first human message of its `next-step` claim, and whether the same Turn already has durable visible output before it, which places the row after the Turn's process control. The answer must hold for live appends, replay, and loading earlier history, and must not scan the event window or the Chat Nodes.
|
||||
|
||||
## Decision
|
||||
|
||||
The batch start is a fact in Conversation Node State, computed by Definitions through the existing [`reader.previous`](2026-08-09-client-conversation-node-assembly.md) lookup:
|
||||
|
||||
- [`inbox.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) records `firstClaimedHuman`, the first human message removed by each `next-step` claim, in the Client Inbox State.
|
||||
- [`turn-process.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) registers `processEvidenceDefinition` (kind `process-evidence`, `publication: () => 'none'`): one Context per Step with durable visible output, id `turn:step`, matched from `assistant/message`, `tool/call`, `tool/result`, and `llm/retry` through the existing `processEvidence` test. Streaming chunks never reach it. Its State holds the Turn number; the Context's matches accumulate that Step's evidence events, and nothing reads them.
|
||||
- [`message.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) calls `reader.previous('process-evidence')` in `start`, only for the claim's first human message. When the nearest earlier record belongs to the same Turn, the steering State carries `batchStart: true`. Later appends have larger seqs and cannot change the answer; loading earlier history replays the dependency through the assembler's tracking of `reader.previous`, as Message already does for Inbox.
|
||||
- The steering renderer reads only `data.batchStart` and writes `data-chat-steering-batch="start"` on its bubble root; CSS gives the containing row the slot.
|
||||
|
||||
The lookup costs O(log N) per claim-leading human message, where N is the number of Steps with output in the loaded window. The change adds no framework API and no persisted or wire data.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Compute it in the renderer from props.** The renderer would need neighbouring rows and the Turn's process state, which puts a steering branch into the business-free node list and Seat, and the row would not re-render when only a neighbour changes.
|
||||
|
||||
**Compute it in the Chat snapshot builder.** A projector over the ordered entries would put a steering-specific rule and an extra pass into the generic build layer.
|
||||
|
||||
**Add tracked Turn-data reads to the framework (`reader.turnData`).** It changes the `ConversationNodeBuildReader` contract and the assembler's dependency tracking for one consumer, while `reader.previous` already gives the needed ordering, dependency, and replay.
|
||||
|
||||
## Consequences
|
||||
|
||||
The decision lives in node data, follows replay and earlier-history loading through existing dependency tracking, and leaves the renderer, Seat, node list, and ui-conversation framework unchanged. It costs one more Definition and one Context per Step with output; each State is one number, and each Context holds references to its Step's evidence events. The slot follows the claim's first human message rather than its first rendered row, so a runtime-context notice logged before that message in the same claim sits above the slot; this follows from the README's rule that the claim's first human message keeps the slot.
|
||||
|
||||
## Testing
|
||||
|
||||
- [conversation-node-definitions.client.spec.ts](../../../../packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts): `records durable process evidence once per Step and ignores live chunks and invisible replies` pins evidence matching; `marks each claim lead and starts a batch only after the Turn control`, run for replay, live appends, and prepends, pins batch starts for the first and a later claim, no batch start for the second row of a claim, an idle claim that opens a Turn, or a claim after a Step without output.
|
||||
- [chat-view.client.spec.tsx](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx): `marks the steering row whose data carries a batch start, and no other input` pins the rendered attribute; [turn-tail-spacing.client.spec.ts](../../../../packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts) pins the 44px slot rule.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 在 Conversation Node Definition 的 State 中预计算插话批首
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-16-steering-batch-start-in-definition-state.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Chat 在每次 claim 的第一条插话行上方保留 44px 的位置(等于运行状态行的高度),条件是这批插话落在运行中 Turn 的过程输出之后;这样插话被领取时,运行状态与气泡交换位置,气泡不动([conversation-nodes README](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md))。判断一条插话是否是批首,需要两个它自己的 `user/message` 事件里没有的事实:它是否是所在 `next-step` claim 的第一条人类消息;同一 Turn 在它之前是否已有持久可见的输出,也就是这一行是否排在 Turn 过程控件之后。结论在实时追加、回放和加载更早历史时都必须成立,并且不能扫描事件窗口或 Chat Node。
|
||||
|
||||
## 决策
|
||||
|
||||
批首是 Conversation Node State 中的事实,由 Definition 通过现有的 [`reader.previous`](2026-08-09-client-conversation-node-assembly.zh.md) 查询计算:
|
||||
|
||||
- [`inbox.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 在 Client Inbox State 中记录 `firstClaimedHuman`,即每次 `next-step` claim 取走的第一条人类消息。
|
||||
- [`turn-process.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) 注册 `processEvidenceDefinition`(kind `process-evidence`,`publication: () => 'none'`):每个有持久可见输出的 Step 一个 Context,id 为 `turn:step`,通过现有的 `processEvidence` 判断从 `assistant/message`、`tool/call`、`tool/result`、`llm/retry` 匹配。流式 chunk 不会进入它。它的 State 只保存 Turn 编号;Context 的 matches 累积该 Step 的证据事件,没有任何代码读取它们。
|
||||
- [`message.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) 只对 claim 的第一条人类消息,在 `start` 中调用 `reader.previous('process-evidence')`。最近的一条更早记录属于同一 Turn 时,插话 State 带上 `batchStart: true`。之后追加的事件 seq 都更大,不会改变结论;加载更早历史时,组装器对 `reader.previous` 的依赖追踪会重放这个依赖,与 Message 读取 Inbox 的方式相同。
|
||||
- 插话渲染器只读取 `data.batchStart`,在气泡根元素上写 `data-chat-steering-batch="start"`;CSS 给所在行保留这个位置。
|
||||
|
||||
每条领头的人类消息做一次 O(log N) 查询,N 为已加载窗口中有输出的 Step 数。这一改动不新增框架 API,也不涉及持久化或传输数据。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在渲染器里根据 props 计算。** 渲染器需要相邻行和 Turn 的过程状态,这会把插话分支带进本应不含业务的节点列表和 Seat;而且只有相邻行变化时,这一行不会重新渲染。
|
||||
|
||||
**在 Chat 快照构建层计算。** 对有序条目做一次投影,会把插话专用的规则和额外一轮遍历放进通用构建层。
|
||||
|
||||
**给框架增加带追踪的 Turn 数据读取(`reader.turnData`)。** 为了一个使用方修改 `ConversationNodeBuildReader` 的约定和组装器的依赖追踪;而 `reader.previous` 已经提供所需的顺序、依赖与重放。
|
||||
|
||||
## 后果
|
||||
|
||||
结论保存在节点数据中,借助现有依赖追踪在回放和加载更早历史时保持正确;渲染器、Seat、节点列表和 ui-conversation 框架都不变。代价是多一个 Definition,每个有输出的 Step 多一个 Context:每个 State 只有一个数字,每个 Context 持有该 Step 证据事件的引用。保留的位置跟随 claim 的第一条人类消息,而不是该批第一条渲染出来的行;因此同一次 claim 中记录在它之前的 runtime-context 通知会排在这个位置上方;这由 README 中“claim 的第一条人类消息保留该位置”的规则推出。
|
||||
|
||||
## 测试
|
||||
|
||||
- [conversation-node-definitions.client.spec.ts](../../../../packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts):`records durable process evidence once per Step and ignores live chunks and invisible replies` 固定证据匹配;`marks each claim lead and starts a batch only after the Turn control` 分别以回放、实时追加和加载更早历史三种方式运行,固定第一次和之后一次 claim 的批首,以及同一 claim 的第二行、开启 Turn 的空闲 claim、无输出 Step 之后的 claim 都不是批首。
|
||||
- [chat-view.client.spec.tsx](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx):`marks the steering row whose data carries a batch start, and no other input` 固定渲染出的属性;[turn-tail-spacing.client.spec.ts](../../../../packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts) 固定 44px 位置的规则。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
|
||||
en: 9105f8148e107f43
|
||||
zh: a160e87bb0605da4
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
|
||||
en: 5071232d9743096c
|
||||
zh: b553a626538b3466
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
|
||||
en: 1b61d351c4d2bdf1
|
||||
zh: d6f8180b30d97b34
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
|
||||
en: acc465cfe02b624c
|
||||
zh: db1d06574f647130
|
||||
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
|
||||
en: 9d8486586d044709
|
||||
zh: 24c7249421fd2541
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Delivering messages from outside an Agent through the Agent controller
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
|
||||
|
||||
## Decision
|
||||
|
||||
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
|
||||
|
||||
| Part | Contract |
|
||||
|---|---|
|
||||
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
|
||||
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
|
||||
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
|
||||
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
|
||||
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
|
||||
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
|
||||
|
||||
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
|
||||
|
||||
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
|
||||
|
||||
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
|
||||
|
||||
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
|
||||
|
||||
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
|
||||
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
|
||||
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
|
||||
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
|
||||
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
|
||||
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
|
||||
|
||||
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
|
||||
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
|
||||
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
|
||||
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
|
||||
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
|
||||
|
||||
## 决策
|
||||
|
||||
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
|
||||
|
||||
| 部分 | 约定 |
|
||||
|---|---|
|
||||
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
|
||||
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
|
||||
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
|
||||
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
|
||||
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
|
||||
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
|
||||
|
||||
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
|
||||
|
||||
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
|
||||
|
||||
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
|
||||
|
||||
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
|
||||
|
||||
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
|
||||
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
|
||||
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
|
||||
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
|
||||
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
|
||||
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
|
||||
|
||||
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
|
||||
|
||||
## 影响
|
||||
|
||||
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
|
||||
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
|
||||
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
|
||||
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
|
||||
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-18-session-list-summary-projections.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set:
|
||||
en: 983b028b1d2aeb7c
|
||||
zh: 2a2d3ced786ee686
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/problem:
|
||||
en: 9f84c9bd85b6aace
|
||||
zh: 8ebb10962c219624
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/decision:
|
||||
en: 6e8f43d7a53c35fd
|
||||
zh: a6e2bccc20474515
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered:
|
||||
en: ce8320f1032018ed
|
||||
zh: 4c5fad5e605bf524
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/consequences:
|
||||
en: d3465b78160d8c1b
|
||||
zh: dcbad6768b74b2bf
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/testing:
|
||||
en: 6b34571bdadb0a9d
|
||||
zh: 41ebfc1b5325b8d6
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Serve Session-list rows from a summary key set
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-18-session-list-summary-projections.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Session-list row carries every registered client view. Four of those views are never rendered by a row: the whole-log turn outline, the whole-log conversation figures, the current todo list, and the host-wide image limits. The outline grows with the Session's log, so a list payload scales with corpus size and log length even though no row shows it. Omitting values from the list needs a rule that keeps the wire and client types honest: a summary consumer must not obtain an omitted key, a value that is omitted must still reach an opened Session, and the owning packages must not drift from whatever declaration drives the omission.
|
||||
|
||||
## Decision
|
||||
|
||||
The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark.
|
||||
|
||||
- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded<T>` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue<T>` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys.
|
||||
- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded<TodoItem[]> | null`, because an intersection with `null` would absorb the null member.
|
||||
- Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type.
|
||||
- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet<key>`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet<Key>`) and use a passed set as-is.
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read.
|
||||
- Client types only: `SessionSummary.projectionValues` narrows to `Partial<SessionProjectionSummaryValues>`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`.
|
||||
|
||||
Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A summary-specific read face per source** (`viewSummaryRecord` for checkpoint rows and `cachedSummarySnapshot` for live cells). Rejected: the summary key set would then be defined and carried by two read faces plus their call sites, and a caller could still pass explicit keys to the complete faces. The derived set plus the existing key-taking faces keeps one definition, one code path, and no per-read key copy.
|
||||
|
||||
**A hardcoded excluded-key list in the session controller.** Rejected: the list would drift from the owning packages, and nothing would fail when a package renamed a key or added a large value.
|
||||
|
||||
**Filtering keys after the complete block is built.** Rejected: the per-row state parse and view work would still happen, and the wire type would keep claiming the excluded keys are present.
|
||||
|
||||
**Making every projection opt in to summaries.** Rejected: it inverts today's default, requires touching every owning package, and silently drops a new large key from summaries whenever a package forgets to opt in.
|
||||
|
||||
**A lazy per-value read for large projections.** Rejected: rows do not need the values at all, and deferring one value per row adds a request shape and loading states the list never had.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: a list response no longer scales with outline length or log count, a summary consumer cannot obtain an excluded key through the derived set (the complete faces still serve an explicitly named key), and reading an excluded key from a list row's type is a type error. `wire.summary` is a plain boolean, so keeping a marked key at `false` is the registering package's responsibility rather than a compile error.
|
||||
|
||||
A list row carries the summary projection, never the full projection: it promises the summary key set and nothing more. A connected Session's client store may hold more values from its control/follow baseline, but those are outside the list contract and must not be read from a row; the opened Session's own baselines keep every value.
|
||||
|
||||
No upgrade guide records this change. The externally perceptible part is the wire: `session/list` rows no longer carry the four keys, and a client type that reads `projectionValues.turnOutline` no longer compiles. A consumer migrating reads the opened Session instead — its `session/follow` opening snapshot, the `session/control` baseline, or a `session.projections` read — which keep every projected value; the package README and the subsystem page state the rule.
|
||||
|
||||
Paid: `ProjectionKeySelection` is a new public signature type and `ReadonlySet` joins the catalog's foundation types; `summaryKeys()` has two invalidation points (a new key, the last disposer) that a future registration path must not miss; the summary key set, like the unit table, is process-wide, so a key excluded by one preset is excluded for every Session; and a nullable value cannot be marked directly — its `null` must stay outside the wrapper.
|
||||
|
||||
## Testing
|
||||
|
||||
- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts): a list row's keys equal the registry's complete snapshot minus the marked keys, a `session/follow` opening snapshot still carries them with equal values, and two consecutive pulls do not re-add them.
|
||||
- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts): the derived set, its stable identity, its rebuild after registration and disposal, summary-shaped reads through the key-taking faces, an empty summary set, and the nullable mark.
|
||||
- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts): a summary read from stored rows omits the marked key and takes its watermark from the served rows only.
|
||||
- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts): `@ts-expect-error` reads of `turnOutline`, `sessionStats`, and `todos` on `SessionSummary.projectionValues`, with the full snapshot still exposing them and `todos` still nullable.
|
||||
- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts): a reconnect refresh replaces the row block, so a stale excluded key does not survive.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Serve Session-list rows from a summary key set
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-18-session-list-summary-projections.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Session 列表行携带每个已注册的客户端视图。其中四个视图从不被行渲染:整份日志的 turn outline、整份日志的会话统计、当前 todo 清单,以及宿主级的图片上限。outline 随 Session 日志增长,因此列表响应会随语料规模与日志长度增长,而没有一行展示它。从列表省略取值需要一条规则同时守住 wire 与客户端类型:摘要消费方不能取得被省略的键;被省略的值仍要送达打开的 Session;owning package 不得与驱动省略的声明漂移。
|
||||
|
||||
## Decision
|
||||
|
||||
Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。
|
||||
|
||||
- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded<T>`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue<T>` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。
|
||||
- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded<TodoItem[]> | null`,因为与 `null` 求交会吞掉 null 成员。
|
||||
- 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。
|
||||
- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet<key>`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet<Key>`),传入 Set 时直接使用。
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。
|
||||
- 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial<SessionProjectionSummaryValues>`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。
|
||||
|
||||
估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**为每个来源各做一个摘要专用读取面**(checkpoint 行的 `viewSummaryRecord` 与实时 cell 的 `cachedSummarySnapshot`)。否决:摘要键集合会由两个读取面及其调用点各定义一次,而调用方仍可向完整读取面传显式键。派生集合加既有带键读取面只保留一处定义、一条代码路径,且每次读取无需复制键。
|
||||
|
||||
**在 session controller 里硬编码被排除键清单。** 否决:清单会与 owning package 漂移,且包改名或新增一个大值时没有任何检查会失败。
|
||||
|
||||
**先构建完整块再按键过滤。** 否决:逐行的 state 解析与 view 工作仍会发生,而且 wire 类型会继续声称被排除键存在。
|
||||
|
||||
**让每个投影主动选择加入摘要。** 否决:这反转了当前默认,需要改动每个 owning package,并且某包忘记加入时新的大键会被静默地从摘要中漏掉。
|
||||
|
||||
**为大投影做按值惰性读取。** 否决:行根本不需要这些值,逐行延迟一个值会引入列表从未有过的请求形状与加载态。
|
||||
|
||||
## Consequences
|
||||
|
||||
换来的:列表响应不再随 outline 长度或日志数量增长;摘要消费方无法通过派生集合取得被排除的键(完整读取面仍按显式键提供);从列表行的类型读取被排除键是类型错误。`wire.summary` 是普通布尔值,因此让带标记的键保持 `false` 是注册方自己的责任,而不是编译错误。
|
||||
|
||||
列表行携带的是摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。已连接 Session 的客户端 store 可能因 control/follow 基线持有更多值,但那不属于列表契约,不得从行上读取;打开的 Session 自身的基线保留全部取值。
|
||||
|
||||
本次没有升级指南记录该变更。外部可感知的部分在 wire:`session/list` 行不再携带四个键,读取 `projectionValues.turnOutline` 的客户端类型不再编译。迁移方式是改读打开的 Session —— 其 `session/follow` 初始快照、`session/control` 基线或 `session.projections` 读取 —— 它们保留全部投影值;包 README 与子系统页写明了该规则。
|
||||
|
||||
付出的:`ProjectionKeySelection` 是新的公开签名类型,`ReadonlySet` 也需加入 catalog 的 foundation 类型;`summaryKeys()` 有两个失效点(新键、最后一个 disposer),未来的注册路径不得漏掉;摘要键集合与单元表一样是进程级的,某个 preset 排除的键对所有 Session 都排除;可空值不能直接标记 —— 它的 `null` 必须留在包装之外。
|
||||
|
||||
## Testing
|
||||
|
||||
- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts):列表行的键等于注册表完整快照减去被标记的键;`session/follow` 初始快照仍携带它们且值相等;连续两次拉取不会重新加入它们。
|
||||
- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts):派生集合、其稳定实例、注册与注销后的重建、经带键读取面的摘要式读取、空摘要集合,以及可空标记。
|
||||
- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts):从持久行读取摘要时省略被标记键,且水位只取自被服务的行。
|
||||
- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts):在 `SessionSummary.projectionValues` 上读取 `turnOutline`、`sessionStats`、`todos` 触发 `@ts-expect-error`,而完整快照仍暴露它们且 `todos` 仍可空。
|
||||
- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts):断线重连刷新会替换行块,残留的被排除键不会存活。
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Bilingual-pair consistency record for 2026-10-18-websocket-permessage-deflate.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-18-websocket-permessage-deflate.md
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate:
|
||||
en: 0bc2e23413bf72c1
|
||||
zh: 5bb252767d0a9fac
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/problem:
|
||||
en: 4b2b62b888cf4735
|
||||
zh: 9d5c1ffce7c253b5
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/decision:
|
||||
en: f0d660d42cd9cfbf
|
||||
zh: a1fff91323e6b213
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/decision/negotiation-and-degradation:
|
||||
en: efd339349731b4eb
|
||||
zh: d4fafd884b3cfc75
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/decision/failure-and-capacity:
|
||||
en: 5f666978c80f0098
|
||||
zh: 69afb0f62622c5cb
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/alternatives-considered:
|
||||
en: 1df91aee779c7c51
|
||||
zh: b8db98186b21143d
|
||||
/agent-note-compress-the-remote-stream-websocket-with-permessage-deflate/consequences:
|
||||
en: b6e6e5fb4dc0f38d
|
||||
zh: fbaa18b8c4b53940
|
||||
@@ -0,0 +1,72 @@
|
||||
# Agent Note: Compress the Remote stream WebSocket with permessage-deflate
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-18-websocket-permessage-deflate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web GUI carries every live Remote stream over one physical WebSocket, `/api/remote.mux`: model token deltas, session events, tool output, terminal output, and the `$events` fan-out. The mux itself is the implemented [Remote duplex stream](2026-09-19-remote-duplex-stream.md) design; this note adds one transport option to it. The Host's HTTP surface already exposes `compression: 'none' | 'gzip'`; before this option the WebSocket had no equivalent, so the link stayed uncompressed.
|
||||
|
||||
Over a tunnel, a reverse proxy, or an admitted public URL, the large JSON frames — session replay, long tool output, terminal scrollback — spend bandwidth the protocol can already reclaim. Browsers offer `permessage-deflate` in the upgrade handshake without asking, so the missing half was the server's.
|
||||
|
||||
## Decision
|
||||
|
||||
The Gateway Config adds `websocketCompression: 'none' | 'deflate'`, defaulting to `'deflate'`, and passes the resolved boolean into the mux. The mux constructs `WebSocketServer` with these transport options and decides compression per outbound frame:
|
||||
|
||||
| Option | Value | Why |
|
||||
|---|---|---|
|
||||
| Frame compression threshold | `1024` characters of the JSON text, decided in the mux send path | `ws` ignores its `threshold` option while context takeover is enabled ([upstream 41ae5631](https://github.com/websockets/ws/commit/41ae5631)), so the mux passes `compress` per frame; frames below 1,024 characters (most token-level `item` frames) stay uncompressed and out of the zlib pool |
|
||||
| `perMessageDeflate.zlibDeflateOptions.level` | `1` | Interactive frames value latency over ratio; the HTTP gzip default in this repository is also `1` |
|
||||
| Context takeover | `ws` default (enabled both ways) | Best ratio for repetitive JSON; no per-message dictionary rebuild |
|
||||
|
||||
The mux constructor takes one options object carrying `heartbeatIntervalMs`, `streamInboxBytes`, and `compression`, because the Gateway resolves the config vocabulary and `stream-server.ts` does not learn `'none' | 'deflate'`.
|
||||
|
||||
Nothing on the client changes: the browser API offers the extension already, the Node carrier uses `ws` with `perMessageDeflate: true` by default, and a client that offers nothing gets an uncompressed upgrade.
|
||||
|
||||
### Negotiation and degradation
|
||||
|
||||
| Peer | Result |
|
||||
|---|---|
|
||||
| Browser (`client/connection` opens a native `WebSocket`) | Negotiates the extension; both directions compressed |
|
||||
| Node `ws` carrier (tests, authenticated Desktop callers) | Negotiates the extension; both directions compressed |
|
||||
| Client that does not offer the extension | Upgrade succeeds without `Sec-WebSocket-Extensions`; frames identical to an uncompressed connection |
|
||||
| New Host with an old Client, or the reverse | No protocol change; the extension is per connection and optional |
|
||||
| `websocketCompression: 'none'` | No extension is negotiated; frames are uncompressed |
|
||||
|
||||
### Failure and capacity
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| Buffered uplink exceeds `streamInboxBytes` | Still fails the logical stream with `gateway/uplink-overflow`; the budget counts decompressed frame bytes, so its meaning does not change |
|
||||
| Ping/Pong heartbeat | Ping cadence is unaffected: control frames are never compressed, and the activity rule's Pong and inbound-frame arms are unchanged |
|
||||
| Downlink write liveness | A sub-threshold write stays synchronous, so it counts as peer activity only when the local send buffer was already full; a frame whose JSON text reaches 1,024 characters compresses asynchronously, so its completed write counts as buffered activity |
|
||||
| zlib saturation | Compression runs on the libuv thread pool behind `ws`'s global limiter; the event loop is not blocked, but thread-pool capacity is shared with file I/O |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Compress frames with gzip.** Rejected because WebSocket has no gzip frame format; RFC 7692 defines `permessage-deflate` as the only registered per-message compression extension.
|
||||
|
||||
**Let the client choose.** Rejected because the browser `WebSocket` API exposes no extension parameters and no way to disable negotiation; the decision can only live on the server.
|
||||
|
||||
**Mirror the HTTP surface with `compressionLevel` and `compressionThresholdBytes`.** Deferred, not rejected: no measurement justifies exposing tuning knobs yet, and the repository forbids hiding a real tunable in a constant while also forbidding speculative configuration. Add the fields when a deployment needs a different trade-off.
|
||||
|
||||
**Enable compression unconditionally.** Rejected because the choice varies by deployment: a loopback deployment pays CPU for no bandwidth gain and must be able to turn it off, which is what the Config field is for.
|
||||
|
||||
**Compress only the downlink.** Rejected because the extension is negotiated per connection and applies to both directions; a one-way accept is not expressible, and the uplink frames are small anyway.
|
||||
|
||||
## Consequences
|
||||
|
||||
Compression reclaims the bandwidth of large JSON frames over a tunnel, a reverse proxy, or an admitted public URL. Frames below 1,024 characters stay out of the zlib pool, and context takeover keeps the dictionary across messages, so repetitive JSON compresses well without per-message rebuilds.
|
||||
|
||||
**Thread-pool contention.** zlib shares the libuv pool with file I/O. The 1,024-character threshold keeps small streaming frames out of it; a saturated pool delays compressed frames rather than blocking the loop.
|
||||
|
||||
**Per-connection memory.** Each negotiated connection holds a deflate and an inflate context for its lifetime. One socket per browser tab keeps this proportional to open tabs; an implementation that expects many idle tabs should measure before enabling by default.
|
||||
|
||||
**No benefit over loopback.** The common local deployment gains nothing but CPU, which is why the field exists; the default is chosen for remote and tunneled use.
|
||||
|
||||
**Unmeasured default.** The `'deflate'` default rests on the HTTP gzip precedent and on remote deployments, not on an on/off byte-and-latency comparison. Record that comparison when one is run and revisit the default if local overhead proves visible.
|
||||
|
||||
**Weakened write liveness.** A compressed downlink write completes when `ws` has deflated and written it, not when the TCP peer acknowledges data, so continuous compressed downlink traffic counts as buffered activity and delays missed-heartbeat termination. The heartbeat rule and its code are unchanged.
|
||||
|
||||
The Gateway unit tests pin default negotiation, no-extension behavior, a client that offers nothing, the per-frame threshold, and the compressed-write liveness consequence; the config default and the generated config catalog are updated in the same change.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Agent Note: 用 permessage-deflate 压缩 Remote 流 WebSocket
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-18-websocket-permessage-deflate.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web GUI 的所有实时 Remote 流共用一条物理 WebSocket `/api/remote.mux`:模型 token 增量、session 事件、工具输出、终端输出,以及 `$events` 广播。这条 mux 本身是已实现的 [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md)设计,本 Note 只给它加一个传输选项。Host 的 HTTP 面已经暴露 `compression: 'none' | 'gzip'`;在此选项之前 WebSocket 没有对应能力,链路因此从不压缩。
|
||||
|
||||
走隧道、反向代理或被接纳的公网地址时,大 JSON 帧——会话回放、长工具输出、终端滚动历史——占掉的带宽协议本来就能省下来。浏览器在升级握手时本来就会主动 offer `permessage-deflate`,缺的只是服务端这一半。
|
||||
|
||||
## 决定
|
||||
|
||||
Gateway Config 增加一个字段 `websocketCompression: 'none' | 'deflate'`,默认 `'deflate'`,把解析后的布尔值交给 mux。mux 用以下传输选项构造 `WebSocketServer`,并在发送时逐帧决定是否压缩:
|
||||
|
||||
| 选项 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| 帧压缩阈值 | `1024` 个 JSON 文本字符,由 mux 在发送路径判定 | `ws` 在开启上下文接管时会忽略自己的 `threshold` 选项([上游 41ae5631](https://github.com/websockets/ws/commit/41ae5631)),因此 mux 逐帧传入 `compress`;少于 1024 个字符的帧(多数 token 级 `item` 帧)不压缩,也不进入 zlib 线程池 |
|
||||
| `perMessageDeflate.zlibDeflateOptions.level` | `1` | 交互帧优先延迟而非压缩率;本仓库 HTTP gzip 默认也是 `1` |
|
||||
| 上下文接管 | `ws` 默认(双向开启) | 重复 JSON 压缩率最好,不必逐帧重建字典 |
|
||||
|
||||
mux 构造函数接收一个 options 对象,携带 `heartbeatIntervalMs`、`streamInboxBytes` 与 `compression`,因为配置词汇由 Gateway 解析,`stream-server.ts` 不认识 `'none' | 'deflate'`。
|
||||
|
||||
客户端零改动:浏览器 API 本来就会 offer 该扩展,Node 载体用的 `ws` 默认 `perMessageDeflate: true`,完全不 offer 的客户端拿到的就是不压缩的升级响应。
|
||||
|
||||
### 协商与降级
|
||||
|
||||
| 对端 | 结果 |
|
||||
|---|---|
|
||||
| 浏览器(`client/connection` 打开原生 `WebSocket`) | 协商成功,双向压缩 |
|
||||
| Node `ws` 载体(测试、认证的 Desktop 调用方) | 协商成功,双向压缩 |
|
||||
| 不 offer 该扩展的客户端 | 升级成功但不带 `Sec-WebSocket-Extensions`,帧与不压缩连接一致 |
|
||||
| 新 Host 配旧 Client,或反过来 | 没有协议变更;扩展按连接、可选协商 |
|
||||
| `websocketCompression: 'none'` | 不协商扩展;帧不压缩 |
|
||||
|
||||
### 失败与容量
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| 缓冲的上行超过 `streamInboxBytes` | 仍以 `gateway/uplink-overflow` 失败该逻辑流;预算统计的是解压后的帧字节,语义不变 |
|
||||
| Ping/Pong 心跳 | Ping 周期不受影响:控制帧从不压缩,活动判据中 Pong 与收到帧这两支不变 |
|
||||
| 下行写入活性 | 阈值以下的写入保持同步,只有本机发送缓冲区已满时才计为对端活动;JSON 文本达到 1024 个字符的帧异步压缩,其写入完成会计为缓冲活动 |
|
||||
| zlib 打满 | 压缩跑在 libuv 线程池、受 `ws` 全局并发限制约束;事件循环不被阻塞,但线程池容量与文件 IO 共享 |
|
||||
|
||||
## 备选方案
|
||||
|
||||
**用 gzip 压缩帧。** 否决:WebSocket 没有 gzip 帧格式,RFC 7692 只注册了 `permessage-deflate` 这一种逐消息压缩扩展。
|
||||
|
||||
**让客户端决定。** 否决:浏览器 `WebSocket` API 不暴露扩展参数,也没有关闭协商的手段,这个决定只能落在服务端。
|
||||
|
||||
**照 HTTP 面配齐 `compressionLevel` 与 `compressionThresholdBytes`。** 暂缓而非否决:目前没有测量依据支持暴露调优旋钮,而仓库既禁止把真正的可调项藏进常量,也禁止投机式配置。等某个部署需要不同取舍时再加字段。
|
||||
|
||||
**无条件开启压缩。** 否决:这个选择随部署变化——本地 loopback 部署付出 CPU 却换不到带宽,必须能关掉,这正是 Config 字段的用途。
|
||||
|
||||
**只压下行。** 否决:该扩展按连接协商并同时作用于两个方向,单向接受不可表达,何况上行帧本来就小。
|
||||
|
||||
## 后果
|
||||
|
||||
压缩为走隧道、反向代理或被接纳公网地址的大 JSON 帧省下带宽。少于 1024 个字符的帧不进入 zlib 线程池,上下文接管跨消息保留字典,因此重复 JSON 的压缩率好且不必逐帧重建。
|
||||
|
||||
**线程池争用。** zlib 与文件 IO 共用 libuv 线程池。1024 字符阈值把小的流式帧挡在外面;线程池打满时被延迟的是压缩帧,事件循环仍不被阻塞。
|
||||
|
||||
**每连接内存。** 每条协商成功的连接在整个生命周期内持有 deflate 与 inflate 一对上下文。每个浏览器标签页一条 socket 时,内存与打开的标签页成正比;如果实现预期大量空闲标签页,应先测量再默认开启。
|
||||
|
||||
**loopback 无收益。** 最常见的本地部署只多付 CPU,所以才有这个字段;默认值是为远端与隧道场景选的。
|
||||
|
||||
**默认值未经测量。** `'deflate'` 这个默认值依据的是 HTTP gzip 的先例与远端场景,不是开/关两态的字节与延迟对比。做过对比后要记录结论,本地开销若明显则重新考虑默认值。
|
||||
|
||||
**写入活性被削弱。** 压缩后的下行写入在 `ws` 完成 deflate 并写出时即完成,而不是等到 TCP 对端确认数据,因此持续压缩下行会把写入计为缓冲活动并推迟心跳超时终止。心跳规则及其代码未改动。
|
||||
|
||||
Gateway 单测钉住默认协商、不协商、不支持扩展的客户端、逐帧阈值与压缩写入的活性后果;配置默认值与生成的 config catalog 在同一次改动中更新。
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md
|
||||
/agent-note-client-plugin-package-assets-served-by-url:
|
||||
en: 5505b948511a2d52
|
||||
zh: dae985d67e5c5935
|
||||
/agent-note-client-plugin-package-assets-served-by-url/problem:
|
||||
en: 159de908b09780eb
|
||||
zh: 8d15ca2f15b8846a
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision:
|
||||
en: 8ffcd355ec5e8271
|
||||
zh: 2932530bf783e3f7
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
|
||||
en: 704267953c123629
|
||||
zh: 860997024ad7825e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
|
||||
en: 485013b2b6778fa2
|
||||
zh: 7e5cb238bb2b8585
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/host-route:
|
||||
en: 151b7300f5b27a9f
|
||||
zh: fae9adff942c2ce8
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview:
|
||||
en: db1c7b2a0b916804
|
||||
zh: 0edf4123667d8e2e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
|
||||
en: eedff52d04763c50
|
||||
zh: 203bfcf81242b8fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration:
|
||||
en: 1e4d83d0de2d47c9
|
||||
zh: dc2c2876c73d3b1c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
|
||||
en: 98960e10634ee536
|
||||
zh: f0fdb0b0cdf9511c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/consequences:
|
||||
en: 898262f39c0243ae
|
||||
zh: e30f2161f5b669fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/testing:
|
||||
en: 8b6ce91df7c4c3a5
|
||||
zh: b149b71cef5be761
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client plugin package assets served by URL
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-client-plugin-package-assets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style.
|
||||
|
||||
The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image.
|
||||
|
||||
## Decision
|
||||
|
||||
A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset(<document-relative URL>)` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins/<package>/assets/<emitted path>`.
|
||||
|
||||
### References and file names
|
||||
|
||||
Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content:
|
||||
|
||||
```text
|
||||
src/client/assets/running-whale@2x.png
|
||||
lib/assets/running-whale@2x-06738fa29b2f.png
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `<style>` text. Components need no hook, injected value, or service: the URL is a module constant computed when the module first runs.
|
||||
|
||||
The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) in client-modules owns the media-type table, the export key and target, the revision, the emitted name, and the URL, and the preset, the route, and the gates read them from there. The shared Client type root [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts), registered in [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json), declares each extension as a module with a string default export; a test keeps it equal to the table.
|
||||
|
||||
### Build
|
||||
|
||||
The `dsh-package-asset` plugin of the dynamic browser face resolves an import against its source location and fails the build when the file's real path is outside the package's `src/client/assets/` (which also rejects another package's or a dependency's image), when `exports["./assets/*"]` is not exactly `./lib/assets/*`, when the extension is unsupported, when a path segment is `node_modules` or contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve, or when the extension is not lowercase, as the import types are. It emits each file once per build and registers it as a watch input. The three CSS pipelines (CSS Modules, global, and `?inline`) rewrite relative `url()` values through a lightningcss visitor in stylesheets inside the package's own `src/`; `data:`, fragment, scheme, `/`-prefixed, and empty URLs stay unchanged, a relative file reference with a fragment or query fails the build, and dependency stylesheets are not rewritten. lightningcss does not call its `Url` visitor inside `image-set()`, so an `Image` visitor rewrites those options with the same rule. The rewrite writes an identifier placeholder that lightningcss never escapes, so a file name with parentheses or quotes still becomes its resolver call. The Node Client face of a dual-face package rejects asset imports. The browser face is the only writer of `lib/assets/`: a one-shot build deletes files it did not emit, and a watch rebuild keeps them, because the Host still serves the previous bundle generation and pages running it request those names.
|
||||
|
||||
### Host route
|
||||
|
||||
`bundleResource` matches `/plugins/<package>/assets/<path>` before the bundle tables, and such a path is answered by the asset route alone. For an active client row, the path is percent-decoded once and `createRequire(<package manifest>).resolve('<package>/assets/<path>')` applies the package's own exports, so unexported paths, `.` and `..` segments, missing files, and directories do not resolve. The media type comes from the resolved file's extension, because Node reads `#` and `?` in the subpath as URL syntax. Any resolution failure, an inactive package, and an unsupported type answer 404; the query selects nothing.
|
||||
|
||||
A served asset streams from disk on every request; the Host keeps neither its bytes nor a hash, and a request the browser cancels mid-stream ends without a warning. It carries the media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag` equal to the requested path, `x-content-type-options: nosniff`, and, for SVG, `content-security-policy: script-src 'none'`. An `If-None-Match` equal to the tag answers 304 without opening the file; `HEAD` answers without a body. `fetchBundle()` returns the same responses with a streamed body that closes the file when cancelled.
|
||||
|
||||
### WebWorker preview
|
||||
|
||||
The preview page runs its Host in a Worker, and a page request for a reference never reaches it. Before any Client bundle runs, the page asks the Worker for `GET /__assets__`, answered by tunnel glue like `GET /__boot__`: an uncompressed tar of every file under `lib/assets/` of each image package that declares `"./assets/*": "./lib/assets/*"`, named by its unencoded `plugins/<package>/assets/<path>`, which fits the ustar name limits the base image already met. The page encodes each entry name into its reference and turns the entry into a Blob URL with the media type of its extension, and its `resolveAsset` returns that URL, or the reference when the image holds no such file. The base image already carries these files, so no network request is added; each boot still walks the image's packages, archives every asset, and copies each entry into a Blob, enabled or not.
|
||||
|
||||
### Changes and package manifests
|
||||
|
||||
A rebuilt image produces a new file name in a rebuilt `client.js`, which reaches the browser through the existing bundle HMR; assets have no channel of their own. Client-modules reads a package manifest and the bundle metadata on every reconciliation, that is, on each Loader entry construction and disposal, instead of caching the manifest until restart, so an entry that restarts after a reinstall, upgrade, or move publishes its current declaration, client export, and manifest path; a reconciliation that finds the same manifest and bundle metadata leaves the published graph unchanged and reads no bundle bytes.
|
||||
|
||||
### Gates and migration
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) requires `lib/assets` in `files` when the export is declared. [`verify-client-packages`](../../../../scripts/verify-client-packages.ts) requires the exact export target, allows the export only on dynamic Web Client packages, and keeps the export and the package's references (imports, and `url()` in the stylesheets its Client sources import) consistent in both directions. [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) treats `./assets/*` as a non-code export. ui-settings-account and ui-chat moved to package assets, and ui-chat's tsdown plugin is gone.
|
||||
|
||||
Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, and workers), plugin metadata icons, detail-document images, and static-linked packages keep their own delivery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep data URLs.** No route is needed, but the bytes travel and parse with the JavaScript, and every package that styles with an image writes its own build plugin.
|
||||
|
||||
**A plain URL string with no runtime helper.** The served Web app needs nothing more, but the WebWorker preview page cannot request the URL, and changing it afterwards means copying the files to a static deployment or rewriting strings found in bundle text. A static copy is a build-time snapshot that bypasses the Host's rules and misses packages installed at runtime; finding strings in bundle text guesses at code. A synchronous call keeps the build output explicit and lets each carrier decide the URL.
|
||||
|
||||
**A Client Cordis service such as `ctx.assets.url(path)`.** Current consumers reference fixed files; a runtime lookup also cannot know the content revision without publishing a per-package asset list.
|
||||
|
||||
**Raw files in a package-root `assets/` without the build.** Authors could not preprocess them, so naming and format conversion would stay manual.
|
||||
|
||||
**The revision in a `?rev=` query, verified by the Host.** The Host must read a whole file to decide between 200 and 404, so a first request reads it twice and the Host must cache hashes by file metadata; a revision in the file name lets the Host only resolve and stream.
|
||||
|
||||
**The `client.js` revision as the asset revision.** That revision comes from the written bundle's metadata after the build, too late to appear in the bundle.
|
||||
|
||||
**Images in CSS only through JavaScript style objects.** Masks, backgrounds, and `@supports` fallbacks would move into components, while the URL is known at build time and the same rules rewrite `url()`.
|
||||
|
||||
**One plugin file protocol shared with detail documents.** Detail images read any package version from local, npm, or Git sources, need authentication because they trigger Host network access, and cache by source. Joining them would move authentication, source providers, and cache policy into client-modules; the Plugin Manager and Plugin Market detail routes stay separate until another consumer needs files of other packages.
|
||||
|
||||
**Manifests from Plugin Manager, or a manifest cache with change checks.** Plugin Manager lists bundles, not every client row, and resolves from the profile installation rather than the Loader resolution of the running entry. The failure was timing: a cache that outlived entry restarts. Rereading on each reconciliation and skipping unchanged results removes the cache instead of adding validation to it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The ui-settings-account `client.js` is 214 KB, and the account and ui-chat bundles contain no image data URLs; an image costs one same-origin request when shown and may appear after layout.
|
||||
- `require.asset` belongs to the bundle factory contract beside `require.async`; a package built outside this repository must emit the call. The WebWorker preview creates a Blob URL for every package asset of its image at boot, enabled or not, and keeps them for the page's lifetime.
|
||||
- The Desktop forwarder rewrites `/plugins/` cache headers to `no-store`, so Desktop reads an asset again on each display.
|
||||
- The Host does not compare file contents with the revision in the name: a file under `lib/assets/` edited in place keeps its immutable cache entries. Only the build writes that directory.
|
||||
- `require.resolve` runs synchronously on each request; a local measurement took about 43 µs for an existing file and 170 µs for a missing one. It reads exports through Node's package configuration cache, so an `./assets/*` export edited in place takes effect after boot HMR expires that package or the Host restarts. The route does not check the resolved file's real path, so a symbolic link under `lib/assets/` that leads outside the package is served.
|
||||
- A package outside this repository must reproduce the emitted-name rule and the `require.asset` call; the Client module system README records them.
|
||||
- A development session leaves earlier asset files under `lib/assets/` until the next one-shot build removes them; that pruning assumes the browser face is the directory's only writer.
|
||||
- An entry restart reads its manifest and stats its bundle; only a changed result reads the bundle and broadcasts a graph frame (about 30 KB for 69 rows). Editing only `package.json`, without an entry restart, still takes effect at the next entry or Host restart.
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) composes the real Web server and compares every answer with `fetchBundle()`: headers including the SVG policy, 304 without opening the file, `HEAD`, 405, multi-chunk streaming, stream closing on cancel, a browser cancellation that logs nothing, and 404 for excluded subpaths, missing exports and files, `..`, `%2e%2e`, encoded `..`, an empty path, malformed escapes, decoded `%` and `#`, unsupported types, directories, and inactive packages.
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) covers media types, the revision, emitted names, and references; [client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) keeps the type root equal to the table.
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering in a complete build, preserved `url()` forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files; [loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) covers the default and carrier resolvers.
|
||||
- The gate specs reject each invalid manifest or reference form, and [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) covers declarations, moved exports, and added or removed client declarations after an entry restart, an unchanged restart that publishes nothing and reads no bundle, and a chunk of a package named `@scope/assets`.
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) and [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) assert the package-asset URLs and decode the images in a browser; [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) decodes the ui-chat mask the WebWorker preview resolves to a Blob URL, and the runtime's [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) covers the archive and resolver.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client 插件包资源经 URL 提供
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-client-plugin-package-assets.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
动态 Client 插件的浏览器半侧由 [client 打包预设](../../../../packages/client/tsdown.client.ts) 打成 lazy-CJS factory,经 [Client 模块系统](../../../../packages/client/modules/README.zh.md) 的 `/plugins` 路由加载。这条路由过去只提供 `client.js` 和包内分片,所以插件代码引用的图片只能以 base64 data URL 打进 bundle,样式里用到的图片则要靠包级构建插件替换成内联样式。
|
||||
|
||||
不管图片显不显示,这些字节都随 JavaScript 一起下载和解析,base64 还会让体积大三分之一:引导插图使 ui-settings-account 的 `client.js` 达到约 3.4 MB。ui-chat 还为一张遮罩图维护了自己的 [tsdown 插件](../../../../packages/client/ui-chat/tsdown.config.ts)。
|
||||
|
||||
## Decision
|
||||
|
||||
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成对 bundle 模块加载器的同步调用 `require.asset(<文档相对 URL>)`;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
|
||||
|
||||
### 引用与文件名
|
||||
|
||||
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
|
||||
|
||||
```text
|
||||
src/client/assets/running-whale@2x.png
|
||||
lib/assets/running-whale@2x-06738fa29b2f.png
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
路径逐段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
|
||||
|
||||
支持的扩展名是 `.svg`、`.png`、`.webp`、`.jpg` 和 `.jpeg`。client-modules 的 [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) 统一定义类型表、导出键与目标、revision、产出文件名和 URL,预设、路由和门禁都从这里读取。共享的 Client 类型根 [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts) 在 [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
|
||||
|
||||
### 构建
|
||||
|
||||
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段为 `node_modules` 或含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL;扩展名不是小写(导入类型只声明小写扩展名)。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包的样式表不改写。lightningcss 不会在 `image-set()` 内部调用 `Url` visitor,因此由 `Image` visitor 按同样的规则改写这些选项。改写时先写入 lightningcss 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
|
||||
|
||||
### Host 路由
|
||||
|
||||
`bundleResource` 先于 bundle 表匹配 `/plugins/<包名>/assets/<路径>`,这类路径只由资源路由回答。对启用中的 client 行,路径解码一次后,`createRequire(<包的 manifest>).resolve('<包名>/assets/<路径>')` 套用包自己的 exports,未导出路径、`.` 和 `..` 段、不存在的文件和目录都解析不出来。媒体类型按解析出的文件扩展名判断,因为 Node 会把子路径中的 `#` 和 `?` 当作 URL 语法。任何解析失败、未启用的包和不支持的类型都返回 404;查询串不参与选择。
|
||||
|
||||
被提供的资源每次请求都从磁盘流式读取,Host 既不保存字节也不保存 hash;浏览器中途取消的请求直接结束,不记 warning。响应带媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、等于请求路径的 `etag`、`x-content-type-options: nosniff`;SVG 另带 `content-security-policy: script-src 'none'`。`If-None-Match` 等于该 tag 时返回 304,且不打开文件;`HEAD` 不带 body。`fetchBundle()` 返回同样的响应,body 为流,取消时关闭文件。
|
||||
|
||||
### WebWorker 预览
|
||||
|
||||
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以未编码的 `plugins/<包名>/assets/<路径>` 命名,基础镜像已满足同样的 ustar 名称限制。页面把每个条目名编码为引用,并按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
|
||||
|
||||
### 变化与包 manifest
|
||||
|
||||
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
|
||||
|
||||
### 门禁与迁移
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) 要求声明导出时 `files` 含 `lib/assets`。[`verify-client-packages`](../../../../scripts/verify-client-packages.ts) 要求导出目标精确匹配,只允许动态 Web Client 包声明该导出,并要求导出与包内引用(import,以及其 Client 源码所 import 的样式表中的 `url()`)双向一致。[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 把 `./assets/*` 视为非代码导出。ui-settings-account 和 ui-chat 已迁移到包资源,ui-chat 的 tsdown 插件已删除。
|
||||
|
||||
三方库自己加载的资源(PDF.js 的字体、cmap、wasm 和 worker)、插件元数据图标、详情文档图片,以及 static-linked 包,保留各自的交付方式。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
|
||||
|
||||
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
|
||||
|
||||
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
|
||||
|
||||
**原始文件直接放在包根 `assets/`,不经构建。** 作者无法做预处理,命名和格式转换只能手工维护。
|
||||
|
||||
**revision 放在 `?rev=` 查询串,由 Host 校验。** Host 必须读完整个文件才能决定回 200 还是 404,首次请求要读两遍,还要按文件元数据缓存 hash;revision 写进文件名后,Host 只需解析路径并流式发送。
|
||||
|
||||
**用 `client.js` 的 revision 作为资源 revision。** 这个 revision 在构建写出 bundle 后才由文件元数据得出,来不及写进 bundle。
|
||||
|
||||
**CSS 只能经 JavaScript style 对象引用图片。** 遮罩、背景和 `@supports` 降级会被挪进组件,而 URL 在构建时就能确定,同一套规则也能改写 `url()`。
|
||||
|
||||
**与详情文档共用一套插件文件协议。** 详情图片要从本地、npm 或 Git 读取任意包版本,会触发 Host 网络访问因而需要鉴权,缓存方式也随来源而定。合并会把鉴权、来源提供方和缓存策略搬进 client-modules;在出现需要读取其他包文件的新使用方之前,Plugin Manager 和 Plugin Market 的详情路由保持独立。
|
||||
|
||||
**从 Plugin Manager 获取 manifest,或给 manifest 缓存加变化检查。** Plugin Manager 只列出 bundle,不覆盖所有 client 行,而且从 profile 安装目录解析,而不是用运行中条目的 Loader 解析。问题出在时机:缓存活得比条目重启更久。每次对账重新读取并跳过未变化的结果,是删掉缓存,而不是给缓存加校验。
|
||||
|
||||
## Consequences
|
||||
|
||||
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
|
||||
- `require.asset` 和 `require.async` 一样属于 bundle factory 契约;仓库外构建的包必须输出这个调用。WebWorker 预览在启动时为镜像里所有包资源创建 Blob URL,不论包是否启用,并在页面生命周期内保留。
|
||||
- Desktop 转发层把 `/plugins/` 的缓存头改写为 `no-store`,Desktop 每次显示都会重新读取资源。
|
||||
- Host 不比对文件内容与文件名中的 revision:原地修改 `lib/assets/` 下的文件会保留它的 immutable 缓存。这个目录只由构建写入。
|
||||
- `require.resolve` 在每次请求时同步执行;本机实测文件存在约 43 µs,不存在约 170 µs。它经 Node 的包配置缓存读取 exports,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包失效或 Host 重启后才生效。路由不检查解析结果的真实路径,所以 `lib/assets/` 下指向包外的符号链接会被提供。
|
||||
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
|
||||
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
|
||||
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) 组合真实 Web server,并将每个响应与 `fetchBundle()` 比对:包括 SVG 策略在内的响应头、不打开文件的 304、`HEAD`、405、多分片流式发送、取消时关闭流、浏览器取消请求时不记日志,以及以下情况的 404:被排除的子路径、缺少导出或文件、`..`、`%2e%2e`、编码后的 `..`、空路径、错误的转义、解码后的 `%` 和 `#`、不支持的类型、目录和未启用的包。
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) 覆盖媒体类型、revision、产出文件名和引用;[client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) 保证类型根与类型表一致。
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) 覆盖产出文件名、脚本和样式表文本中的解析调用、完整构建中它们的 CommonJS 渲染、保留的 `url()` 形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件;[loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) 覆盖默认解析和载体解析。
|
||||
- 各门禁的 spec 拒绝每种无效的 manifest 或引用形式;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 覆盖条目重启后的声明更新、导出移动、client 声明的增减、未变化的重启既不发布也不读取 bundle,以及名为 `@scope/assets` 的包的分片。
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) 和 [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) 断言包资源 URL,并在浏览器中解码这些图片;[preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 解码 WebWorker 预览解析为 Blob URL 的 ui-chat 遮罩图,运行时的 [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) 覆盖归档和解析。
|
||||
+6
-6
@@ -9,14 +9,14 @@
|
||||
en: 3238dfb7db7dfdd8
|
||||
zh: ec3bdd9a9da0a321
|
||||
/agent-note-user-authorized-subagent-model-routes/decision:
|
||||
en: 0166e452a774ade5
|
||||
zh: 747061b9424b3610
|
||||
en: 9eeb42f10be1bd28
|
||||
zh: 24e6cca2db77444a
|
||||
/agent-note-user-authorized-subagent-model-routes/alternatives-considered:
|
||||
en: e41c17226190ea8b
|
||||
zh: d3b1395fafe75f01
|
||||
/agent-note-user-authorized-subagent-model-routes/consequences:
|
||||
en: f1927d31ffc1dee9
|
||||
zh: b0fed04b80043c71
|
||||
en: 26244ba796f6ed70
|
||||
zh: 47e9744719b05f0b
|
||||
/agent-note-user-authorized-subagent-model-routes/related-decisions:
|
||||
en: 595a9485d91592c6
|
||||
zh: 7b32611ea380498c
|
||||
en: 0eead87ec1172d81
|
||||
zh: 4e776a583f5a00a5
|
||||
+7
-7
@@ -12,11 +12,11 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an
|
||||
|
||||
The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process.
|
||||
|
||||
A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session.
|
||||
The Host settings service snapshots the route list for each fresh top-level Session in `subagent/model-selection-policy` when the setting is enabled, before its first model request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently created top-level Sessions. Without the event, including an explicitly empty restored Session, explicit selection remains restricted to the current parent route.
|
||||
|
||||
The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice.
|
||||
The fixed `list_subagent_models` schema does not enumerate the policy. At call time, it lists authorized registered providers and intersects their advertised model catalogs with the recorded route list. Exact lookup requires authorization before resolving model metadata and all supported reasoning efforts, including for ids absent from the catalog. Without a recorded policy, discovery resolves only the parent Agent’s current effective route, even if the catalog omits it. The delegation executor independently checks any explicit provider, model, or effort selection against the same authorization before adapter preflight. A call with no selection fields retains configured or inherited routing.
|
||||
|
||||
Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse.
|
||||
Model selection has no unrestricted static mode. The default-off Host setting authorizes alternative child routes; without it, the parent route and its supported efforts remain selectable. Each delegation tool’s `modelSelection` option controls only its route fields. One delegation instance per scope enables `modelDiscovery` to own discovery; the setting, discovery, and delegation read the same package-local authorization projection. Web and Desktop presets mount discovery by default, enable spawn route fields, and leave fork route fields disabled to preserve inherited-prefix reuse; custom fork compositions may enable them under the same Session authorization. Tool installation does not sample or expand authorization.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -33,11 +33,11 @@ Model selection has no unrestricted static mode. The default-off Host setting is
|
||||
## Consequences
|
||||
|
||||
- New adapter registrations and newly advertised models do not expand user authorization.
|
||||
- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it.
|
||||
- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript.
|
||||
- Adapter removal or catalog lookup failure can make provider discovery unavailable without deleting the saved route decision; an allowed model remains usable when its adapter accepts it even if the advisory catalog omits it.
|
||||
- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. The `modelSelection` option adds three route fields; `modelDiscovery` adds a fixed discovery schema. Both options default off; shipped headless and SDK profiles carry neither.
|
||||
- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it.
|
||||
- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow.
|
||||
- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, shared definitions, parent-route defaults, discovery restrictions, executor denial, and the settings UI’s staged writes and revision checks. The assembled Web scenario pins the real settings document and Plugins card flow.
|
||||
|
||||
## Related decisions
|
||||
|
||||
The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](../../../../packages/subagent/tool-subagent/README.md).
|
||||
The route arguments, adapter preflight, discovery tool, and fork restriction remain owned by [model-selected subagent routes](../../../../packages/subagent/tool-subagent/README.md).
|
||||
+7
-7
@@ -12,11 +12,11 @@ Status: implemented
|
||||
|
||||
Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。
|
||||
|
||||
设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。
|
||||
设置启用时,Host 设置服务为每个新顶层 Session 在首个模型请求前将路由列表记录为 `subagent/model-selection-policy`。事件存在表示选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一精确列表,恢复的 Session 使用已记录事件而非当前设置。因此设置变更只影响之后创建的顶层 Session。没有该事件时,包括显式为空的恢复 Session,显式选择仍仅限当前父级路由。
|
||||
|
||||
固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。
|
||||
固定的 `list_subagent_models` schema 不枚举策略。调用时,它列出已授权且已注册的提供方,并取其公布模型目录与已记录路由列表的交集。精确查询先要求授权,再解析模型元数据及全部支持的推理强度,也允许目录未列出的 ID。没有已记录策略时,发现只解析父 Agent 当前生效的路由,即使目录未列出它。委派执行器会在适配器预检前独立校验显式提供方、模型或强度选择是否符合相同授权。没有选择字段的调用保留配置或继承的路由。
|
||||
|
||||
模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。
|
||||
模型选择没有无限制的静态模式。默认关闭的 Host 设置授权其他子级路由;没有该授权时,仍可选择父级路由及其支持的强度。各委派工具的 `modelSelection` 选项只控制自身路由字段。每个作用域的一个委派实例启用 `modelDiscovery` 并负责发现;设置、发现和委派读取包内同一个授权投影。Web 与 Desktop preset 默认挂载发现工具,启用 spawn 路由字段,并为 fork 保持关闭,以保留对继承前缀的复用;自定义 fork 组合可以在相同 Session 授权下启用这些字段。工具安装不采样或扩大授权。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -33,11 +33,11 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled`
|
||||
## Consequences
|
||||
|
||||
- 新适配器注册和新公布模型不会扩大用户授权。
|
||||
- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。
|
||||
- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。
|
||||
- 适配器移除或目录查询失败可能使提供方发现不可用,但不会删除已存路由决定;只要适配器接受某个获准模型,即使建议性目录省略它,该模型仍可使用。
|
||||
- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。`modelSelection` 选项增加三个路由字段;`modelDiscovery` 增加固定发现 schema。两个选项都默认关闭;随附 headless 与 SDK profile 两者都不携带。
|
||||
- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。
|
||||
- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
|
||||
- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、共享定义、父级路由默认值、发现限制、执行器拒绝,以及设置 UI 的暂存写入与 revision 检查。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
|
||||
|
||||
## Related decisions
|
||||
|
||||
路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](../../../../packages/subagent/tool-subagent/README.zh.md)负责。
|
||||
路由参数、适配器预检、发现工具与 fork 限制仍由[模型选择的 subagent 路由](../../../../packages/subagent/tool-subagent/README.zh.md)负责。
|
||||
@@ -9,23 +9,23 @@
|
||||
en: 492ba2355360d81a
|
||||
zh: 825c0e7ab1cf4beb
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision:
|
||||
en: 3868c5c4b087bdba
|
||||
zh: c3dff98fc5f05f19
|
||||
en: d31f010f9c22c452
|
||||
zh: 5c3966a953227998
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/effects-and-authority:
|
||||
en: 55a79d8572fb4332
|
||||
zh: f63d03d0f3bb5811
|
||||
en: db0a2deb9193a00d
|
||||
zh: dab5a65b88957464
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request:
|
||||
en: d93bd9b8af6b0bfc
|
||||
zh: 9514c2e5554a721e
|
||||
en: 3a07a59b6c5ec26c
|
||||
zh: e4f3290dc6d26368
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
|
||||
en: 237018634b5f9dcf
|
||||
zh: cbc49d9c1b794065
|
||||
en: 647fe68c52b907e0
|
||||
zh: 267a739bad44ad65
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/decision/process-catalog-and-children:
|
||||
en: 0042c645360c8361
|
||||
zh: 5dba39466d376559
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/alternatives-considered:
|
||||
en: fe97128ed05b9e43
|
||||
zh: 4aaa344933fa89b4
|
||||
en: a7e7f25cf98215bc
|
||||
zh: 1ddcabec2bba3539
|
||||
/agent-note-experimental-auto-review-before-each-tool-call/consequences:
|
||||
en: 1aca35a6d0784f67
|
||||
zh: 51cfa397db101600
|
||||
@@ -10,7 +10,7 @@ Full access lets useful project work proceed without repeated approvals, but it
|
||||
|
||||
## Decision
|
||||
|
||||
[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental Web layer, published under the [experimental package publication policy](../../../../packages/experimental/README.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it uses Full access's `danger-full-access` sandbox and tool definitions with the approval policy chosen by [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md). Headless, General settings, and new-session defaults exclude the integration.
|
||||
[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental profile layer, published under the [experimental package publication policy](../../../../packages/experimental/README.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it uses Full access's `danger-full-access` sandbox and tool definitions with the approval policy chosen by [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md). Default Headless, General settings, and new-session defaults exclude the integration. [Headless caller admission](2026-10-18-headless-auto-review.md) defines explicit headless opt-in and transport-independent user-source authority.
|
||||
|
||||
Every native call and started PTC `tools.*` inner call receives one review before its body. The outer `run_code` transport and direct Node effects in a PTC program remain outside this guarantee. There are no tool-name exemptions, cached grants, retries, configurable policy, or second authorization check; [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) owns what follows a denial. A repeated call receives a fresh review.
|
||||
|
||||
@@ -21,12 +21,12 @@ The fixed policy follows the allow/soft-deny/hard-deny distinction and classifie
|
||||
| Risk | Examples | Decision |
|
||||
| --- | --- | --- |
|
||||
| `low` | Ordinary project-local reads/writes, analysis, format/lint/test/build, non-destructive Git, and exact cleanup of an object whose retained calls establish creation in this Session | Allow |
|
||||
| `medium` | Irreversible deletion of pre-existing state, force push/history rewrite, production reads/writes/deployment, non-sensitive external writes/sends, and permission/security/system changes | Allow only with explicit current human or direct-parent authorization naming action, exact target, and necessary scope |
|
||||
| `medium` | Irreversible deletion of pre-existing state, force push/history rewrite, production reads/writes/deployment, non-sensitive external writes/sends, and permission/security/system changes | Allow only with explicit current user or direct-parent authorization naming action, exact target, and necessary scope |
|
||||
| `high` | Sensitive data exfiltration across the current trust boundary and equivalent hard-deny effects | Deny, including an explicitly requested action |
|
||||
|
||||
Actual effects that are ambiguous or broader than established scope fail closed. A later instruction clears an earlier conflict only by explicitly revoking or replacing it; direct-parent instructions cannot override human restrictions. History can establish a session-created object but cannot authorize medium work or remove a high-risk prohibition.
|
||||
Actual effects that are ambiguous or broader than established scope fail closed. A later instruction clears an earlier conflict only by explicitly revoking or replacing it; direct-parent instructions cannot override user restrictions. History can establish a session-created object but cannot authorize medium work or remove a high-risk prohibition.
|
||||
|
||||
The reviewer derives authority from existing Session facts. A shipped Web human instruction has `source.kind === 'user'` and the `rpcId` written at browser prompt admission. A child's initial direct-parent prompt is identified after its own existing creation descriptor; a later `agent-message` is direct-parent instruction only when `senderSessionId` matches `SessionHeader.parentSession`. Human text defines or replaces the task and restrictions. Direct-parent text defines the child task within those restrictions. Project instructions only constrain; checkpoints restore lossy context; images, attachment metadata, and historical calls provide facts. Compaction does not promote a checkpoint into the human authority of text that left the surface.
|
||||
The reviewer derives authority from existing Session facts. Text with `source.kind === 'user'` defines or replaces the task and restrictions, independent of its input transport. A child's initial direct-parent prompt is identified after its own existing creation descriptor; a later `agent-message` is direct-parent instruction only when `senderSessionId` matches `SessionHeader.parentSession`. Inherited and unverified child creation prompts remain facts. [User-source admission](2026-10-18-headless-auto-review.md) defines caller and restored-history semantics. Direct-parent text defines the child task within user restrictions. Project instructions only constrain; checkpoints restore lossy context; images, attachment metadata, and historical calls provide facts. Compaction does not promote a checkpoint into the instruction authority of text that left the surface.
|
||||
|
||||
### One complete reviewer request
|
||||
|
||||
@@ -37,7 +37,7 @@ The integration uses only the latest `request/header.config` provider/model and
|
||||
| `REVIEW_POLICY` | Fixed classification, source authority, and strict result rules; allow executes immediately with Full access and no later confirmation |
|
||||
| `ENVIRONMENT` | Existing Session header `cwd` only |
|
||||
| `PROJECT_INSTRUCTIONS` | Visible project instructions with their original sources and constraint role |
|
||||
| `FILTERED_HISTORY` | Current compaction surface's sourced human/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments |
|
||||
| `FILTERED_HISTORY` | Current compaction surface's sourced user/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments |
|
||||
| `PENDING_ACTION` | Tool name, description, parameter schema, and parsed arguments |
|
||||
|
||||
The reviewer builds those two action sections from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
|
||||
@@ -50,7 +50,7 @@ The reviewer may emit reasoning blocks followed by exactly one JSON text block a
|
||||
|
||||
A final denial, which [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) limits to the `never` approval policy, gives native results and PTC settle events the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. For a final denial the main agent receives only `Auto review rejected tool "<name>"; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The generic Web tool card supplies the denial identity for the collapsed row and one not-executed output line for the expanded row, with no input body. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule.
|
||||
|
||||
Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, changes live Auto Sessions to Full access through the existing preset writer, which writes the `never` approval policy without changing the sandbox value or closing terminals, then aborts and drains reviews before removing listener and contribution. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; a late failure or final denial retains that outcome, and a late denial under `ask` produces a cancelled approval and canonical pre-dispatch cancellation. No cancelled review starts a tool body.
|
||||
Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, then aborts and drains reviews before removing listener and contribution. Only unload while the root context remains active changes live Auto Sessions to Full access through the existing preset writer, which writes the `never` approval policy without changing the sandbox value or closing terminals. Root shutdown preserves selection: stopping the application does not revoke its installed integration, and its child disposers can race Session persistence cleanup. The root fiber enters `UNLOADING` before child cleanup starts, so that lifecycle state distinguishes shutdown without a new shutdown service or timing heuristic. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; a late failure or final denial retains that outcome, and a late denial under `ask` produces a cancelled approval and canonical pre-dispatch cancellation. No cancelled review starts a tool body.
|
||||
|
||||
A persisted Auto Session cannot publish when the complete integration is absent or failed. Its log is not rewritten, and no background retry runs. Reinstallation allows a user to reopen it; live Sessions migrated to Full access remain there until explicitly switched.
|
||||
|
||||
@@ -64,7 +64,7 @@ The [delegation-time policy capture](../../../../packages/subagent/subagent/READ
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Default or released integration** would make an experimental model approval policy part of every deployment. A private source Web patch keeps opt-in explicit while using the existing plugin installer.
|
||||
**Default or released integration** would make an experimental model approval policy part of every deployment. An explicit profile patch keeps opt-in explicit while using the existing plugin installer.
|
||||
|
||||
**A new sandbox or approval-policy value** would couple review to enforcement and create combinations without a current consumer. An explicit preset identity preserves the unchanged Full access execution behavior.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Full access 让有用的项目工作无需反复审批即可继续,但也允
|
||||
|
||||
## 决策
|
||||
|
||||
[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 Web 层,按[实验包发布策略](../../../../packages/experimental/README.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它使用 Full access 的 `danger-full-access` 沙箱与工具定义,审批策略由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)决定。Headless、通用设置与新会话默认值都排除此 integration。
|
||||
[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 profile 层,按[实验包发布策略](../../../../packages/experimental/README.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它使用 Full access 的 `danger-full-access` 沙箱与工具定义,审批策略由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)决定。默认 Headless、通用设置与新会话默认值都排除此 integration。[Headless 调用者入口](2026-10-18-headless-auto-review.zh.md)定义显式 headless 启用方式与不依赖传输的 user-source 权威。
|
||||
|
||||
每个原生调用与已开始的 PTC `tools.*` inner call 都在 body 前接受一次审查。外层 `run_code` transport 与 PTC 程序内直接 Node 效果不在保证范围内。不提供按工具名豁免、缓存 grant、重试、可配置策略或第二授权检查;拒绝之后的行为由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)负责。重复调用也重新审查。
|
||||
|
||||
@@ -21,12 +21,12 @@ Full access 让有用的项目工作无需反复审批即可继续,但也允
|
||||
| 风险 | 示例 | 决定 |
|
||||
| --- | --- | --- |
|
||||
| `low` | 普通项目内读写、分析、format/lint/test/build、非破坏性 Git,以及保留调用能证明在本 Session 创建的对象的精确清理 | 允许 |
|
||||
| `medium` | 不可逆删除既有状态、force push/历史改写、生产读写/部署、非敏感外部写入/发送,以及权限/安全/系统变更 | 只有当前 human 或直接父级明确授权动作、准确目标与必要范围时才允许 |
|
||||
| `medium` | 不可逆删除既有状态、force push/历史改写、生产读写/部署、非敏感外部写入/发送,以及权限/安全/系统变更 | 只有当前用户或直接父级明确授权动作、准确目标与必要范围时才允许 |
|
||||
| `high` | 敏感数据跨越当前信任边界外泄及等价 hard-deny 效果 | 拒绝,包括显式请求的动作 |
|
||||
|
||||
实际效果不明确或超出已确立范围时按拒绝处理。后续指令只有明确撤销或替换旧指令才能消除冲突;直接父级指令不能覆盖 human 限制。历史可以证明对象由本会话创建,但不能授权 medium 操作或解除 high 禁令。
|
||||
实际效果不明确或超出已确立范围时按拒绝处理。后续指令只有明确撤销或替换旧指令才能消除冲突;直接父级指令不能覆盖用户限制。历史可以证明对象由本会话创建,但不能授权 medium 操作或解除 high 禁令。
|
||||
|
||||
Reviewer 从既有 Session 事实派生权威。Shipped Web human 指令具有 `source.kind === 'user'` 和浏览器 prompt admission 写入的 `rpcId`。Child 初始直接父级 prompt 在其自身既有创建 descriptor 之后识别;后续 `agent-message` 只有 `senderSessionId` 与 `SessionHeader.parentSession` 匹配时才属于直接父级指令。Human 文本定义或替换任务与限制。直接父级文本在这些限制内定义 child 任务。项目指令只施加约束;checkpoint 恢复有损语境;图片、附件元数据和历史调用提供事实。Compaction 不会把 checkpoint 提升为已经离开 surface 的原文本所拥有的 human 权威。
|
||||
Reviewer 从既有 Session 事实派生权威。`source.kind === 'user'` 的文本定义或替换任务与限制,不区分输入传输。Child 初始直接父级 prompt 在其自身既有创建 descriptor 之后识别;后续 `agent-message` 只有 `senderSessionId` 与 `SessionHeader.parentSession` 匹配时才属于直接父级指令。继承的 child 创建 prompt 与无法验证的 child 创建 prompt 仍是事实。[User-source 接纳](2026-10-18-headless-auto-review.zh.md)定义调用者与恢复历史的语义。直接父级文本在用户限制内定义 child 任务。项目指令只施加约束;checkpoint 恢复有损语境;图片、附件元数据和历史调用提供事实。Compaction 不会把 checkpoint 提升为已经离开 surface 的原文本所拥有的指令权威。
|
||||
|
||||
### 一次完整 reviewer 请求
|
||||
|
||||
@@ -37,7 +37,7 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi
|
||||
| `REVIEW_POLICY` | 固定分类、来源权威与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 |
|
||||
| `ENVIRONMENT` | 仅既有 Session header `cwd` |
|
||||
| `PROJECT_INSTRUCTIONS` | 可见项目指令,保留原始来源并赋予约束角色 |
|
||||
| `FILTERED_HISTORY` | 当前 compaction surface 中带来源的 human/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 |
|
||||
| `FILTERED_HISTORY` | 当前 compaction surface 中带来源的用户/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 |
|
||||
| `PENDING_ACTION` | 工具名称、描述、参数 schema 与解析后的 arguments |
|
||||
|
||||
reviewer 从 Session 的完整动作历史构建这两个动作分节:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
|
||||
@@ -50,7 +50,7 @@ Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和
|
||||
|
||||
最终拒绝([用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)将其限定在 `never` 审批策略下)让原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。对最终拒绝,主 agent 通过普通失败渲染只收到 `Auto review rejected tool "<name>"; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。通用 Web 工具卡片为折叠行提供拒绝身份,为展开行提供一行未执行输出,不提供输入正文。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。
|
||||
|
||||
准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,经由既有 preset writer 把存活 Auto Session 切为 Full access,该 writer 写入 `never` 审批策略,不改变沙箱值、不关闭终端,然后中止并等待 review 结清,最后移除 listener 与 contribution。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 failure 或最终拒绝保留原结果,`ask` 下晚到的拒绝产生已取消的审批与规范的 dispatch 前取消。被取消的 review 不启动工具 body。
|
||||
准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,然后中止并等待 review 结清,最后移除 listener 与 contribution。只有在根 context 仍活跃时卸载插件,才经由既有 preset writer 把存活 Auto Session 切为 Full access;该 writer 写入 `never` 审批策略,不改变沙箱值、不关闭终端。根关闭保留选择:停止应用不等于撤销已安装的 integration,且子级 disposer 可能与 Session 持久化清理并发。根 fiber 在子级清理开始前进入 `UNLOADING`,因此可用该生命周期状态区分关闭,无需新建关闭服务或依赖时序推测。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 failure 或最终拒绝保留原结果,`ask` 下晚到的拒绝产生已取消的审批与规范的 dispatch 前取消。被取消的 review 不启动工具 body。
|
||||
|
||||
完整 integration 缺失或失败时,持久 Auto Session 不能发布。日志不改写,也不后台重试。重装后用户可以重新打开它;已经迁移为 Full access 的存活 Session 保持原状,直到显式切换。
|
||||
|
||||
@@ -64,7 +64,7 @@ Auto 在选择器选项中带 `EXP` 标记;选中后的 composer 控件不显
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**默认或正式发布 integration** 会把实验模型审批策略带入每个部署。私有源码 Web patch 在复用既有插件安装器的同时保持显式 opt-in。
|
||||
**默认或正式发布 integration** 会把实验模型审批策略带入每个部署。显式 profile patch 在复用既有插件安装器的同时保持显式 opt-in。
|
||||
|
||||
**新增 sandbox 或 approval-policy 值**会把 review 与执行耦合,产生没有当前消费者的组合。显式 preset 身份保留 Full access 未改变的执行行为。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: ed725dff16cd2339
|
||||
zh: 555d4ba70af96790
|
||||
/agent-note-auto-review-asks-the-user-after-a-denial/decision:
|
||||
en: f5ec55bcfe5e9a9a
|
||||
zh: 1b7462f8f06320e9
|
||||
en: 6bd32636c775b690
|
||||
zh: 5579111a3dbb6bf9
|
||||
/agent-note-auto-review-asks-the-user-after-a-denial/alternatives-considered:
|
||||
en: 01f71dd10849f925
|
||||
zh: 8b79a7e149a9a60c
|
||||
|
||||
@@ -14,7 +14,7 @@ A reviewer denial returns the tools pipeline's existing `ask` decision when the
|
||||
|
||||
Selecting Auto writes `danger-full-access` with the `ask` approval policy. A recorded Auto selection also matches the `never` policy. A [delegated in-process child pins `never`](../../../../packages/subagent/subagent/README.md), so a child Auto keeps the final `AutoReviewDeniedError` denial and its reason, and a Session recorded under the earlier `never` bundle still resolves to Auto instead of Full access. Selecting Auto again writes `ask`.
|
||||
|
||||
A reviewer failure denies the call with `Auto review of tool "<name>" failed; its body was not executed: <error>` and no structured error info. A provider failure's message carries the finish kind, failure code, and provider message, so the model and the generic tool card show the actual cause.
|
||||
A reviewer failure denies the call with `Auto review of tool "<name>" failed; its body was not executed: <error>` and structured `AutoReviewFailedError` / `AUTO_REVIEW_FAILED` info. [Headless callers](2026-10-18-headless-auto-review.md) use that identity to distinguish failure from denial. A provider failure's message carries the finish kind, failure code, and provider message, so the model and the generic tool card show the actual cause.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
选择 Auto 会写入 `danger-full-access` 与 `ask` 审批策略。已记录的 Auto 选择也匹配 `never` 策略。[进程内委派 child 固定 `never`](../../../../packages/subagent/subagent/README.zh.md),因此 child 的 Auto 保留最终的 `AutoReviewDeniedError` 拒绝及其理由;按先前 `never` 组合记录的 Session 仍解析为 Auto,而不是 Full access。再次选择 Auto 会写入 `ask`。
|
||||
|
||||
Reviewer 失败以 `Auto review of tool "<name>" failed; its body was not executed: <error>` 拒绝调用,不带结构化错误信息。Provider 失败消息包含 finish 类型、失败代码与 provider 消息,因此模型与通用工具卡片显示实际原因。
|
||||
Reviewer 失败以 `Auto review of tool "<name>" failed; its body was not executed: <error>` 拒绝调用,带结构化 `AutoReviewFailedError` / `AUTO_REVIEW_FAILED` 信息。[Headless 调用者](2026-10-18-headless-auto-review.zh.md)用该身份区分失败与拒绝。Provider 失败消息包含 finish 类型、失败代码与 provider 消息,因此模型与通用工具卡片显示实际原因。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-built-in-plugin-feature-previews.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.md
|
||||
/agent-note-built-in-plugin-feature-previews:
|
||||
en: f6abb7d00b13d362
|
||||
zh: b89d5da18677f31a
|
||||
/agent-note-built-in-plugin-feature-previews/problem:
|
||||
en: e38fa1e5b3a62b5a
|
||||
zh: c612ea229705d695
|
||||
/agent-note-built-in-plugin-feature-previews/decision:
|
||||
en: aa12e3f72f7c2498
|
||||
zh: 7dda9f061009aaa0
|
||||
/agent-note-built-in-plugin-feature-previews/alternatives-considered:
|
||||
en: 4a4416610894d7c6
|
||||
zh: 4256e81e7ab70db5
|
||||
/agent-note-built-in-plugin-feature-previews/consequences:
|
||||
en: a3dbd70080416314
|
||||
zh: 4d802da45085dfb0
|
||||
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-10-15-chat-keep-assistant-replies.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.md
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds:
|
||||
en: 813e0e2590457f2d
|
||||
zh: 12b01305ae439712
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/problem:
|
||||
en: 967c8df85ef30286
|
||||
zh: 7cd5f219fe42f567
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision:
|
||||
en: 7f59f284df26b5fe
|
||||
zh: b6ae20ffdb506ac3
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/what-stays-visible:
|
||||
en: 41d8322fb0a8d010
|
||||
zh: a413bfdffb34833f
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/what-does-not-change:
|
||||
en: 352c5bdf25c09c62
|
||||
zh: 24878584535b4d57
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/implementation:
|
||||
en: e40156780daec8fe
|
||||
zh: 4447a61c2b1574c8
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/alternatives-considered:
|
||||
en: 28694c0a82df72ac
|
||||
zh: 8f6f81c833cf8f2c
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/consequences:
|
||||
en: d648f688c7e6c87d
|
||||
zh: d84f1a43de8992b2
|
||||
/agent-note-keep-every-assistant-reply-when-a-turn-folds/testing:
|
||||
en: 70133688614f2a5c
|
||||
zh: 931a2710211ced3f
|
||||
@@ -0,0 +1,111 @@
|
||||
# Agent Note: Keep Every Assistant Reply When a Turn Folds
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-15-chat-keep-assistant-replies.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
In Compact, Standard, and Detailed, a normally completed Turn folds as a whole. Under the previous rule the reader saw only the input, the whole-Turn control, the **final answer**, and the footer. The final answer is chosen by position: the last Assistant message in the latest Step that has visible reply content and no tool-call block (`latestAnswer` in [turn-process.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts)). Every intermediate reply before it was hidden with the process.
|
||||
|
||||
Position is not content. A Turn often continues after the answer: a settled-subagent notice wakes another Step, the Agent updates its records, or it calls a tool after answering. The last message then becomes a short addendum, or there is no text-only ending at all, and the real answer folds into the process (#6132, #5429). For example, after a long research summary, a subagent completion notice woke one more Step whose message added a single remark; that remark was all the reader saw.
|
||||
|
||||
We made a rough estimate of how intermediate replies are distributed. The proportions below are order-of-magnitude judgments, not statistical results:
|
||||
|
||||
- In main Sessions, intermediate replies before the last reply make up about 10% of the Assistant text; subagent Sessions write more fragmented replies, about 20%.
|
||||
- By count, more than half are one- or two-line "next I will…" messages, so showing all of them adds redundancy.
|
||||
- A few longer intermediate replies carry actual conclusions or deliverables. Under the previous rule they folded with the process, which directly affected whether the reader understood the result.
|
||||
- Some subagent tasks end with a structured-result tool call and no text-only reply, so under the previous rule the whole Turn showed no text at all.
|
||||
|
||||
## Decision
|
||||
|
||||
By default, a folded Turn hides only its process, never its replies: every `assistant-step` reply row stays outside whole-Turn folding, whichever Step it belongs to. Main and subagent Sessions use the same rule, without a branch on the Session kind.
|
||||
|
||||
Settings → General → Replies After Collapse sits directly below When to Collapse Work Details:
|
||||
|
||||
| Option | Behavior |
|
||||
|---|---|
|
||||
| All replies (default, `all`) | Every reply stays visible; only the process folds |
|
||||
| Final answer only (`final`) | Previous rule: intermediate replies fold with the process and only the final answer stays visible |
|
||||
|
||||
- The choice is saved as `ui-chat.foldedReplies` in the same Host profile settings as Work details; without a saved value it uses `all` and does not write the default.
|
||||
- Switching applies immediately to rendered Turns by changing visibility only, without rebuilding rows.
|
||||
- Verbose never folds a Turn, so the option is hidden in that mode.
|
||||
|
||||
### What stays visible
|
||||
|
||||
| Viewing state (Compact/Standard/Detailed, `all`) | Visible content |
|
||||
|---|---|
|
||||
| Whole Turn collapsed | Input, whole-Turn control, **every reply row** in its original order, and Nodes at the `turn` position (terminal errors, max-token notices, the completed-turn footer) |
|
||||
| Whole Turn open | Process group headers and every reply row; group bodies follow the mode and manual disclosure |
|
||||
|
||||
A collapsed Turn hides:
|
||||
|
||||
- the reasoning rows of every Step, including the final answer's reasoning;
|
||||
- tool calls, their results, and step groups;
|
||||
- Nodes at the `process` position, such as model retries;
|
||||
- every other process row without a group-tree position.
|
||||
|
||||
A Turn with replies but no process content keeps the control's title without a collapse action.
|
||||
|
||||
### What does not change
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Folding eligibility | Running, stopped, failed, and interleaved-input Turns offer no whole-Turn collapse; the rules for a missing start or end are unchanged |
|
||||
| Whole-Turn control | One per Turn, with unchanged position and title (duration or status). Hidden step groups between replies simply disappear; no per-segment fold rows appear |
|
||||
| Collapse timing | On completion, On next message, and the exchange exemption are unchanged |
|
||||
| Manual disclosure | Still saved per answer generation |
|
||||
| Footer, branch, and copy | Attached to the Turn's last settled Assistant message with text (`closing` of `turn-tail`), independent of whole-Turn folding |
|
||||
|
||||
### Implementation
|
||||
|
||||
Only the rule that decides process membership changes, and it follows the setting. `TurnProcessSpec` with its answer fields (`answerAnchorSeq`, `answerStep`, `inlineReasoning`, `messageCount`), the `latestAnswer` selection, and manual disclosure saved per answer Step stay as they were; with Final answer only they still apply and the behavior matches the previous rule.
|
||||
|
||||
| Location | Content |
|
||||
|---|---|
|
||||
| [chat-settings.ts](../../../../packages/client/ui-chat/src/chat-settings.ts), [index.ts](../../../../packages/client/ui-chat/src/index.ts) | `FOLDED_REPLIES_FIELD`, `FOLDED_REPLIES = ['all', 'final']`, and `DEFAULT_FOLDED_REPLIES = 'all'`, registered in `ChatSettingsFields` and the Host `Config` |
|
||||
| [transcript-view.ts](../../../../packages/client/ui-chat/src/client/transcript-view.ts) | The `foldedReplies` snapshot and `setFoldedReplies` of `TranscriptViewPolicy`, read and written like `collapseTiming` |
|
||||
| [presentation-policy.ts](../../../../packages/client/ui-chat/src/client/presentation-policy.ts) | `ChatPresentationPolicy.foldedReplies`; `presentationPolicyFor(mode, timing, replies)` returns one fixed object for each of the 16 combinations; `derivePresentationPolicy` subscribes to all three preferences |
|
||||
| [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts), [settings/TranscriptViewRow.tsx](../../../../packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx), [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) | The selector below collapse timing, not rendered in Verbose; Chinese and English copy |
|
||||
| [chat/ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | With `foldedReplies === 'all'`, a `replyRow` (the non-reasoning part of an `assistant-step` with reply content) is not a `processMember` and owns the disclosure (`ownsDisclosure`) to read `foldable`/`open`; collapsible content uses `hasNonReplyProcess` or `hasExternalProcess` according to the setting |
|
||||
| [chat/AssistantNodeView.tsx](../../../../packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx) | With `all`, a folded Turn hides the inline reasoning of every reply row; with `final`, only the final answer's inline reasoning |
|
||||
| [conversation-nodes/turn-process-presentation.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts) | `hasNonReplyProcess` beside `hasExternalProcess`: an `assistant-step` from a Step other than the answer's counts as process content only through its reasoning |
|
||||
|
||||
The [Chat Node business rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md) describe the reader-visible rule and setting; [Chat group-tree positions and exchanges](../architecture/2026-10-11-chat-group-tree-exchanges.md) records that `assistant-step` reply rows stay outside whole-Turn folding.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep showing only the final answer.** The final answer is chosen by position. When a Turn continues after answering, the reader loses the real answer.
|
||||
|
||||
**Distinguish by message structure: show only replies without tool calls, except messages that call `present` or `ask_user_question`.** It removes part of the redundancy while keeping most of the effective content, but it needs a maintained list of tool-name exceptions, a fine-grained rule.
|
||||
|
||||
**Distinguish by a length threshold: keep folding short replies that precede a tool call.** It removes most short previews, but the threshold has no general basis, and Chinese and English character counts are not comparable.
|
||||
|
||||
**Keep the previous rule for subagent Sessions.** ui-chat would need to read `sessions.subagentAddress()` and branch on the Session kind. Subagent replies are more fragmented, so showing all of them adds more redundancy, but those views are rarely read directly, and a reader who opens one usually wants the process; subagent tasks that end with a structured result showed no text under the previous rule and now show the summary written before submitting the result.
|
||||
|
||||
**Give each process segment its own fold row.** Replies split the process into segments, adding many fold rows. The whole-Turn control already opens and closes the entire process at once.
|
||||
|
||||
**Also delete the final-answer structure (the `answerStep` fields, `latestAnswer`, and per-generation manual disclosure).** Less code, but a larger change, and Final answer only could no longer be offered.
|
||||
|
||||
**Change the default rule without a setting.** Readers who prefer the previous presentation would have no way back; the previous rule's code remains anyway, so a preference adds only one read/write path.
|
||||
|
||||
**Bind the option to the Work details mode (for example, Compact = final answer only).** No new settings row, but two independent choices would be tied together and could not be switched separately.
|
||||
|
||||
**Merge short action previews into the title of the following step group.** No text is lost and no row is added, but group titles need their own design, outside this change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gains: Turns that receive a notice, record keeping, or a tool call after answering no longer lose the answer; subagent tasks that end with a structured result are no longer blank; readers who want the previous presentation can switch back with one choice.
|
||||
- Collapsed text grows by an estimated 10% in main Sessions and 20% in subagent views. The added text includes actual conclusions that previously folded away as well as redundancy from one- or two-line action previews; subagent views are rarely read, so the absolute cost is small.
|
||||
- After collapsing, consecutive replies no longer show the process between them and may read as one passage; each reply remains its own row with the original spacing.
|
||||
- A collapsed Turn no longer gives its final answer the control's 16px clearance: in both choices, a reply separated from the control by folded rows keeps the 12px reply gap, and only a row directly following the control receives 16px. Because no reply changes its spacing when the fold finishes, the final answer no longer moves down about 4px at the end of a fold (#6323).
|
||||
- The retained answer structure leaves one small asymmetry under `all`: `data-turn-process-messages` still counts the replies before the final answer although they are visible.
|
||||
- These proportions are rough estimates and vary with ways of working and models.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-chat/tests/chat-view.client.spec.tsx`: replies from every Step stay visible while reasoning and tools fold, a Turn without a text-only ending still shows its replies, and a reply-only Turn offers no collapse; switching between `all` and `final` does not rebuild rows; cases that used an intermediate reply as a process stand-in now use reasoning-only rows.
|
||||
- `packages/client/ui-chat/tests/presentation-policy.client.spec.tsx`: fixed policy objects for the 16 combinations and subscriptions to all three preferences.
|
||||
- `packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts`, `transcript-view-row.client.spec.tsx`, and `config.host.spec.ts`: default, persistence, and adoption; the settings row's position, Chinese and English copy, and hiding in Verbose; Host configuration validation and restoration.
|
||||
- Web e2e: `turn-tail-actions.e2e.ts` and `chat-flow-layout.e2e.ts` assert that the first visible reply after a folded control keeps the 12px reply gap; the settings and folded-Turn aria goldens include the new settings row and intermediate replies.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Agent Note: 整轮折叠保留全部 Assistant 回复
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-15-chat-keep-assistant-replies.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
简洁、标准、详细模式下,正常结束的 Turn 会整轮折叠。原规则下读者只看到输入、整轮控件、**最终答案**和页脚。最终答案按位置选取,即最后一个 Step 里最后一条有可见回复、且不带工具调用块的 Assistant 消息([turn-process.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) 的 `latestAnswer`);在它之前的阶段回复和过程一起被隐藏。
|
||||
|
||||
位置不等于内容。回答之后 Turn 往往还会继续:子 agent 结束的通知唤醒一步,Agent 记账、收尾,或回答后又调用了工具。这时最后一条变成一句简短补充,或者根本没有纯文字收尾,真正的回答被折进了过程(#6132、#5429)。例如一段很长的调研汇总之后,子 agent 结束的通知又唤醒一步,最后一条只写了一句补充;读者看到的就只有这句补充。
|
||||
|
||||
我们对阶段回复的分布做了粗略估计。以下比例是量级判断,不是统计结果:
|
||||
|
||||
- 主会话里,最后一条正文之前的阶段回复约占 Assistant 正文的 10%;子 agent 会话的阶段回复更碎,约占 20%。
|
||||
- 按条数算,一半以上是一两行的"接下来要做什么",全部显示会带来冗余。
|
||||
- 少数较长的阶段回复装着实际结论或交付内容。原规则下它们随过程被折叠,直接影响读者理解问题。
|
||||
- 一部分子任务以结构化结果的工具调用结束,没有纯文字回复,原规则下整轮看不到任何文字。
|
||||
|
||||
## Decision
|
||||
|
||||
整轮收起默认只隐藏过程,不再隐藏回复:所有 `assistant-step` 的回复行都留在整轮折叠之外,不论它属于哪个 Step。主会话和子 agent 会话使用同一规则,不按会话类型分支。
|
||||
|
||||
设置 → 通用设置的「收起后显示的回复」位于「工作步骤收起时机」正下方:
|
||||
|
||||
| 选项 | 行为 |
|
||||
|---|---|
|
||||
| 全部回复(默认,`all`) | 回复全部可见,只收起过程 |
|
||||
| 仅最终回答(`final`) | 原规则:阶段回复随过程一起收起,只露最终答案 |
|
||||
|
||||
- 选择以 `ui-chat.foldedReplies` 保存到与「工作步骤展示」相同的 Host profile 设置;未保存时按 `all`,不自动写入默认值。
|
||||
- 切换立即作用于已渲染的 Turn,只改显隐,不重建行。
|
||||
- 完全展开模式从不折叠 Turn,该选项在这一模式下隐藏。
|
||||
|
||||
### 可见范围
|
||||
|
||||
| 查看状态(简洁/标准/详细,`all`) | 可见内容 |
|
||||
|---|---|
|
||||
| 整轮收起 | 输入、整轮控件、按原顺序排列的**全部回复行**、`turn` 位置的节点(终止错误、最大 token 提示、已完成轮次页脚) |
|
||||
| 整轮展开 | 过程组头与全部回复行;组正文按模式与手动开合显示 |
|
||||
|
||||
整轮收起时隐藏的内容:
|
||||
|
||||
- 所有 Step 的推理行,包括最终答案附带的推理;
|
||||
- 工具调用与结果、步骤组;
|
||||
- `process` 位置的节点(如模型重试);
|
||||
- 其余没有分组树位置的过程行。
|
||||
|
||||
只有回复、没有任何过程内容的 Turn,整轮控件保留标题,不提供折叠操作。
|
||||
|
||||
### 不变的部分
|
||||
|
||||
| 方面 | 说明 |
|
||||
|---|---|
|
||||
| 折叠资格 | 运行中、已停止、失败、过程开始后有可见输入的 Turn 不提供整轮收起;缺起点、缺结束记录的规则不变 |
|
||||
| 整轮控件 | 每个 Turn 一个,位置和标题(时长或状态)不变。回复之间被隐藏的步骤组直接消失,不为每段过程单独生成折叠行 |
|
||||
| 收起时机 | 「完成时收起」「下次发送时收起」与交互段例外不变 |
|
||||
| 手动开合 | 仍按答案世代保存 |
|
||||
| 页脚、分支、复制 | 挂在本 Turn 最后一条带文字的已结算 Assistant 消息上(`turn-tail` 的 `closing`),与整轮折叠无关 |
|
||||
|
||||
### 实现
|
||||
|
||||
只改"谁是过程成员"这一条规则,并让它跟随设置。`TurnProcessSpec` 及其答案字段(`answerAnchorSeq`、`answerStep`、`inlineReasoning`、`messageCount`)、`latestAnswer` 选取、按答案 Step 保存的手动开合保持原样;选「仅最终回答」时这些结构照旧生效,行为与原规则一致。
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| [chat-settings.ts](../../../../packages/client/ui-chat/src/chat-settings.ts)、[index.ts](../../../../packages/client/ui-chat/src/index.ts) | `FOLDED_REPLIES_FIELD`、`FOLDED_REPLIES = ['all', 'final']`、`DEFAULT_FOLDED_REPLIES = 'all'`,登记在 `ChatSettingsFields` 与 Host `Config` |
|
||||
| [transcript-view.ts](../../../../packages/client/ui-chat/src/client/transcript-view.ts) | `TranscriptViewPolicy` 的 `foldedReplies` 快照与 `setFoldedReplies`,读写方式与 `collapseTiming` 相同 |
|
||||
| [presentation-policy.ts](../../../../packages/client/ui-chat/src/client/presentation-policy.ts) | `ChatPresentationPolicy.foldedReplies`;`presentationPolicyFor(mode, timing, replies)` 对 16 种组合各返回固定对象;`derivePresentationPolicy` 订阅三项偏好 |
|
||||
| [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts)、[settings/TranscriptViewRow.tsx](../../../../packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx)、[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) | 设置行在收起时机下方的选择框,完全展开模式下不渲染;中英文案 |
|
||||
| [chat/ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | `foldedReplies === 'all'` 时的 `replyRow`(`assistant-step` 中非推理部分且有回复内容的行)不是 `processMember`,并拥有开合状态(`ownsDisclosure`)以读取 `foldable`/`open`;可折叠内容按设置取 `hasNonReplyProcess` 或 `hasExternalProcess` |
|
||||
| [chat/AssistantNodeView.tsx](../../../../packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx) | `all` 时整轮收起隐藏回复行自带的内联推理;`final` 时只隐藏最终答案的内联推理 |
|
||||
| [conversation-nodes/turn-process-presentation.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts) | `hasExternalProcess` 之外的 `hasNonReplyProcess`:非答案 Step 的 `assistant-step` 只有带推理时才算过程内容 |
|
||||
|
||||
[Chat 节点业务规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md)描述读者可见的规则与设置;[Chat 分组树位置与交互段](../architecture/2026-10-11-chat-group-tree-exchanges.zh.md)记录 `assistant-step` 回复行留在整轮折叠之外。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保持只露最终答案。** 最终答案按位置选取。回答之后 Turn 还在继续时,读者会丢掉真正的回答。
|
||||
|
||||
**按消息结构区分:只露不带工具调用的回复,带 `present`、`ask_user_question` 的消息例外。** 能去掉一部分冗余,同时保住大部分有效内容,但需要按工具名维护例外名单,属于微观规则。
|
||||
|
||||
**按字数阈值区分:较短且紧跟工具调用的回复继续折叠。** 能去掉大部分短预告,但阈值没有通用依据,中英文字数也不可比。
|
||||
|
||||
**子 agent 会话保持原规则。** ui-chat 需要新读 `sessions.subagentAddress()` 并按会话类型分支。子 agent 的阶段回复更碎,全部显示后冗余更多,但这类视图很少被直接阅读,读者点进去通常就是想看过程;以结构化结果收尾的子任务原规则下整轮空白,改后能看到提交结果前写的总结。
|
||||
|
||||
**每段过程各给一个折叠行。** 回复把过程切成多段,会多出大量折叠行。整轮控件已经能一次开合全部过程。
|
||||
|
||||
**同时删除最终答案结构(`answerStep` 等字段、`latestAnswer`、按答案世代的手动开合)。** 代码更少,但改动面大,也无法再提供「仅最终回答」选项。
|
||||
|
||||
**不提供设置,只改默认规则。** 想看原呈现的用户没有退路;原规则的代码本来就保留,加一个偏好只多一条读写链。
|
||||
|
||||
**把选项绑到「工作步骤展示」模式上(例如简洁 = 仅最终回答)。** 不需要新设置行,但两件独立的事被绑在一起,不能单独切换。
|
||||
|
||||
**把短的动作预告并入后续步骤组的标题。** 不丢文字,也不占行,但需要单独设计组标题,不在本次范围内。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 收益:回答之后又有通知、记账或工具调用的 Turn 不再丢失回答;以结构化结果收尾的子任务不再整轮空白;想要原呈现的读者可以一键切回。
|
||||
- 收起后的正文按估计在主会话多出约 10%,在子 agent 视图多出约 20%。多出的内容里既有原本被折掉的实际结论,也有一两行动作预告带来的冗余;子 agent 视图阅读频率低,代价按绝对量计较小。
|
||||
- 收起后连续的回复之间不再显示过程,几条回复可能被连读成一段;每条回复仍是独立行,保持原有间距。
|
||||
- 收起的 Turn 不再给最终答案额外的控件 16px 间距:两种选项下,与控件之间隔着已收起行的回复都保持 12px 回复间距,只有紧跟控件的那一行得到 16px。折叠结束时没有回复改变间距,最终答案不再在折叠末尾下移约 4px(#6323)。
|
||||
- 保留的答案结构在 `all` 下留下一处小的不对称:`data-turn-process-messages` 仍统计最终答案之前的回复数,尽管它们已可见。
|
||||
- 上述比例是粗略估计,不同的工作方式和模型下会有差异。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-chat/tests/chat-view.client.spec.tsx`:收起后各 Step 的回复可见、推理与工具收起、无纯文字收尾的 Turn 仍显示回复、只有回复的 Turn 不提供折叠;`all` 与 `final` 实时切换不重建行;把阶段回复当作过程替身的用例改为纯推理行。
|
||||
- `packages/client/ui-chat/tests/presentation-policy.client.spec.tsx`:16 种组合的固定策略对象与三项偏好订阅。
|
||||
- `packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts`、`transcript-view-row.client.spec.tsx`、`config.host.spec.ts`:默认值、持久化与采纳,设置行位置、中英文案与完全展开时隐藏,Host 配置校验与恢复。
|
||||
- Web e2e:`turn-tail-actions.e2e.ts` 与 `chat-flow-layout.e2e.ts` 断言收起后控件之后第一条可见回复保持 12px 回复间距;设置页与收起后 aria 黄金快照包含新设置行与阶段回复。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for 2026-10-15-deepseek-search-fetch-api.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis:
|
||||
en: fb19be7e7fc65810
|
||||
zh: 865aaed5b7f802cc
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/problem:
|
||||
en: d1aad580a4db9872
|
||||
zh: 1eb3bac59353291d
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision:
|
||||
en: e70efbb3aa9d53b6
|
||||
zh: 1d25eae2461346df
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment:
|
||||
en: ec653b59d62ed95f
|
||||
zh: 0d675616059930ba
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
|
||||
en: a95618f172ef63ee
|
||||
zh: 306556be09c56bdf
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership:
|
||||
en: 8051417b08739ed0
|
||||
zh: c11d35a346014436
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
|
||||
en: d5a8e36b545f1c04
|
||||
zh: d741fd0789401c62
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix:
|
||||
en: 738e32b488bbc1a1
|
||||
zh: f33afe570f62599a
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered:
|
||||
en: 641cbfefc76f2f95
|
||||
zh: e3e718473eace85f
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/consequences:
|
||||
en: a55da02f4d3c06c0
|
||||
zh: 3c1a624f03f34f28
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
|
||||
en: 41f9a331c7aca397
|
||||
zh: 17a0fd53e133c2f5
|
||||
@@ -0,0 +1,84 @@
|
||||
# Agent Note: Direct DeepSeek Search and Fetch APIs
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-15-deepseek-search-fetch-api.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek web retrieval through Messages requires an auxiliary model request for each query. Harness extracts sources and citation excerpts but discards the generated answer. The dedicated Search and Fetch APIs provide structured sources and Markdown directly, so retrieval can run without Harness starting that auxiliary generation. Adopting these APIs requires explicit selection of both operations, independent deployment addresses, and a mapping of Fetch results onto the existing fetch result.
|
||||
|
||||
## Decision
|
||||
|
||||
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields.
|
||||
|
||||
### Protocol selection and deployment
|
||||
|
||||
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together.
|
||||
|
||||
The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable.
|
||||
|
||||
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search.
|
||||
|
||||
### Search results and model input
|
||||
|
||||
Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful.
|
||||
|
||||
The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
|
||||
|
||||
Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data.
|
||||
|
||||
### Fetch metadata and ownership
|
||||
|
||||
Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged.
|
||||
|
||||
The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
|
||||
|
||||
### Default migration
|
||||
|
||||
Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages.
|
||||
|
||||
### Supplementary Messages fix
|
||||
|
||||
The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode.
|
||||
|
||||
**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search.
|
||||
|
||||
**Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route.
|
||||
|
||||
**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it.
|
||||
|
||||
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone.
|
||||
|
||||
**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then.
|
||||
|
||||
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other.
|
||||
|
||||
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages.
|
||||
|
||||
**Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase.
|
||||
|
||||
**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone.
|
||||
|
||||
**Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain.
|
||||
|
||||
## Consequences
|
||||
|
||||
Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
|
||||
|
||||
Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The current tool can issue four queries concurrently and fails the batch if one query fails. The provider has no shared rate scheduler, automatic retries, or fallback; other Sessions or processes can exhaust the same account allowance.
|
||||
|
||||
## Verification and remaining API adoption
|
||||
|
||||
Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
|
||||
|
||||
The following capabilities remain outside the implemented integration:
|
||||
|
||||
- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16.
|
||||
- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language.
|
||||
- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources.
|
||||
- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Agent Note: 直接接入 DeepSeek Search 与 Fetch API
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-15-deepseek-search-fetch-api.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
通过 Messages 获取 DeepSeek 网页搜索结果时,每个查询都需要一次辅助模型请求。Harness 提取来源与引用摘录,却丢弃生成的回答。独立 Search 和 Fetch API 直接提供结构化来源与 Markdown,使检索无需由 Harness 发起这次辅助生成。接入这些 API 需要明确选择两种操作的提供方、独立配置部署地址,并把 Fetch 结果映射到现有抓取结果。
|
||||
|
||||
## 决策
|
||||
|
||||
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。
|
||||
|
||||
### 协议选择与部署
|
||||
|
||||
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。
|
||||
|
||||
两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。
|
||||
|
||||
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。
|
||||
|
||||
### 搜索结果与模型输入
|
||||
|
||||
直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。
|
||||
|
||||
提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
|
||||
|
||||
直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。
|
||||
|
||||
### 抓取元数据与职责
|
||||
|
||||
Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。
|
||||
|
||||
HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
|
||||
|
||||
### 默认值迁移
|
||||
|
||||
将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。
|
||||
|
||||
### 附带的 Messages 修复
|
||||
|
||||
保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。
|
||||
|
||||
**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。
|
||||
|
||||
**复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。
|
||||
|
||||
**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。
|
||||
|
||||
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。
|
||||
|
||||
**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。
|
||||
|
||||
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。
|
||||
|
||||
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。
|
||||
|
||||
**让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。
|
||||
|
||||
**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。
|
||||
|
||||
**新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。
|
||||
|
||||
## 影响
|
||||
|
||||
直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
|
||||
|
||||
Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。当前工具可并发发送四个查询,任一查询失败会使整批失败。提供方没有共享限速调度器、自动重试或回退;其他会话或进程也会消耗同一账号的额度。
|
||||
|
||||
## 验证与尚未接入的 API 能力
|
||||
|
||||
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
|
||||
|
||||
以下能力尚未包含在已实现的接入中:
|
||||
|
||||
- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。
|
||||
- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。
|
||||
- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。
|
||||
- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
|
||||
/agent-note-plugin-detail-documents:
|
||||
en: 2cca350bb3d00987
|
||||
zh: 02689ad1893d30fe
|
||||
/agent-note-plugin-detail-documents/problem:
|
||||
en: 018296714f6bba40
|
||||
zh: 5926db9dca265dd7
|
||||
/agent-note-plugin-detail-documents/decision:
|
||||
en: 638ccade77cb14db
|
||||
zh: c70a83e2ed0e72f7
|
||||
/agent-note-plugin-detail-documents/alternatives-considered:
|
||||
en: 791b7839125f9f99
|
||||
zh: f75e3abeb9777494
|
||||
/agent-note-plugin-detail-documents/consequences:
|
||||
en: 0a37ee01fc43ff26
|
||||
zh: 156571e1d3181015
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Plugin detail documents
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-15-plugin-detail-documents.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin.
|
||||
|
||||
Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read.
|
||||
|
||||
## Decision
|
||||
|
||||
Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/<language>.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it.
|
||||
|
||||
`meta.detail` lives in the `locale/<language>.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document.
|
||||
|
||||
Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections.
|
||||
|
||||
The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison.
|
||||
|
||||
Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url.<base>.insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it.
|
||||
|
||||
The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library.
|
||||
|
||||
**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories.
|
||||
|
||||
**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents.
|
||||
|
||||
**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository.
|
||||
|
||||
**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts.
|
||||
|
||||
**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred.
|
||||
|
||||
**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Authors write a user document per language under `meta.detail`; other packages show their README.
|
||||
- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages.
|
||||
- A repository README is read at the default branch and can differ from the listed version.
|
||||
- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`.
|
||||
- Private repositories work when the user's Git credentials reach the host.
|
||||
- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 插件详细介绍文档
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-15-plugin-detail-documents.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。
|
||||
|
||||
DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。
|
||||
|
||||
## 决策
|
||||
|
||||
详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/<language>.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。
|
||||
|
||||
`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/<language>.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。
|
||||
|
||||
**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。
|
||||
|
||||
由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。
|
||||
|
||||
市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url.<base>.insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。
|
||||
|
||||
Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。
|
||||
|
||||
**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。
|
||||
|
||||
**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。
|
||||
|
||||
**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。
|
||||
|
||||
**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。
|
||||
|
||||
**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。
|
||||
|
||||
**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。
|
||||
|
||||
## 后果
|
||||
|
||||
- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。
|
||||
- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。
|
||||
- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。
|
||||
- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。
|
||||
- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。
|
||||
- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-18-headless-auto-review.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-18-headless-auto-review.md
|
||||
/agent-note-headless-caller-authority-for-auto-review:
|
||||
en: 9be347ac387545f7
|
||||
zh: b360764b7d22d1d6
|
||||
/agent-note-headless-caller-authority-for-auto-review/problem:
|
||||
en: 01dcd32fe547f90c
|
||||
zh: f9f577c4d72ebdbe
|
||||
/agent-note-headless-caller-authority-for-auto-review/decision:
|
||||
en: 372c020d57c6186a
|
||||
zh: 31c00bb5175930b1
|
||||
/agent-note-headless-caller-authority-for-auto-review/alternatives-considered:
|
||||
en: 7e7aefd55c59837c
|
||||
zh: e119a25a2171d376
|
||||
/agent-note-headless-caller-authority-for-auto-review/consequences:
|
||||
en: e3c7afb422c73478
|
||||
zh: 2acb65eaa03ad716
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Headless caller authority for Auto review
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-18-headless-auto-review.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Headless, SDK, and ACP admit tasks with `source.kind: 'user'`. Auto must recognize those tasks as instructions without requiring a browser RPC identity. The message role alone is insufficient: injected context also uses the user role but names its own source.
|
||||
|
||||
## Decision
|
||||
|
||||
The [experimental Auto layer](../../../../packages/experimental/auto-review/README.md#headless-supervision) supports explicit headless selection. Default profiles remain unchanged. This extends the Web-only scope of [per-tool Auto review](2026-08-28-auto-review.md); its risk classes, full-access execution, PTC exclusions, and lifecycle remain authoritative.
|
||||
|
||||
The reviewer assigns user-sourced text the `user-instruction` role, including existing history and tasks admitted before Auto was selected. The user may be a human or a supervising caller. Both can define the task and authorize an exact medium-risk action, target, and scope. The current child's creation prompt and direct-parent messages keep their subordinate role; inherited and unverified creation prompts remain facts. Files, constraints, checkpoints, images, and tool results do not acquire instruction authority. Input producers own the source attribution; task text never selects its source. The message types and persisted fields remain unchanged.
|
||||
|
||||
Headless applies an explicit `--approval-policy` after selecting the permission preset. Saved policy continues when both options are omitted on resume. Pass `--permission-mode auto --approval-policy never` to select unattended Auto explicitly. This retains the structured denial and avoids dependence on a human answerer. `ask` still follows [the approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md); it does not add a CLI approval transport. Application shutdown preserves the saved Auto selection. Restoring it requires the integration even when selecting another preset; removing the plugin while the application remains active still migrates live Sessions to Full access.
|
||||
|
||||
The JSON projection includes existing durable error fields under its normal output bounds. A final denial carries `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional reviewer detail; a technical or parsing failure carries `AutoReviewFailedError` / `AUTO_REVIEW_FAILED`. Neither executes the tool body. Completed agent execution does not imply that every requested action succeeded.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reuse a browser RPC id.** A headless invocation is not a browser request. Faking that identity would couple authorization to a different transport and misdescribe the persisted source.
|
||||
|
||||
**Add a headless admission marker.** The existing user source already identifies task input. A transport-specific marker adds persisted metadata and reader rules without changing the required authority decision.
|
||||
|
||||
**Promote every user-role message.** Plugins also supply user-role context. The existing source discriminator distinguishes admitted user input from injected content.
|
||||
|
||||
**A live caller-approval channel.** Headless consumes stdin as one task. Approving a suspended call needs a bidirectional protocol with correlation, disconnect and cancellation handling, and an answerer lifecycle. A later invocation can instead supply scoped authorization and receive a fresh review, without claiming to approve the earlier call.
|
||||
|
||||
## Consequences
|
||||
|
||||
A supervisor can use Auto for implementation and gates, inspect structured denials, and resume with revision instructions. Native tools keep all supported tool bodies under review; direct Node effects inside PTC remain outside it. The reviewer remains a fallible model with full-access consequences, and the integration remains experimental and opt-in. Custom producers that use the user source are treated as the user; background context must use its own source.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Headless 调用者对 Auto review 的授权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-18-headless-auto-review.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Headless、SDK 和 ACP 以 `source.kind: 'user'` 接纳任务。Auto 必须将这些任务识别为指令,而不要求浏览器 RPC 身份。仅有消息角色不足以判断:注入上下文也使用 user role,但声明自己的 source。
|
||||
|
||||
## Decision
|
||||
|
||||
[实验性 Auto 层](../../../../packages/experimental/auto-review/README.zh.md#headless-supervision)支持显式 headless 选择。默认 profile 保持不变。这扩展了[逐工具 Auto review](2026-08-28-auto-review.zh.md)仅限 Web 的范围;其风险分类、完全访问执行、PTC 排除项和生命周期仍具权威性。
|
||||
|
||||
Reviewer 将 user-source 文本归为 `user-instruction`,包括已有历史和选择 Auto 前接纳的任务。用户可以是人或监督调用者;两者都可定义任务,并明确授权 medium 风险动作、准确目标和范围。当前 child 的创建 prompt 和直接父级消息保留从属角色;继承的创建 prompt 与无法验证的创建 prompt 仍是事实。文件、约束、checkpoint、图片和工具结果不会获得指令权威。输入生产者负责来源归属;任务文本不能选择自己的 source。消息类型和持久字段保持不变。
|
||||
|
||||
Headless 在选择权限预设后应用显式 `--approval-policy`。恢复时省略两个选项会延续已保存的策略。传入 `--permission-mode auto --approval-policy never` 可显式选择无人值守 Auto。这保留结构化拒绝,并避免依赖人工应答者。`ask` 仍遵循[审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md);它不添加 CLI 审批传输。应用关闭保留已保存的 Auto 选择。恢复时需要该 integration,即使要选择其他预设也是如此;应用仍运行时移除插件则仍将存活 Session 迁移到 Full access。
|
||||
|
||||
JSON 投影在正常输出限制内包含已有的持久错误字段。最终拒绝携带 `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` 和可选 reviewer 详情;技术或解析失败携带 `AutoReviewFailedError` / `AUTO_REVIEW_FAILED`。两者都不执行工具 body。Agent 执行完成不代表每个请求动作都成功。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**复用浏览器 RPC id。** Headless 调用不是浏览器请求。伪造该身份会将授权耦合到另一种传输,并错误描述持久来源。
|
||||
|
||||
**添加 headless 入口标记。** 已有 user source 已标识任务输入。传输专属标记增加持久元数据和读取规则,却不改变所需的权威判断。
|
||||
|
||||
**提升每条 user-role 消息。** 插件也提供 user-role 上下文。已有 source 判别字段区分接纳的用户输入与注入内容。
|
||||
|
||||
**实时调用者审批通道。** Headless 将 stdin 作为一个任务消费。审批暂停的调用需要双向协议、请求关联、断连与取消处理,以及应答者生命周期。后续调用可以改为提供限定范围的授权并接受新审查,不声称批准了先前调用。
|
||||
|
||||
## Consequences
|
||||
|
||||
监督者可以使用 Auto 实现修改和运行检查、查看结构化拒绝,并通过修订指令恢复。原生工具让所有支持的工具 body 都经过审查;PTC 内的直接 Node 效果仍不在范围内。Reviewer 仍是可能出错的模型,执行具有完全访问权限的后果,integration 仍保持实验性与显式启用。使用 user source 的自定义生产者被视为用户;后台上下文必须使用自己的 source。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-sdk-native-user-questions.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md
|
||||
/agent-note-sdk-native-user-questions:
|
||||
en: b506991bd07d87dc
|
||||
zh: 9f3e1ecb42163d72
|
||||
/agent-note-sdk-native-user-questions/problem:
|
||||
en: b3dbcdb4b5bce2d1
|
||||
zh: 60df882dc253fcf5
|
||||
/agent-note-sdk-native-user-questions/decision:
|
||||
en: 0f7b71f0ef58f5f8
|
||||
zh: 07bb12acad27d2fc
|
||||
/agent-note-sdk-native-user-questions/consequences:
|
||||
en: 46e3a450c6dce54e
|
||||
zh: 9f99efc6f8d2ab8d
|
||||
/agent-note-sdk-native-user-questions/alternatives-considered:
|
||||
en: 9861066ae00e7bde
|
||||
zh: 0dec00f4e3c692e0
|
||||
/agent-note-sdk-native-user-questions/verification:
|
||||
en: c16fb364fe8ec13b
|
||||
zh: fa23c162348ba682
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: SDK native user questions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-sdk-native-user-questions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The SDK can expose the shared `ask_user_question` tool without a Web GUI, but an out-of-process host cannot answer the native user-questions event through the SDK. Publishing the tool alone leaves calls without an answerer. The SDK must carry the interaction while the application chooses its user interface or simulated-user policy.
|
||||
|
||||
## Decision
|
||||
|
||||
The TypeScript and Python clients accept a question callback and advertise it during initialization. The SDK server adapts the existing user-questions seam to a bidirectional RPC only for its exact owned root Agents. Clients without the capability keep native in-process answerers. Tool definitions and the service's live-root and delegation checks remain shared with the desktop composition.
|
||||
|
||||
Question identity is independent of the JSON-RPC envelope so cancellation can identify outstanding host work. The runtime validates a complete answer batch, aborts pending requests when the tool or server stops, and ignores late responses. The host owns answer collection; the SDK neither chooses answers nor treats missing input as approval. The [protocol reference](../../../../packages/sdk/protocol/README.md#native-user-questions) owns wire details.
|
||||
|
||||
## Consequences
|
||||
|
||||
A GUI and runtime patch module are unnecessary. A host can use the same interface for a terminal, browser, or simulated user. The Python SDK keeps its synchronous API by running callbacks outside the reader thread; callbacks must observe cancellation and release their own resources. Late callbacks cannot publish into a later runtime process. Question waits are process-local; completed tool results retain ordinary Session persistence.
|
||||
|
||||
A Web Session controller is not an interaction provider for the SDK. Reminder restoration uses the SDK's own Session owner and initialized model options; it does not claim a second write handle through a GUI controller.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Requiring a Web GUI or mounting its controller couples headless execution to another lifecycle owner. Asking each host to inject a runtime patch repeats the transport bridge outside the maintained SDK. Removing the shared question tool would reduce the model's available capabilities rather than supply its missing answer path.
|
||||
|
||||
## Verification
|
||||
|
||||
Server tests cover ownership, native fallback, answer validation, cancellation, and transport failure. Client tests cover concurrent bidirectional transport and cancellation, including callback advertisement and answer delivery with either explicit startup or a direct `initialize()` call. Initialization starts the runtime before constructing the handshake capabilities so both entry paths advertise the installed callback. Python tests also cover reentrant requests and a late callback after process replacement. Real shipped-profile tests drive the built-in question in standard and PTC modes and close a runtime while the host is answering. The [server tests](../../../../packages/sdk/server/tests/user-questions.spec.ts), [client tests](../../../../packages/sdk/client/tests/user-questions.spec.ts), [Python tests](../../../../python/sdk/tests/test_user_questions.py), and [profile tests](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts) retain this evidence.
|
||||
Loaded 100 of 2144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user