mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge remote-tracking branch 'origin/master' into turtle/async-notice-efficiency
- tool-jobs: inline a completion notice only when the complete text fits inlineOutputBytes and the producer cap; otherwise point at job_output, whose result the spill policy bounds - subagent: drop toolCallsThisStep; omit the closing text while the child's last logged tool call is its message to the parent
This commit is contained in:
commit
389b729ac8
4878 files changed
+187933
-675713
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
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agent Note: Built-in plugin feature previews
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
English | [中文](2026-10-14-built-in-plugin-feature-previews.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A one-line plugin description does not always show what a plugin produces. Users deciding whether to enable an official bundle benefit from a picture of its result, such as a document ending with the badge that the DSH badge skill adds. A picture of this kind needs an owner, a localization rule, and a delivery path to the Client.
|
||||
|
||||
## Decision
|
||||
|
||||
In this note, built-in plugins are the official bundles that ship with DSH. Plugin Manager owns every feature preview of these bundles. [PluginManagerPage.tsx](../../../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) maps package names to `{ en, zh }` images, in the same file and with the same hardcoding that defines its optional bundle lists. The images live under `packages/client/ui-plugin-manager/src/client/assets/`. The shared [client tsdown preset](../../../../packages/client/tsdown.client.ts) embeds them in the Plugin Manager client bundle as data URLs. The bundle details page shows the image for the UI language below the description and status notices, whether or not the bundle is enabled.
|
||||
|
||||
Every built-in feature preview is an entry in this map. Packages cannot declare a preview, so the map is not a plugin-author interface and can change without a compatibility path.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `preview` field in the package manifest.** Declaring `preview` in `DshPackageManifest` and `PluginLocalizedMeta`, read through app-boot package metadata and checked by the publication and catalog gates, makes image paths, the language map, size limits, and design rules a public contract for every plugin author before any third-party plugin needs one. One official bundle has artwork, which does not justify a public format.
|
||||
|
||||
**Keeping the images in each bundle package and fetching them from the Host.** This keeps artwork beside its feature, but it still requires a Host read path and a resource interface between bundles and Plugin Manager.
|
||||
|
||||
## Consequences
|
||||
|
||||
Built-in feature previews need no metadata reader, manifest validation, publication rule, or catalog field. Adding one means adding an image and a map entry in Plugin Manager. Each preview adds its encoded size to the Plugin Manager client bundle; the badge skill images add about 207 KB.
|
||||
|
||||
Third-party plugins cannot show a preview or other introduction before they are enabled; a bundle's own `plugins.bundle.config` content appears only while its Client half is loaded. The long-term plan is a way for third-party plugins to present their README in Plugin Manager. That design will define its own format and trust rules, and built-in feature previews may move to it then.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agent Note: 内置插件功能配图
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
[English](2026-10-14-built-in-plugin-feature-previews.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一行插件描述不一定能说明插件产生什么结果。用户决定是否启用官方组合包时,一张展示其结果的图片会有帮助,例如一份以 DSH badge skill 添加的徽章结尾的文档。这类图片需要所有者、本地化规则和发送到 Client 的途径。
|
||||
|
||||
## 决策
|
||||
|
||||
在本记录中,内置插件指随 DSH 发布的官方组合包。插件管理拥有这些组合包的所有功能配图。[PluginManagerPage.tsx](../../../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) 把包名映射到 `{ en, zh }` 图片;该映射与定义其可选组合包列表的代码位于同一文件,并采用相同的硬编码方式。图片放在 `packages/client/ui-plugin-manager/src/client/assets/` 下。共享的 [client tsdown preset](../../../../packages/client/tsdown.client.ts) 把图片以 data URL 嵌入插件管理 client bundle。组合包详情页在描述和状态提示下方显示当前 UI 语言对应的图片,无论组合包是否启用。
|
||||
|
||||
每个内置功能配图都是这个映射中的一项。包不能声明配图,因此该映射不是面向插件作者的接口,修改时不需要兼容路径。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在包 manifest 中增加 `preview` 字段。** 在 `DshPackageManifest` 和 `PluginLocalizedMeta` 中声明 `preview`,通过 app-boot 包元数据读取,并由发布和目录检查校验,会在任何第三方插件需要之前,使图片路径、语言映射、大小限制和设计规则成为所有插件作者都要遵守的公开约定。只有一个官方组合包有配图,不足以支持一个公开格式。
|
||||
|
||||
**把图片留在各组合包中,从 Host 获取。** 这样配图与功能放在一起,但仍需要 Host 读取路径以及组合包与插件管理之间的资源接口。
|
||||
|
||||
## 后果
|
||||
|
||||
内置功能配图不需要元数据读取器、manifest 校验、发布规则或目录字段。增加一张配图意味着在插件管理中添加图片和映射项。每张配图都会把其编码后的大小加到插件管理 client bundle 中;badge skill 的图片约增加 207 KB。
|
||||
|
||||
第三方插件在启用前无法显示配图或其他介绍内容;组合包自身的 `plugins.bundle.config` 内容仅在其 Client 部分加载时显示。长期计划是提供一种方式,让第三方插件在插件管理中展示其 README。该设计将定义自己的格式和信任规则,届时内置功能配图可能迁移过去。
|
||||
@@ -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,14 +18,14 @@
|
||||
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
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
|
||||
en: f734fdc57a6546c9
|
||||
zh: 027c28cbc584a8c2
|
||||
en: 6dba3b9a51c28b6d
|
||||
zh: 58a93f6ba2d269ea
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/tool-consumer-behavior:
|
||||
en: 62127883453dd79c
|
||||
zh: 01503a8177a0ddab
|
||||
@@ -55,19 +55,16 @@
|
||||
zh: 64032ea6ab61b836
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/mirror-claude-code-s-url-prompt-webfetch-shape:
|
||||
en: 56a080de39e96056
|
||||
zh: ae4d070aa7657503
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/validate-dns-and-then-call-an-ordinary-fetch:
|
||||
en: aa6c1e7e6374a875
|
||||
zh: 045c8af6cd44c3e1
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/block-private-looking-hostname-strings-without-pinning-resolved-addresses:
|
||||
en: 3d906f3056733479
|
||||
zh: d652723eb5372007
|
||||
zh: 937336a73de70518
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/restrict-fetch-to-public-addresses:
|
||||
en: af3014c0de7ff66f
|
||||
zh: 509040c9183327fe
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/require-per-call-approval-before-public-fetches:
|
||||
en: f8d1fc8e9c9659cf
|
||||
zh: da5792520e35cba0
|
||||
en: 3ea94ffb142d144a
|
||||
zh: 89645f68412f6769
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
|
||||
en: 12fd20ba54818215
|
||||
zh: c1ca000b13666701
|
||||
en: 9774448fd6b21c41
|
||||
zh: 8411c590ad70d5c4
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
|
||||
en: 48b7b8e7310cd56b
|
||||
zh: d406cd6a8a649e58
|
||||
|
||||
@@ -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
|
||||
@@ -199,7 +202,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis
|
||||
|
||||
## Fetch request and result schema
|
||||
|
||||
The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services.
|
||||
The `web_fetch` implementation is an anonymous HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, or internal auth tokens. It does not restrict destination addresses.
|
||||
|
||||
The seam request stays smaller than OpenCode's model-facing tool:
|
||||
|
||||
@@ -235,14 +238,12 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir
|
||||
The fetch provider's resource controls:
|
||||
|
||||
- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
|
||||
- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected.
|
||||
- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation.
|
||||
- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
|
||||
- Abort signals propagate through network fetches and expensive decoding.
|
||||
- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
|
||||
- Only same-origin redirects are followed automatically; each followed hop repeats URL validation. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
|
||||
- Requests carry an explicit product user agent rather than silently impersonating a browser.
|
||||
|
||||
The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy.
|
||||
Requests use the process-wide dispatcher, so the installed outbound proxy policy and the system resolver decide the connection. Loopback, private-network, and cloud metadata destinations are reachable.
|
||||
|
||||
## Tool consumer behavior
|
||||
|
||||
@@ -310,17 +311,13 @@ Rejected for the first version. Those providers often return extracted or summar
|
||||
|
||||
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
|
||||
|
||||
### Validate DNS and then call an ordinary fetch
|
||||
### Restrict fetch to public addresses
|
||||
|
||||
Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior.
|
||||
|
||||
### Block private-looking hostname strings without pinning resolved addresses
|
||||
|
||||
Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it.
|
||||
Rejected. Resolving each hostname, rejecting any non-public answer, and pinning the connection to the validated set does not confine the model, which reaches the same networks through mounted shell tools, and it refuses every page on hosts whose DNS answers with non-public addresses, such as TUN-mode proxies with fake-IP DNS. Deployments that must keep the model away from private destinations need network confinement, a `tools/pre-execute` policy, or no fetch tool.
|
||||
|
||||
### Require per-call approval before public fetches
|
||||
|
||||
Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
|
||||
Rejected for the shipped presets. Per-call confirmation would interrupt ordinary browsing without controlling data egress reliably: a model can reach the same networks through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -332,7 +329,7 @@ Rejected for the shipped presets. Public-address validation blocks SSRF destinat
|
||||
|
||||
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
|
||||
|
||||
**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
|
||||
**Fetch is a network boundary, not just a read-only tool.** `web_fetch` reaches any destination the host can, including loopback and private-network services, a model can disclose data through a URL, and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
|
||||
|
||||
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -199,7 +202,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`
|
||||
|
||||
## Fetch 请求与结果 schema
|
||||
|
||||
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。
|
||||
`web_fetch` 的实现是一个匿名 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证或内部认证令牌。它不限制目的地址。
|
||||
|
||||
seam 请求比 OpenCode 的面向模型工具更小:
|
||||
|
||||
@@ -235,14 +238,12 @@ export type WebFetchBody =
|
||||
fetch 提供方的资源控制:
|
||||
|
||||
- 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。
|
||||
- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。
|
||||
- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。
|
||||
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
|
||||
- Abort 信号传播到网络获取和高开销解码。
|
||||
- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
|
||||
- 仅自动跟随同源重定向;每个跟随的跳转都会重复 URL 校验。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
|
||||
- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。
|
||||
|
||||
只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
|
||||
请求使用进程级 dispatcher,因此由已安装的出站代理策略与系统解析器决定连接。loopback、内网与云 metadata 目的地址都可访问。
|
||||
|
||||
## 工具消费方行为
|
||||
|
||||
@@ -310,17 +311,14 @@ fetch 提供方的资源控制:
|
||||
|
||||
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。
|
||||
|
||||
### 验证 DNS 后调用普通 fetch
|
||||
<a id="restrict-fetch-to-public-addresses"></a>
|
||||
### 只允许抓取公开地址
|
||||
|
||||
否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。
|
||||
|
||||
### 只阻断看起来像私网的 hostname 字符串,不固定解析地址
|
||||
|
||||
否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。
|
||||
否决。解析每个 hostname、拒绝任何非公开解析结果并把连接固定到已验证地址,并不能约束模型,因为模型可以通过已挂载的 shell 工具访问相同网络;同时在 DNS 返回非公开地址的主机上(例如使用 fake-IP DNS 的 TUN 模式代理),这会拒绝所有页面。需要阻止模型访问私有目的地址的部署应使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
|
||||
|
||||
### 在公开抓取前要求逐次审批
|
||||
|
||||
已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
|
||||
已交付的 preset 不采用这一方案。逐次确认会打断普通浏览,却不能可靠控制数据出站:模型可以通过已挂载的 shell 工具访问相同网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -332,7 +330,7 @@ fetch 提供方的资源控制:
|
||||
|
||||
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
|
||||
|
||||
**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
|
||||
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 可以访问主机能访问的任何目的地址,包括 loopback 与内网服务;模型可以通过 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
|
||||
|
||||
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
|
||||
|
||||
|
||||
+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 包。
|
||||
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
en: 26e4b71366bebd1b
|
||||
zh: 10598e12b28c586e
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification:
|
||||
en: fbd101d4576afa92
|
||||
zh: 35f5d3c2f6cad6a8
|
||||
en: 983da6d0c09f5c10
|
||||
zh: ca7c86117a1a4d5f
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered:
|
||||
en: c4d86feb135d2ac4
|
||||
zh: 67c56c0bc2213949
|
||||
|
||||
@@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making
|
||||
|
||||
## Verification
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
|
||||
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
## 验证
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
|
||||
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
|
||||
|
||||
|
||||
+6
-6
@@ -21,20 +21,20 @@
|
||||
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
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/hot-reload-one-driver-plugin-self-watched-bundles:
|
||||
en: f8b591d4c41ef492
|
||||
zh: bc70f1cd540b7753
|
||||
en: c1fdcb3e6f074a7a
|
||||
zh: b08c7b1ff25acb09
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/package-ownership:
|
||||
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.
|
||||
|
||||
@@ -94,7 +94,7 @@ On the browser side, the transport delegates code replacement to the same module
|
||||
|
||||
Bootstrap replacement is rejected before invalidation or teardown: the module system retains its initial exports, so remounting its old code would reset consumers without applying the requested revision. The page reports the required reload and preserves the bootstrap fiber. Every non-bootstrap plugin shares the replacement semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy.
|
||||
|
||||
Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page. Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
|
||||
Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page; when the Host itself serves another release, the page holds plugin replacement and asks whether to reload ([Host release change prompt](../feature/2026-10-14-host-release-change-refresh-prompt.md)). Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
|
||||
|
||||
## Package ownership
|
||||
|
||||
@@ -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 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -94,7 +94,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
|
||||
|
||||
Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
|
||||
|
||||
重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
|
||||
重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面;Host 本身换成另一个 release 时,页面会暂停插件替换并询问是否刷新([Host release 变化提示](../feature/2026-10-14-host-release-change-refresh-prompt.zh.md))。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
|
||||
|
||||
## 包归属
|
||||
|
||||
@@ -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 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 504dd5f06475d707
|
||||
zh: 6557fd5649f48944
|
||||
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/decision:
|
||||
en: 4c58d379cd59d879
|
||||
zh: a6b6322fe82d7b6a
|
||||
en: 1077d88affd90a59
|
||||
zh: 73b07bf6dcb130ba
|
||||
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/alternatives-considered:
|
||||
en: d906d7a421c6b5c0
|
||||
zh: b230cd58c1b7ac79
|
||||
|
||||
@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](../../archived/architecture/2026-08-06-app-owned-command-line.md).
|
||||
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A non-shipped target can use `--from-default-profile <template>` to replace itself with a copy of one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher assembles the copy in a staging directory beside the target and renames it into place, so repeated rescue runs reset the profile; a failed rename restores the previous directory. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md), which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](../../archived/architecture/2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
|
||||
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前,用一个默认模板的 bundle 列表与 patch 重载策略的副本替换自身。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 在目标旁的暂存目录中组装副本并重命名到目标位置,因此重复执行 rescue 会重置该 profile;重命名失败时会恢复原有目录。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md),在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
|
||||
|
||||
|
||||
+8
-8
@@ -9,14 +9,14 @@
|
||||
en: 1fe344c95a0de36d
|
||||
zh: 656c3e0f1117f64e
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/decision:
|
||||
en: b70aafc9ce30dd00
|
||||
zh: 74e3ff915bd968a9
|
||||
en: 3f5b3a4646c9466b
|
||||
zh: 690172d6a4025b2a
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/alternatives-considered:
|
||||
en: 80504ffd8331e6c5
|
||||
zh: 193261bdde24b4bb
|
||||
en: bc437309b64997ab
|
||||
zh: 947010eed8d6fac4
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/consequences:
|
||||
en: 0738d36f17c889b5
|
||||
zh: b71ee47f75d6d4e0
|
||||
en: 0f4d735328b3211d
|
||||
zh: db4adb0f46b33bba
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/consequences/accepted-risks:
|
||||
en: f634caa9973eaedb
|
||||
zh: 820291096c8e7352
|
||||
en: 72f4913b603e8596
|
||||
zh: 50924c62200070d1
|
||||
@@ -18,7 +18,7 @@ The model-facing `send_message` tool is registered globally for every Agent in a
|
||||
|
||||
The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startActivation()` preserve the same request-prefix behavior across these compositions.
|
||||
|
||||
Byte-identical prefix reuse is qualified by explicit deployment choices. A fork delegation that applies a child persona or `toolFilter` may still change the request head. In particular, filtering out `send_message` removes both the schema and the return guidance from the child; the runtime does not bypass an explicit allow-list.
|
||||
Byte-identical prefix reuse depends on the assembled inputs. Structured output or generated SDK differences can change the request head. Historical child descriptors can also restore persona or tool restrictions during cold resume; filtering out `send_message` removes both its schema and return guidance. New fork starts inherit the parent preset without these override fields.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,11 +28,11 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork
|
||||
|
||||
**Add the return instruction to the system prompt.** This would place child-only bytes ahead of inherited messages. Appending it to the initial user task preserves the inherited prefix and keeps the parent id next to the task that needs it.
|
||||
|
||||
**Ignore an explicit child `toolFilter`.** Structural return tools previously bypassed the child allow-list. Rejected because a declared tool restriction must determine both schema visibility and guidance; hidden authority would make the model-facing roster inaccurate.
|
||||
**Ignore a historical child `toolFilter` on resume.** A stored restriction must continue to determine both schema visibility and guidance; silently discarding it changes the restored agent’s behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Parent and continuable-fork child expose byte-identical ordered tool schemas when the delegation does not request a persona or tool filter.
|
||||
- Parent and continuable-fork child expose byte-identical ordered tool schemas when they use the same preset and have no child-only structured output or restored historical overrides.
|
||||
- The inherited Session seed precedes the child's initial task and return guidance.
|
||||
- The base bundle and full CLI presets use local continuable fork for both parent and caller delivery, without a child-only request-head addition.
|
||||
- The child can send messages to its direct parent through `send_message`. Parent delivery adds a separate runtime settlement notice; caller delivery returns the result through the activation receipt.
|
||||
@@ -40,4 +40,4 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork
|
||||
|
||||
### Accepted risks
|
||||
|
||||
Provider-side prefix reuse still depends on the selected provider and model and on the absence of explicit persona or tool-filter differences. The harness proves equality of its assembled request-head inputs, not a provider's cache behavior.
|
||||
Provider-side prefix reuse still depends on the selected provider and model and matching assembled inputs. The harness tests equality of its assembled request-head inputs, not a provider’s cache behavior.
|
||||
@@ -18,7 +18,7 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作
|
||||
|
||||
base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startActivation()` 在这些组合中保持相同的请求前缀行为。
|
||||
|
||||
逐字节相同的前缀复用受显式部署选择约束。配置 child persona 或 `toolFilter` 的 fork 委派仍可能改变请求头。尤其是过滤掉 `send_message` 时,child 会同时失去该 schema 与返回指引;运行时不会绕过显式 allow-list。
|
||||
逐字节相同的前缀复用取决于组装输入。结构化输出或生成 SDK 差异可以改变请求头。历史子级 descriptor 也可在冷恢复时恢复 persona 或工具限制;过滤掉 `send_message` 会同时移除其 schema 和返回指引。新 fork 启动继承父级 preset,不含这些覆盖字段。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -28,11 +28,11 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为
|
||||
|
||||
**把返回指令放进系统提示词。** 这会在继承消息之前加入 child-only 字节。将其追加到初始用户任务,可以保留继承前缀,并让 parent id 紧邻需要它的任务。
|
||||
|
||||
**忽略显式 child `toolFilter`。** 结构性返回工具过去会绕过 child allow-list。否决该方案,因为声明的工具限制必须同时决定 schema 可见性与指引;隐藏权限会让面向模型的工具清单失真。
|
||||
**恢复时忽略历史子级 `toolFilter`。** 存储的限制必须继续决定 schema 可见性和指引;静默丢弃它会改变恢复 agent 的行为。
|
||||
|
||||
## 后果
|
||||
|
||||
- 未请求 persona 或工具过滤时,parent 与可继续 fork child 暴露逐字节相同且顺序一致的工具 schema。
|
||||
- 使用相同 preset 且没有子级专属结构化输出或恢复的历史覆盖时,父级与可继续的 fork 子级公开逐字节相同、顺序一致的工具 schema。
|
||||
- 继承的 Session 种子位于 child 初始任务及其返回指引之前。
|
||||
- base 组合包与完整 CLI preset 对 parent 和 caller 投递均使用本地可继续 fork,不增加 child-only 请求头字段。
|
||||
- child 可以通过 `send_message` 向直接 parent 发消息。parent 投递会追加独立的运行时结算通知;caller 投递通过 activation 回执返回结果。
|
||||
@@ -40,4 +40,4 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为
|
||||
|
||||
### 已接受风险
|
||||
|
||||
提供方侧前缀复用仍取决于选定的提供方和模型,以及是否不存在显式 persona 或工具过滤差异。harness 证明的是自己组装出的请求头输入相等,而不是提供方的缓存行为。
|
||||
提供方侧前缀复用仍取决于所选提供方和模型,以及组装输入是否相同。harness 测试的是自身组装请求头输入相等,而非提供方的缓存行为。
|
||||
+2
-2
@@ -18,5 +18,5 @@
|
||||
en: ca7c9088d0793e1e
|
||||
zh: ea648bac9e726c64
|
||||
/agent-note-what-stays-host-plane-once-presets-own-the-agent-plane/consequences:
|
||||
en: de26cde94adda770
|
||||
zh: 7f39760b77958966
|
||||
en: 0a426923c2a1f774
|
||||
zh: 29626804b99b35ff
|
||||
+1
-1
@@ -38,6 +38,6 @@ Projection key presence is not a per-session capability signal ([session project
|
||||
|
||||
## Consequences
|
||||
|
||||
The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting.
|
||||
The context meter is a per-session fact independent of mount history. Presets resolve the host meter for token accounting while selecting their own compaction backend; `minimal` supports both automatic compaction and the human `/compact` command.
|
||||
|
||||
Production entry points must compose Agents explicitly; installing the registry alone does not bind them. An unjoined Agent receives no tools, prompt sections, or skills and reports no error.
|
||||
+1
-1
@@ -38,6 +38,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。
|
||||
context meter 是独立于挂载历史的逐会话事实。Preset 使用宿主 meter 进行 token 记账,并选择自己的压缩后端;`minimal` 同时支持自动压缩和用户 `/compact` 命令。
|
||||
|
||||
生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。未加入的 Agent 不会获得工具、提示词段落或 skill,也不会报告错误。
|
||||
+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 集。
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 01293500639c9d79
|
||||
zh: 6d19e4843955c811
|
||||
/agent-note-browser-launch-token-authentication/decision:
|
||||
en: 54efa59339f75256
|
||||
zh: 0f53ead3844151c6
|
||||
en: 51885594bdeb6f0c
|
||||
zh: 9c2e41ad79ccdd08
|
||||
/agent-note-browser-launch-token-authentication/verification:
|
||||
en: 189b36d0fc5f3a17
|
||||
zh: 08993cb585204fc0
|
||||
|
||||
@@ -10,6 +10,8 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
|
||||
|
||||
## Decision
|
||||
|
||||
Browser callers follow this decision; independent CLI/MCP admission is owned by the [API connect decision](2026-10-09-operator-scoped-connect-handshake.md).
|
||||
|
||||
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
|
||||
|
||||
@@ -10,6 +10,8 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
|
||||
|
||||
## 决策
|
||||
|
||||
浏览器调用方遵循本决策;独立 CLI/MCP 接纳由 [API connect 决策](2026-10-09-operator-scoped-connect-handshake.zh.md)定义。
|
||||
|
||||
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
|
||||
|
||||
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 1188c699ce6e6ca2
|
||||
zh: 4d5b974cd1216f06
|
||||
/agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction:
|
||||
en: 8a4be155a4984ca8
|
||||
zh: 00c2a61da2443530
|
||||
en: 4b0a25389324fa91
|
||||
zh: c2cdd9f7e40ef43b
|
||||
/agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition:
|
||||
en: 5a9ec6478cf7430c
|
||||
zh: f4c2f067cc72040e
|
||||
|
||||
@@ -46,7 +46,7 @@ interface SendMessageInput {
|
||||
}
|
||||
```
|
||||
|
||||
Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. A child `toolFilter` may explicitly remove the inherited tool, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix.
|
||||
Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix.
|
||||
|
||||
The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ interface SendMessageInput {
|
||||
}
|
||||
```
|
||||
|
||||
parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。child `toolFilter` 可以显式移除继承的工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。
|
||||
parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。
|
||||
|
||||
该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。
|
||||
|
||||
|
||||
+2
-2
@@ -18,5 +18,5 @@
|
||||
en: 5917a573cbdc5fa4
|
||||
zh: 3c3769070539a0a9
|
||||
/agent-note-handle-based-session-persistence/related:
|
||||
en: 6b160ed89d2f91a7
|
||||
zh: 23ee33d03847d0d0
|
||||
en: 0ff54c05c2a64c07
|
||||
zh: f3bd6a5d1f37563f
|
||||
@@ -36,6 +36,7 @@ Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one e
|
||||
|
||||
## Related
|
||||
|
||||
- [Cleanup dependency lifetimes](../../proposed/architecture/2026-10-09-cordis-cleanup-dependency-lifetimes.md) proposes retaining final events emitted during graceful cleanup, tightening this decision's permitted root-disposal tail loss while preserving handle ownership and storage format.
|
||||
- [Session persistence as an abstract service](../../../../packages/session/session-persistence/README.md) — the seam this reshapes; its interface list reflects the handle API.
|
||||
- [Persistence export() and pre-release trims](../../archived/simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the retired cold blank probe.
|
||||
- [Retain ignorable external session events](2026-08-30-retain-ignorable-external-session-events.md) — the read-side refusal contract, now shared through `storage-contract` helpers.
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ Status: implemented
|
||||
|
||||
## 相关
|
||||
|
||||
- [清理依赖生命周期](../../proposed/architecture/2026-10-09-cordis-cleanup-dependency-lifetimes.zh.md)提议保留有序清理期间发出的最终事件,收紧本决定允许的根释放尾部丢失,同时保留句柄所有权与存储格式。
|
||||
- [作为抽象服务的会话持久化](../../../../packages/session/session-persistence/README.zh.md)——本 Note 重塑的 seam;其接口列表已反映句柄 API。
|
||||
- [持久化 export() 与预发布读取路径精简](../../archived/simplification/2026-08-27-persistence-export-and-pre-release-trims.md)——预备性的移除,包括已退役的冷空白探测。
|
||||
- [保留可忽略的外部会话事件](2026-08-30-retain-ignorable-external-session-events.zh.md)——读取侧的拒绝约定,现经由 `storage-contract` 辅助函数共享。
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
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: 6435c336ab53ca0f
|
||||
zh: 57070799a7061f85
|
||||
/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
|
||||
en: ff1898a64e5db79c
|
||||
zh: 0a77d02385fb3506
|
||||
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/testing:
|
||||
en: 0d7eaa666c94a5cd
|
||||
zh: 210fa098f154d137
|
||||
en: fddb0be6abbd809e
|
||||
zh: 88843f0f3ea60555
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
|
||||
|
||||
**Rejection is quiet, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead.
|
||||
|
||||
**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently.
|
||||
**`web_fetch` uses the installed dispatcher.** The provider sends every request through the process-wide dispatcher, so a proxied hop goes through the proxy, which performs the origin's DNS, and a hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, connects directly. The provider does not restrict destination addresses ([web capability seam](2026-06-24-web-capability-seam.md#restrict-fetch-to-public-addresses)).
|
||||
|
||||
The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop.
|
||||
|
||||
@@ -48,17 +48,17 @@ 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.
|
||||
|
||||
**Every call site carries an egress test.** Each owning package's `egress.spec.ts` drives its actual transport through a fake proxy and checks the observed route. These tests cover search backends, pi-ai discovery, MCP over HTTP, child Node processes, worker threads and telemetry's direct-route exception. They detect dependency transport changes that a static call-site check cannot observe.
|
||||
|
||||
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
|
||||
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and a call site that genuinely owns its transport says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -78,8 +78,6 @@ A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied
|
||||
|
||||
Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness.
|
||||
|
||||
`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters.
|
||||
|
||||
Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field.
|
||||
|
||||
The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first.
|
||||
@@ -88,7 +86,7 @@ The suite is hermetic against the developer's own environment: every Vitest conf
|
||||
|
||||
`packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce.
|
||||
|
||||
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path.
|
||||
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts that a proxied hop reaches the proxy, a bypassed hop connects directly, and the cross-origin redirect refusal survives on the proxied path.
|
||||
|
||||
`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
|
||||
|
||||
**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。
|
||||
|
||||
**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。
|
||||
**`web_fetch` 使用已安装的 dispatcher。** 该提供方的每个请求都经过进程级 dispatcher,因此代理转发的一跳经过代理,由代理执行 origin 的 DNS;被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,则直接连接。该提供方不限制目的地址([web capability seam](2026-06-24-web-capability-seam.zh.md#restrict-fetch-to-public-addresses))。
|
||||
|
||||
URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
|
||||
|
||||
@@ -48,17 +48,17 @@ 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` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西。
|
||||
|
||||
**每个出网点都配有出网测试。** 各所属包中的 `egress.spec.ts` 通过假代理驱动实际传输,并检查观察到的路由。这些测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、子 Node 进程、worker 线程和遥测的直连例外。它们可发现静态调用点检查无法观察到的依赖传输变化。
|
||||
|
||||
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
|
||||
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;确实自有传输的调用点用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
|
||||
|
||||
## 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」的断言来回应。
|
||||
|
||||
@@ -78,8 +78,6 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
|
||||
|
||||
由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。
|
||||
|
||||
`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。
|
||||
|
||||
userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。
|
||||
|
||||
测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。
|
||||
@@ -88,7 +86,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
|
||||
|
||||
`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。
|
||||
|
||||
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。
|
||||
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言代理转发的一跳抵达代理、被绕过的一跳直接连接,且跨域重定向拒绝在代理路径上依然成立。
|
||||
|
||||
`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
|
||||
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
en: 99308bd758859e02
|
||||
zh: 35f1e7719fe80d2e
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes:
|
||||
en: 0e5e2006caec3c91
|
||||
zh: fb3b61dd5692d2e3
|
||||
en: 39d27cff0d419259
|
||||
zh: 6cbdc88a450614d0
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers:
|
||||
en: b0607713102a093d
|
||||
zh: 2de9c33bfaa0e3e2
|
||||
|
||||
@@ -107,7 +107,7 @@ New Workers inherit the latest published generation. Existing Workers keep the g
|
||||
|
||||
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive.
|
||||
|
||||
A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. A profile mapping moves to another directory only through removal and a new installation; a linked root may point to another target ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
|
||||
### Filesystem and runtime carriers
|
||||
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列
|
||||
|
||||
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。
|
||||
|
||||
保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。profile 映射只能通过卸载和重新安装移到其他目录;linked root 可以指向其他目标([替换 Note](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
|
||||
### 文件系统与运行时载体
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 6bb727a659ba1503
|
||||
zh: d2ebf5c8164a807e
|
||||
/agent-note-current-profile-plugin-management-shares-cli-transactions/decision:
|
||||
en: 863b4369c39f3933
|
||||
zh: 7dd44a91ccaaef8f
|
||||
en: d2e13562b5aef903
|
||||
zh: 07face30189c07fc
|
||||
/agent-note-current-profile-plugin-management-shares-cli-transactions/alternatives-considered:
|
||||
en: 30c609de89084351
|
||||
zh: 27f2747ad9d9b752
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
|
||||
|
||||
Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
|
||||
|
||||
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
|
||||
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Standard and Creator modes and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
|
||||
|
||||
CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
|
||||
|
||||
profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
|
||||
|
||||
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
|
||||
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。标准模式和创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
|
||||
|
||||
CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 3c2dd2c948ffb51b
|
||||
zh: d019197df0ee3dcf
|
||||
/agent-note-deepseek-account-browser-login/consequences:
|
||||
en: a6eff4a0350e9018
|
||||
zh: 2e3200ca089023ab
|
||||
en: 3c5681b492cd34cf
|
||||
zh: d7707fed9241f9bc
|
||||
/agent-note-deepseek-account-browser-login/verification:
|
||||
en: 881edcb454b941a6
|
||||
zh: 7dc5a9b7a066f623
|
||||
|
||||
@@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op
|
||||
|
||||
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
|
||||
|
||||
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
The bundled account UI mounts the account launcher, settings and sign-in onboarding in both Desktop and plain Web. The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience; the UI sends desktop when the Desktop preload bridge is present and web otherwise. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
|
||||
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请
|
||||
|
||||
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
|
||||
|
||||
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
内置账号界面在 Desktop 和普通 Web 中都挂载账号入口、设置和登录引导。Host 在 auth_init 中发送 login_source(desktop 或 web),以便 Platform 选择完成页体验;存在 Desktop preload 桥接时界面发送 desktop,否则发送 web。Web 登录失败时关闭授权标签页,原标签页通过 Host 状态获知结果;不使用 Web UI 返回 URL。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
|
||||
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
|
||||
|
||||
|
||||
+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 后自行消失;页面上不留任何东西。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 44615a7a0939054e
|
||||
zh: b8efa721321ba0d7
|
||||
/agent-note-creator-mode-installs-persistent-plugin-bundles/decision:
|
||||
en: c0d74db1b2b16576
|
||||
zh: b8713a6aadf007f9
|
||||
en: 38ecabc170c418c7
|
||||
zh: d380f53fa508328e
|
||||
/agent-note-creator-mode-installs-persistent-plugin-bundles/alternatives-considered:
|
||||
en: af527d8aabdfd59c
|
||||
zh: 34388da992d2ab56
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ Agents need to install capabilities and use them during the same conversation. G
|
||||
|
||||
Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior.
|
||||
|
||||
The default Standard mode enables the same tool under the same profile condition and approval gate. Without it, Standard sessions did not install plugins directly and spent extra turns and tokens working around the missing tool; the change also moves Standard and Creator toward one capability set. A full-access Standard session can therefore install Host code without a separate prompt, as its shell already can. PTC and Minimal keep the tool disabled.
|
||||
|
||||
The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset reference](../../../../packages/extensions/cordis-host-runner/README.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures.
|
||||
|
||||
Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result.
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ agent 需要安装能力并在同一段对话中使用。生成代码工具在
|
||||
|
||||
创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。
|
||||
|
||||
默认的标准模式以相同的 profile 条件和审批门槛启用同一工具。缺少该工具时,标准模式会话不会直接安装插件,而要额外花费轮次和 token 绕过;此改动也让标准模式和创造模式朝同一能力集收敛。因此,完整权限的标准模式会话无需单独审批即可安装 Host 代码,与其 shell 已有的能力一致。PTC 和 Minimal 仍禁用该工具。
|
||||
|
||||
模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集参考](../../../../packages/extensions/cordis-host-runner/README.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。
|
||||
|
||||
除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-17-experimental-cua-object-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/architecture/2026-09-17-experimental-cua-object-api.md
|
||||
/agent-note-native-cua-objects-independent-of-evaluator-ownership:
|
||||
en: 1ea218552c24946e
|
||||
zh: 83524cf60abec6c8
|
||||
/agent-note-native-cua-objects-independent-of-evaluator-ownership/problem:
|
||||
en: a22ddcfb339e503d
|
||||
zh: a1f4fadb46fce1e7
|
||||
/agent-note-native-cua-objects-independent-of-evaluator-ownership/decision:
|
||||
en: 2780d1b0f5ce9ea8
|
||||
zh: ff986f8c1c90300f
|
||||
/agent-note-native-cua-objects-independent-of-evaluator-ownership/alternatives-considered:
|
||||
en: 3d0bc77e3c432cb9
|
||||
zh: 50a20eda1811f16c
|
||||
/agent-note-native-cua-objects-independent-of-evaluator-ownership/consequences:
|
||||
en: 528c644d5810e60b
|
||||
zh: 9649dfc3b1172a6d
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Native CUA objects independent of evaluator ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-experimental-cua-object-api.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Computer-use programs need consistent app and window operations. Coupling those operations to a CUA-specific interpreter makes input handling depend on unrelated choices about variable persistence, imports and cancellation.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared `cua` library owns application/window objects, consent, and logged dispatch. `cua-macos` owns the Swift helper, its build, and its local connection lifecycle. Providers pass a prepared executable and a connection factory to the shared installer, keeping platform code out of object consumers without adding another runtime registry.
|
||||
|
||||
The experimental CUA package owns a native-app subset of the Codex-style object interface and selected MIT-licensed macOS behavior adapted from Cua Driver in a Swift helper. A source manifest records the adapted upstream files and local implementation. The helper is locally compiled; the package has no external Cua Driver runtime dependency. The existing [provider registration](2026-09-12-computer-use-provider-registration.md) prevents competing computer-use providers within one composition.
|
||||
|
||||
The facade accepts an operation dispatcher. Its tool adapter resolves the current tool binding for each operation, preserving normal authorization, logging and image admission. The execution host owns variable persistence and completion serialization. CUA defines no persistence directive, worker or run_code replacement.
|
||||
|
||||
Native process ownership follows the [shared helper decision](2026-10-09-cua-shared-native-helper.md), with isolated Agent connections. App handles bind process identity and kernel start time; replacing the process or helper never revives old handles. AX snapshots are scoped to an app and optional window, so independent window observations remain usable. Coordinate actions select an owned window; the Host maps [screenshot coordinates](2026-10-13-cua-hover-routes-and-coordinates.md) to native window-local points. Keyboard input remains app-scoped because macOS delivers it to the app's current focus.
|
||||
|
||||
The operation schema owns shared data declarations. Host-only limits and launch continuations remain separate from model parameters, and generated native field/enum tables preserve independent native admission without hand-maintained copies. Native code continues to own system calls, permissions and cancellation. AX formatting consumes immutable observations; the walker retains sole ownership of indices and AX references.
|
||||
|
||||
Clipboard paste is distinct from Unicode key input: Return and Tab key events can trigger actions instead of inserting clipboard content. Pasteboard access runs on the AppKit main actor and retains promised data until a reader requests it. AX command completion alone does not establish that the application has fetched the data. Restoration checks ownership before replacing contents; the caller still verifies the application result. After successful command dispatch, `pasteReadTimeoutMs` bounds waiting for a data request, with a default of 1000 ms. Expiry returns `PASTE_NOT_CONSUMED`, closes the data provider against late delivery, and restores the clipboard only while still owned. The failure reports no read within that interval; it does not prove the application will never process its queued Paste command. A delayed command may read restored clipboard contents, so callers inspect the target and never retry automatically. Foreground-dependent consumption was observed in the installed ChatGPT/Codex application (`com.openai.codex`); other applications need their own validation. Guidance presents this as an example and applies recovery instructions to any `PASTE_NOT_CONSUMED` result.
|
||||
|
||||
CUA registers its own Harness tool and returns operation values directly. Screenshot capture requires a resolved image-capable model and the shared attachment service. The tool admits each screenshot once and renders its durable image reference; the object API, combined observation and image sink retain the same `CuaScreenshot` containing encoded `bytes`, `mimeType`, `pixelWidth`, `pixelHeight` and `windowId` from the validated screenshot result. The bridge preserves this metadata without another upload or image emission. Callers use the reported format and dimensions when saving or declaring images.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Depending on the complete upstream driver would simplify upgrades but does not meet the requirement for locally owned implementation. Copying browser and other platform code would add unsupported scope. The JSON operation protocol allows another native implementation without changing object consumers.
|
||||
|
||||
A CUA-specific persistent evaluator duplicates execution lifecycle policy and prevents independent iteration on PTC. Requiring callers to manage raw app and snapshot tokens alone loses reusable object semantics. The facade and small tool adapter provide those semantics while leaving the evaluator outside this package.
|
||||
|
||||
Reusing the MCP result adapter would impose MCP envelopes and wire validation on native CUA operations. Direct Harness tool output and shared attachment admission provide the needed logging and images without a second protocol.
|
||||
|
||||
**Fixing the screenshot format** would require otherwise unnecessary re-encoding and discard the attachment pipeline’s pass-through optimization. A format convention alone still forces callers to guess, and cannot tell them when encoding policy changes. Returning the already computed format and dimensions preserves pass-through and makes each result describe its actual bytes.
|
||||
|
||||
**Waiting until the full native-operation timeout** hides successful command dispatch behind a generic timeout. A separate configurable read budget reports the missing acknowledgement without treating slow applications as unsupported. Requiring every application to be foreground would claim a prerequisite that the available observations do not establish.
|
||||
|
||||
## Consequences
|
||||
|
||||
The package remains opt-in and macOS-only. Observations do not lock the desktop, and input success records dispatch rather than task completion. Callers must verify effects and must not retry uncertain input automatically. Native fixture tests verify actual window state separately from protocol and model-transcript tests. Persistent execution, binary distribution, permission onboarding and real-model task success remain separately owned work.
|
||||
|
||||
Source builds retain a content-addressed executable per compiled generation. The [shared helper decision](2026-10-09-cua-shared-native-helper.md) owns capture-client identity and cross-Host lifetime. Retained source generations avoid replacing a live executable, while the package payload remains separately published for distribution.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:独立于执行器管理的原生 CUA 对象
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-experimental-cua-object-api.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Computer-use 程序需要一致的应用和窗口操作。将这些操作与 CUA 专用解释器绑定,会使输入处理依赖变量持久化、导入和取消等独立设计。
|
||||
|
||||
## 决策
|
||||
|
||||
共享的 `cua` 库负责应用与窗口对象、授权和已记录的派发。`cua-macos` 负责 Swift helper、构建和本地连接生命周期。提供方将准备好的可执行文件和连接工厂交给共享安装函数,使对象调用方无需包含平台代码,也无需增加另一套运行时注册表。
|
||||
|
||||
实验 CUA 包提供 Codex 风格的原生应用对象接口,并在 Swift 辅助进程中改写 Cua Driver 中选取的 MIT 许可 macOS 行为。源码清单记录改写的上游文件和本地实现。辅助进程在本地编译,本包不依赖外部 Cua Driver 运行时。已有的 [provider 注册机制](2026-09-12-computer-use-provider-registration.zh.md)防止同一组合中出现竞争 provider。
|
||||
|
||||
对象接口接收操作分派器。工具适配模块每次操作都获取当前工具绑定,保留标准授权、日志和图片接入。执行宿主负责变量持久化和最终表达式值的序列化。CUA 不定义持久化指令、执行 worker 或 run_code 替代实现。
|
||||
|
||||
原生进程归属遵循[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md),Agent 连接保持隔离。应用句柄绑定进程身份及内核启动时间,替换进程或辅助进程不会使旧句柄重新有效。AX 快照按应用和可选窗口保存,不同窗口的观察可以分别使用。坐标操作选择属于该应用的窗口;Host 按[截图坐标规则](2026-10-13-cua-hover-routes-and-coordinates.zh.md)换算为原生窗口内 point。键盘输入保持应用范围,因为 macOS 将其送往应用的当前焦点。
|
||||
|
||||
操作 schema 负责共享数据声明。Host 专属限制和启动续接与模型参数分开,生成的原生字段、枚举表保留独立原生校验,同时避免手工维护副本。系统调用、权限和取消仍由原生代码负责。AX 格式化只消费不可变观察数据,遍历器独自负责索引与 AX 引用。
|
||||
|
||||
剪贴板粘贴与 Unicode 按键输入分别实现,因为 Return 和 Tab 按键可能触发动作,而不是插入剪贴板内容。剪贴板访问使用 AppKit 主线程,保留延迟提供的数据直到读取方请求。AX 命令完成本身不能证明应用已取得数据。恢复前检查剪贴板归属;调用方仍需验证应用中的结果。 命令成功分派后,`pasteReadTimeoutMs` 限制等待数据请求的时间,默认 1000 毫秒。到期返回 `PASTE_NOT_CONSUMED`,关闭数据提供方以阻止迟到的数据交付,并仅在仍持有剪贴板时恢复原内容。该失败只说明这段时间内没有读取,不证明应用以后不会处理已排队的 Paste 命令。迟到的命令可能读取恢复后的剪贴板内容,因此调用方必须检查目标,不能自动重试。已安装的 ChatGPT/Codex 应用(`com.openai.codex`)已观察到依赖前台才能读取的行为,其他应用需要分别验证。指引将其写为例子,并对任何 `PASTE_NOT_CONSUMED` 结果提供恢复步骤。
|
||||
|
||||
CUA 注册自己的 Harness 工具,直接返回操作结果。截图要求解析到支持图片输入的模型,并提供共享附件服务。工具只接入一次截图,再渲染持久图片引用;对象 API、组合观察和图片输出回调保留同一个 `CuaScreenshot`,其中的编码 `bytes`、`mimeType`、`pixelWidth`、`pixelHeight` 和 `windowId` 来自已校验的截图结果。桥接保留这些元数据,不再次上传或输出图片。调用方落盘或声明图片时使用返回的格式和尺寸。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
依赖完整上游驱动可以简化升级,但不符合本地维护实现的要求。复制浏览器及其他平台代码会引入尚不支持的范围。JSON 操作协议允许替换原生实现,而不改变对象调用方。
|
||||
|
||||
CUA 专用持久执行器会重复实现执行生命周期规则,使 PTC 无法独立演进。仅让调用方管理原始应用和快照 token,又会失去可复用的对象语义。对象接口和轻量工具适配模块提供这些语义,并将执行器留在本包之外。
|
||||
|
||||
复用 MCP 结果适配器会给原生 CUA 操作引入 MCP 信封和协议校验。直接使用 Harness 工具输出与共享附件接入,即可取得所需的日志和图片能力,无需再经过一种协议。
|
||||
|
||||
**固定截图格式**会要求原本不需要的重新编码,并失去附件处理的原样直通优化。仅约定格式仍要求调用方猜测,也无法告知编码策略的变化。返回已经算出的格式和尺寸,既保留直通优化,也让每个结果说明其实际字节。
|
||||
|
||||
**等到原生操作的总超时**会用一条通用超时掩盖命令已经成功分派的事实。独立、可配置的读取等待上限能报告缺失的读取确认,而不将响应慢的应用认定为不支持粘贴。要求所有应用都必须位于前台,则超出了现有观察能支持的结论。
|
||||
|
||||
## 后果
|
||||
|
||||
本包需要显式启用,且只提供 macOS 实现。观察不会锁定桌面,输入成功只记录分派完成,不能证明任务完成。调用方必须验证效果,不能自动重试结果不确定的输入。原生测试通过实际窗口状态验证效果,与协议和模型消息测试分别进行。持久执行、二进制分发、权限引导及真实模型任务成功率是分别负责的工作。
|
||||
|
||||
源码构建按编译产物的内容摘要保留可执行文件。[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md)负责捕获客户端身份与跨 Host 生命期。源码各代副本避免替换运行中的可执行文件,包内产物仍单独发布。
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 943208904785f7eb
|
||||
zh: bda1969132a16101
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/terms:
|
||||
en: 401703adbd9cc1c2
|
||||
zh: 56d960c0b6fdff57
|
||||
en: a22cd84756773075
|
||||
zh: 49efa0c5a37d4b57
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/type-signatures:
|
||||
en: 5f9cd00890b6f4c3
|
||||
zh: 600ecba7e60b63f6
|
||||
@@ -42,8 +42,8 @@
|
||||
en: a7180d3c194cf9d9
|
||||
zh: 0dd7833e0e5d0a71
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/peerscope:
|
||||
en: 202aa6e8e8b04bd6
|
||||
zh: b1fc897c09074a7c
|
||||
en: 182634406ed2b25b
|
||||
zh: 0bd5900a39802224
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/client-face:
|
||||
en: 9f10347a73402b8b
|
||||
zh: b939c7a254a8e579
|
||||
@@ -72,11 +72,11 @@
|
||||
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: ec339e57dc2c0603
|
||||
zh: 3570be8c094ea24c
|
||||
en: df4ae60d8403cdbb
|
||||
zh: 70c6024a2a9a1e84
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/relation-to-events:
|
||||
en: a011793e5a4400ed
|
||||
zh: df8b20115f0348b8
|
||||
@@ -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: 41128746334320c7
|
||||
zh: 558a676340aae3a2
|
||||
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
|
||||
@@ -126,14 +126,14 @@
|
||||
en: b42ab0932f8b31b4
|
||||
zh: 7fed8cb5b6e36788
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/alternatives-considered:
|
||||
en: 04e72789417d6e3a
|
||||
zh: b57c5cc68dff45a5
|
||||
en: b0046c9f7c12a876
|
||||
zh: f3c37b466746b0fd
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/consequences:
|
||||
en: 35326fed46e31d74
|
||||
zh: 6f47baa645a1e370
|
||||
en: dbc54ce699ef8e71
|
||||
zh: 83144f07ae8f8759
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/testing:
|
||||
en: 5c8188930b9d4fdb
|
||||
zh: 1d94e6563cedde13
|
||||
en: 06fbeed7b82ded32
|
||||
zh: 66accb4bd2bf64a2
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/deferred:
|
||||
en: 8368c592f30af97f
|
||||
zh: 2ff89acb7b3ed477
|
||||
|
||||
@@ -88,7 +88,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr
|
||||
| Half-close | One direction has ended while the other remains open |
|
||||
| Item | One business value in one direction; every item is validated by the codec independently |
|
||||
| inbox | The bounded uplink queue the Host keeps per logical stream |
|
||||
| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator |
|
||||
| Peer | The party one connection speaks for, one `PeerScope` carrying its principal; every browser connection shares `connection.browserPeer`, and in-process code creates its own through `connection.createPeer()` ([Peer principals](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)) |
|
||||
| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call |
|
||||
|
||||
### Type signatures
|
||||
@@ -135,7 +135,7 @@ export interface RemoteInvocation {
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
/** The Peer that initiated the call; every carrier names it explicitly. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
@@ -180,24 +180,30 @@ The mechanism relies on two existing Cordis behaviors:
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
/** Participation in forwarded interactions; independent of RBAC. */
|
||||
readonly interactions: 'answer' | 'none'
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`.
|
||||
`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal, interactions? })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks.
|
||||
|
||||
| Member | Semantics |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection |
|
||||
| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here |
|
||||
| `connection.browserPeer: PeerScope` | The Peer every authenticated browser connection shares; owner principal; lives and dies with the Connection |
|
||||
| `connection.admit(request): PeerAdmission` | Checks Host/Origin and signed browser or API Cookies; returns `{ rejection: 401 \| 403 }` or the credential's live Peer; connect publishes the API Peer and its lifecycle socket releases it |
|
||||
|
||||
The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears.
|
||||
The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. `InvokeRemoteRequest.peer` and the `peer` parameter of `RemoteStreamOpener` and `wireStream.open` are required, so every in-process caller names its Peer, and the Gateway creates none. Looking up a Peer by id and Peer open and close events wait until the first consumer that needs them appears.
|
||||
|
||||
The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`.
|
||||
The Peer carries its principal; what that principal may call is decided by the roles, bindings, and policies that [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) defines.
|
||||
|
||||
### Client face
|
||||
|
||||
@@ -303,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
|
||||
|
||||
@@ -312,7 +319,7 @@ Credit frames wait until a consumer that needs sustained high-volume uplink appe
|
||||
| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped |
|
||||
| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`; `rpc.open` passes it through |
|
||||
|
||||
An in-process carrier with no `peer` means the operator.
|
||||
An in-process carrier passes its Peer explicitly; the in-page Host of the worker tunnel passes `connection.browserPeer`.
|
||||
|
||||
### Relation to `$events`
|
||||
|
||||
@@ -326,7 +333,7 @@ An in-process carrier with no `peer` means the operator.
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
@@ -363,13 +370,13 @@ export interface InvokeRemoteRequest {
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** The Peer that initiated the call; required on every path. */
|
||||
readonly peer: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
@@ -386,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`:
|
||||
@@ -396,7 +405,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
request.peer,
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
@@ -446,8 +455,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed |
|
||||
| `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` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` 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)`; `operatorPeer()` |
|
||||
| `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` 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 |
|
||||
@@ -562,7 +571,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly.
|
||||
|
||||
**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices.
|
||||
**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. Method-level access control later moved to [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md), which supersedes this part.
|
||||
|
||||
**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature.
|
||||
|
||||
@@ -578,7 +587,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`.
|
||||
- **Web terminals use `attach`** for screen output and input commands, with provider acknowledgements and per-attachment writable state; their Client retains the current generation's handle and never replays input.
|
||||
- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README.
|
||||
- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`.
|
||||
- **Every browser connection shares `browserPeer`**, and `admit` is the only network admission point; in-process code creates further Peers with `connection.createPeer()`.
|
||||
- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
- **Upper-layer supervisors expose only the downlink**: `$stream`, snapshot and journal do not expose `send`; a domain that writes uplink retains its generated physical handle for that generation.
|
||||
- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary.
|
||||
@@ -591,7 +600,7 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
- typert: the analyzer's handling of `RemoteStream<Out, In>`, `RemoteStream<Out>`, `never`, and the existing `AsyncIterable<Out>`; decorator options; registry load-time validation.
|
||||
- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection.
|
||||
- connection: the three outcomes of `admit`, 401, 403, and `browserPeer`; `browserPeer` reaching handlers through the bridge and the shared handler; release with the Connection.
|
||||
- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle.
|
||||
- Existing stream and unary method tests pass without changed expectations.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose()
|
||||
| 半关闭 | 一个方向已结束而另一个方向仍开放 |
|
||||
| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 |
|
||||
| inbox | Host 侧每逻辑流一个的有界上行队列 |
|
||||
| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer |
|
||||
| Peer | 一条连接代表的一方,一个携带 principal 的 `PeerScope`;所有浏览器连接共享 `connection.browserPeer`,进程内代码经 `connection.createPeer()` 自建([Peer 身份属性](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)) |
|
||||
| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 |
|
||||
|
||||
### 类型签名
|
||||
@@ -135,7 +135,7 @@ export interface RemoteInvocation {
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
/** The Peer that initiated the call; every carrier names it explicitly. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
@@ -180,24 +180,30 @@ declare module '@deepseek-ai/cordis' {
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
/** Participation in forwarded interactions; independent of RBAC. */
|
||||
readonly interactions: 'answer' | 'none'
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。
|
||||
`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal, interactions? })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
|
||||
|
||||
| 成员 | 语义 |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 |
|
||||
| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 |
|
||||
| `connection.browserPeer: PeerScope` | 所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 |
|
||||
| `connection.admit(request): PeerAdmission` | 检查 Host/Origin 与浏览器或 API 签名 Cookie;返回 `{ rejection: 401 \| 403 }` 或凭据对应的存活 Peer;API Peer 在 connect 时发布,并由其生命周期 socket 负责释放 |
|
||||
|
||||
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。
|
||||
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer`、`RemoteStreamOpener` 与 `wireStream.open` 的 `peer` 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。
|
||||
|
||||
Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。
|
||||
Peer 携带自己的 principal;该 principal 能调用什么,由 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 定义的 role、binding 与 policy 判定。
|
||||
|
||||
### Client 面
|
||||
|
||||
@@ -303,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 载体
|
||||
|
||||
@@ -312,7 +319,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox
|
||||
| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 |
|
||||
| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`;`rpc.open` 透传 |
|
||||
|
||||
进程内载体的 `peer` 缺席即操作者。
|
||||
进程内载体显式传入 Peer;worker 隧道的页内 Host 传 `connection.browserPeer`。
|
||||
|
||||
### 与 `$events` 的关系
|
||||
|
||||
@@ -326,7 +333,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
@@ -363,13 +370,13 @@ export interface InvokeRemoteRequest {
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** The Peer that initiated the call; required on every path. */
|
||||
readonly peer: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
@@ -386,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`:
|
||||
@@ -396,7 +405,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
request.peer,
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
@@ -446,8 +455,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor |
|
||||
| `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`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `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)`;`operatorPeer()` |
|
||||
| `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` 与 `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 目录 |
|
||||
@@ -562,7 +571,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。
|
||||
|
||||
**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。
|
||||
**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。方法级访问控制后来归入 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md),该部分由其 supersede。
|
||||
|
||||
**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。
|
||||
|
||||
@@ -578,7 +587,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。
|
||||
- **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。
|
||||
- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。
|
||||
- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。
|
||||
- **所有浏览器连接共享 `browserPeer`**,`admit` 是唯一的网络接纳点;进程内代码经 `connection.createPeer()` 创建其他 Peer。
|
||||
- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。
|
||||
- **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。
|
||||
- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。
|
||||
@@ -591,7 +600,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
- typert:analyzer 对 `RemoteStream<Out, In>`、`RemoteStream<Out>`、`never` 与既有 `AsyncIterable<Out>` 的处理;修饰器选项;registry 加载校验。
|
||||
- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。
|
||||
- connection:`admit` 的 401、403 与 `browserPeer` 三种结果;`browserPeer` 经桥与共享处理器到达;随 connection 释放。
|
||||
- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。
|
||||
- 既有 stream 与 unary 方法的测试不改期望值即通过。
|
||||
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: 6c8583bdefc5e36c
|
||||
zh: 0928b4f2876bbfe4
|
||||
en: 191ca8bffb101db2
|
||||
zh: 1365bef5cd830a5c
|
||||
/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
|
||||
+6
-2
@@ -10,14 +10,16 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
## Decision
|
||||
|
||||
`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.
|
||||
`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 [plugin manager page](../../../../packages/client/ui-plugin-manager/README.md#use-this-package) displays that metadata for eligible bundles and retains explicit activation for default-hidden ones. 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.
|
||||
+6
-2
@@ -10,14 +10,16 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 决策
|
||||
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;[插件管理页](../../../../packages/client/ui-plugin-manager/README.zh.md#use-this-package)为符合展示条件的组合包显示这些元数据,并保留默认隐藏项的显式启用入口。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 执行方式。
|
||||
+4
-4
@@ -15,8 +15,8 @@
|
||||
en: 5cfe619038339caa
|
||||
zh: c2d2651d304d867c
|
||||
/agent-note-preset-scoped-schedule-tools/consequences:
|
||||
en: 546d83d28487d1c1
|
||||
zh: 438922cd4f3ff9ec
|
||||
en: d7c7e6b8e6268559
|
||||
zh: 801e5be70e00cc90
|
||||
/agent-note-preset-scoped-schedule-tools/testing:
|
||||
en: 1c5f34cf30a24fd7
|
||||
zh: 6f7a372de52d0ecd
|
||||
en: d42cb866907a96b5
|
||||
zh: 6e66f8babe744bcb
|
||||
@@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md)
|
||||
- Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence.
|
||||
- A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management.
|
||||
- `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool.
|
||||
- A delegated child cannot use the four tools through either layer: the `tool-subagent` and `tool-subagent-fork` rows in `standard`, `cordis`, and `ptc` deny all four names through `toolFilter`, and the provider applies that filter in the child scope through `ctx.tools.restrict()`, so the tools leave the child's prompt; `subagentCallerRefusal` in `dsh-tool-schedule` independently returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from every one of the four tools when the caller's delegation depth is above zero, so a call that reaches a tool anyway is refused.
|
||||
- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution.
|
||||
- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its bash-only tool table. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets deny all four tool names.
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins.
|
||||
+2
-2
@@ -26,9 +26,9 @@ Status: implemented
|
||||
- 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。
|
||||
- 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。
|
||||
- `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。
|
||||
- 被委派的子 agent 无法通过两道中的任何一道使用这四个工具:`standard`、`cordis` 与 `ptc` 中的 `tool-subagent` 与 `tool-subagent-fork` 两行通过 `toolFilter` 拒绝全部四个名称,provider 会在子作用域内通过 `ctx.tools.restrict()` 应用该过滤,因此这些工具会从子 agent 的 prompt 中消失;`dsh-tool-schedule` 中的 `subagentCallerRefusal` 会在调用方委派深度大于零时,从四个工具中的每一个独立返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`,因此即使某个调用仍能到达工具,也会被拒绝。
|
||||
- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。
|
||||
- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其只含 `bash` 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都拒绝全部四个工具名。
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。
|
||||
@@ -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 时刻,这是本次未触及、仍然存在的时刻剖面副本。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-26-cua-application-consent.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-26-cua-application-consent.md
|
||||
/agent-note-application-consent-for-native-computer-use:
|
||||
en: d570ebf4790311f9
|
||||
zh: a6ec87cb3ae3d0f1
|
||||
/agent-note-application-consent-for-native-computer-use/problem:
|
||||
en: b32a7ce6483b9b48
|
||||
zh: ec0938ad032b99d8
|
||||
/agent-note-application-consent-for-native-computer-use/decision:
|
||||
en: 36c560c24c376f12
|
||||
zh: 5e4ff72f66574e38
|
||||
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
|
||||
en: 80cd5fd93a42d937
|
||||
zh: 2a0e241b1b8c2e1d
|
||||
/agent-note-application-consent-for-native-computer-use/consequences:
|
||||
en: 4bda3366af65250a
|
||||
zh: 7c83561b4db07284
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Application consent for native Computer Use
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-26-cua-application-consent.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
macOS privileges authorize an execution host, not a user's choice of target application. Retained app and window objects can outlive that choice. Concurrent permission decisions must not restore access after the user revokes it.
|
||||
|
||||
## 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. 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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; 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. 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: 原生 Computer Use 的应用授权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-26-cua-application-consent.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应用。保留的应用和窗口对象可能比用户的许可存活更久。并发授权决定不能在用户撤销后恢复访问。
|
||||
|
||||
## 决策
|
||||
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。
|
||||
|
||||
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
|
||||
|
||||
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得会话授权,许可绑定进程实例。显示名称不用于确定身份。
|
||||
|
||||
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
|
||||
|
||||
会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。
|
||||
|
||||
同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。
|
||||
|
||||
撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。
|
||||
|
||||
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
|
||||
|
||||
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。
|
||||
|
||||
**根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。
|
||||
|
||||
**把最近的 GUI 祖先当作授权对象。** 源码构建的 Desktop 可以位于 Terminal 和 helper 之间,但 macOS 仍将辅助功能和屏幕录制归属于 Terminal。责任进程记录提供系统认定的归属,仅凭父进程链无法确定。可选内部接口有兼容性风险,因此查询失败时省略应用信息,不再猜测。
|
||||
|
||||
**撤销后保留原生状态。** 原生应用查找可能返回旧句柄并保留窗口快照。关闭 Agent 连接可以通过现有传输清理释放其原生状态;保留其他应用的句柄则需要新增按应用销毁状态的原生协议。
|
||||
|
||||
## 影响
|
||||
|
||||
拒绝或撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;会话许可和长期许可仍可复用。其他会话使用独立连接,除非也持有被撤销应用的许可,否则不受影响。
|
||||
|
||||
显式启用可以启动共享 helper 进行只读权限检查。helper 在发布就绪状态、让启动器退出之前,记录 macOS 责任进程对应的应用,因此父进程改变后仍能提供应用名称和图标。可选的本机查询通过动态加载解析,无法取得归属时显示通用系统弹窗指引。授权归属仍以 macOS 提示为准。启用不会申请 macOS 授权或检查应用内容。
|
||||
|
||||
授权决定和失败结果由策略回归测试及 CUA 录制会话验证。桌面安装包的二进制分发、签名和打包宿主权限不属于源码启动流程。
|
||||
+12
-12
@@ -12,26 +12,26 @@
|
||||
en: a15c7c13f167e5ac
|
||||
zh: a15c7c13f167e5ac
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities:
|
||||
en: 3ffd0c78fa204b04
|
||||
zh: f468bab00a7ff357
|
||||
en: d53a850a2deabc08
|
||||
zh: 01c2f735f2a9562a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution:
|
||||
en: ca45b973e1968533
|
||||
zh: 002f23bb70e4fed7
|
||||
en: 42a3ed903fbb5483
|
||||
zh: 7e1f6df1137794c3
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager:
|
||||
en: 9b04926c175f22e8
|
||||
zh: fbf05dd4065b0738
|
||||
en: 7ec1ad2a691bbd12
|
||||
zh: 01057bbe0240056a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
|
||||
en: c7de8dafe47a5587
|
||||
zh: 6010a70d8451edd1
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work:
|
||||
en: 31818f8f7f80470b
|
||||
zh: ecb7ac85bb49cf6d
|
||||
en: 8b5faf72418a43dd
|
||||
zh: 3d676c9b54697a2a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered:
|
||||
en: 7c1c6d21570cda0d
|
||||
zh: e6f8334c85f1b404
|
||||
en: f04a27b3525180b0
|
||||
zh: 9f3a01d0400c5a88
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification:
|
||||
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
|
||||
+12
-14
@@ -17,9 +17,9 @@ Two pieces of Host state did not follow changes while the process ran:
|
||||
|
||||
| Owner | Does | Does not |
|
||||
|---|---|---|
|
||||
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache |
|
||||
| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches |
|
||||
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement |
|
||||
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names, and changing a retained profile entry's version in place | Touch any Node cache |
|
||||
| plugin-manager | Without HMR, calls `pluginPackages.refresh()` at fixed points of package operations; with HMR, leaves publication to its recomposition | Construct resolutions or manage module and package-configuration caches |
|
||||
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules; replaces plugins whose loaded package files changed during recomposition ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | Reload plugins for configuration-only manifests |
|
||||
|
||||
### Runtime resolution
|
||||
|
||||
@@ -29,20 +29,18 @@ Validation of a new generation in `replace()`:
|
||||
|
||||
- `scope: profile` entries may be removed.
|
||||
- Profile-local package names may be removed.
|
||||
- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory.
|
||||
- Retained profile entries keep their normalized directory and scope, but may change declaring anchor when another selected bundle supplies the same package, and may change version when the package is upgraded in place ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry. A published link name may select another real directory; Node's cached real paths of the previous one are relocated.
|
||||
|
||||
### Plugin Manager
|
||||
|
||||
| Operation | Publication point |
|
||||
|---|---|
|
||||
| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled |
|
||||
| Overwrite an installed package | No publication; the result is `restart-required` |
|
||||
| Enable a bundle | After the selection is saved, before reconciliation |
|
||||
| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table |
|
||||
| Remove | After pnpm remove succeeds, inside the HMR transaction |
|
||||
| Failed or cancelled install, failed removal | No publication |
|
||||
| Install, enable, disable or remove with HMR | Inside HMR's recomposition, after the rows the generation drops stopped and before the generation applies ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) |
|
||||
| Install a new package or enable a bundle without HMR | After the profile files are saved, before reconciliation |
|
||||
| Remove without HMR | After pnpm remove succeeds |
|
||||
| Disable without HMR, an install over an installed package, a failed or cancelled install, a failed removal | No publication; an install over a package the spec names is refused before pnpm runs |
|
||||
|
||||
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw.
|
||||
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. app-boot's `recomposeProfile()` first stops the rows the generation no longer lists, so their plugins dispose while their packages are still mapped, then publishes the latest generation once; a table that is already published is not published again.
|
||||
|
||||
Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally.
|
||||
|
||||
@@ -79,7 +77,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
|
||||
|
||||
### Future Work
|
||||
|
||||
- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly.
|
||||
- A retained profile mapping that moves to another directory waits for a restart; Plugin Manager moves a package by removing and installing it. In-place replacement inside `node_modules` is handled by the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md).
|
||||
- TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization.
|
||||
- CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior.
|
||||
- Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred.
|
||||
@@ -90,7 +88,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
|
||||
|
||||
**Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR.
|
||||
|
||||
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart.
|
||||
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md) adds it at profile recomposition.
|
||||
|
||||
**Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads.
|
||||
|
||||
@@ -115,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.
|
||||
+12
-14
@@ -17,9 +17,9 @@ Host 运行期间有两处状态没有随变化更新:
|
||||
|
||||
| 负责方 | 做什么 | 不做什么 |
|
||||
|---|---|---|
|
||||
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 |
|
||||
| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 |
|
||||
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 |
|
||||
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名,并允许原地更改保留的 profile 条目的版本 | 不碰 Node 的任何缓存 |
|
||||
| plugin-manager | 没有 HMR 时,在包操作的确定时点调用 `pluginPackages.refresh()`;有 HMR 时,把发布交给其重新组合 | 不构造 resolution;不管模块缓存与包配置缓存 |
|
||||
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载;重新组合时替换已加载包文件发生变化的插件([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | 不因仅作为配置的 manifest 重载插件 |
|
||||
|
||||
### runtime resolution
|
||||
|
||||
@@ -29,20 +29,18 @@ Host 运行期间有两处状态没有随变化更新:
|
||||
|
||||
- `scope: profile` 的条目可以移除。
|
||||
- profile 本地包名可以移除。
|
||||
- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。
|
||||
- 保留的 profile 条目保持规范化目录与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点;包被原地升级时,可以更改版本([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目。已发布的 link 名称可以选中其他真实目录;Node 缓存的原目录真实路径会被重新定位。
|
||||
|
||||
### plugin-manager
|
||||
|
||||
| 操作 | 发布时点 |
|
||||
|---|---|
|
||||
| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 |
|
||||
| 覆盖已安装的包 | 不发布,结果为 `restart-required` |
|
||||
| 启用 bundle | 保存选择后发布,再做 reconciliation |
|
||||
| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 |
|
||||
| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 |
|
||||
| 安装失败、取消、卸载失败 | 不发布 |
|
||||
| 有 HMR 时的安装、启用、停用或卸载 | 在 HMR 的重新组合内,新一代丢弃的行停止之后、新一代应用之前([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) |
|
||||
| 没有 HMR 时安装新包或启用 bundle | 保存 profile 文件之后,reconciliation 之前 |
|
||||
| 没有 HMR 时卸载 | pnpm remove 成功后 |
|
||||
| 没有 HMR 时停用、覆盖已安装的包、安装失败或取消、卸载失败 | 不发布;spec 指明已安装的包时,覆盖安装在 pnpm 运行之前被拒绝 |
|
||||
|
||||
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。
|
||||
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()`,再调用 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。app-boot 的 `recomposeProfile()` 先停止新一代不再列出的行,使其插件在包仍有映射时完成释放,再发布一次最新一代;已发布的表不会再次发布。
|
||||
|
||||
没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。
|
||||
|
||||
@@ -79,7 +77,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
|
||||
|
||||
### 后续工作
|
||||
|
||||
- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。
|
||||
- 移动到其他目录的保留 profile 映射需要等待重启;Plugin Manager 通过卸载再安装来移动包。`node_modules` 内的原地替换由[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)处理。
|
||||
- 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。
|
||||
- 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。
|
||||
- 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。
|
||||
@@ -90,7 +88,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
|
||||
|
||||
**在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。
|
||||
|
||||
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
|
||||
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做;[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)在 profile 重新组合时加入了这一能力。
|
||||
|
||||
**package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。
|
||||
|
||||
@@ -115,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 升级时需要重跑这些测试。
|
||||
+4
-4
@@ -9,14 +9,14 @@
|
||||
en: 6aa396e12264ee48
|
||||
zh: 56849a18acaf830a
|
||||
/agent-note-initial-draft-content-and-structured-reference-restoration/decision:
|
||||
en: 9cf2afd9d5e663e9
|
||||
zh: ad59b1bec6ec4666
|
||||
en: 1a370f23ee32b6a4
|
||||
zh: fb969ec929a6c7c9
|
||||
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model:
|
||||
en: 7f33895364a9f5cc
|
||||
zh: f1c79ea15f5c151e
|
||||
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods:
|
||||
en: ca81423a57f898a0
|
||||
zh: 98962cc1c6bce113
|
||||
en: e57394545d9d93ff
|
||||
zh: 5ab142e661750b82
|
||||
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow:
|
||||
en: dd57e639e9328f1d
|
||||
zh: 5e16cb2e3a73597c
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ New-task entry points need to place unsent content in the composer. A draft cont
|
||||
|
||||
Keep the existing `SessionInputShell`, `DraftEditorRuntime`, Conversation store, and Session reference lifetime. Plain text and structured drafts enter through the input model. Initial import finishes before `InputHub.shellFor()` returns, without React effects restoring text. Existing input objects are not restored again. React mounts the same editor and binds the existing persistence callback.
|
||||
|
||||
The second parameter of `startSession(workspaceId?, options?)` contains `prompt` and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. Without clearing, existing content is preserved and only an empty draft adopts the prompt. Explicit clearing replaces the target Session's text document, without deleting attachments or changing other Sessions. Prefilling does not send, execute commands, or change `blank`.
|
||||
The second parameter of `startSession(workspaceId?, options?)` contains `prompt`, `whenDraftExists`, and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. An empty draft adopts the prompt. When the target already has text or attachments, `whenDraftExists` decides: `keep` (the default) preserves it, `replace` replaces the target Session's text document with the prompt or clears it without one, and `append` adds the prompt as new paragraphs after the existing text and references. Neither deletes attachments or changes other Sessions. `clearPreviousDraft: true` acts as `replace` only when `whenDraftExists` is omitted. Prefilling does not send, execute commands, or change `blank`.
|
||||
|
||||
### Data model
|
||||
|
||||
@@ -41,7 +41,7 @@ The existing `dsh.conversation.<sessionId>` key remains. Readers accept both leg
|
||||
|
||||
There is no new draft service, storage key, general task framework, or Session creation entry point. SlotRenderer, Host Session logs, command execution, and file-discovery RPCs are unchanged. `InputActions` remains the shared input-action entry; components receive no separate content-initialization callback.
|
||||
|
||||
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`, whose fields are `prompt?: string` and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
|
||||
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` extends `DraftInitializationOptions`, whose fields are `prompt?: string`, `whenDraftExists?: 'replace' | 'append' | 'keep'`, and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
|
||||
|
||||
### Data flow
|
||||
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。
|
||||
|
||||
`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。
|
||||
`startSession(workspaceId?, options?)` 的第二参数包含 `prompt`、`whenDraftExists` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。空稿直接采用 prompt。目标已有文字或附件时由 `whenDraftExists` 决定:`keep`(默认)保留原稿,`replace` 用 prompt 替换目标 Session 的文字文档、无 prompt 时清空,`append` 在原有文字与引用之后另起段落追加 prompt。三者都不删除附件,也不修改其他 Session。只有未传 `whenDraftExists` 时,`clearPreviousDraft: true` 才等同 `replace`。预填不自动发送、执行命令或改变 `blank`。
|
||||
|
||||
### 数据模型
|
||||
|
||||
@@ -41,7 +41,7 @@ Status: implemented
|
||||
|
||||
不新增草稿服务、存储 key、通用任务框架或另一套 Session 创建入口;不改 SlotRenderer、Host Session 日志、命令执行和文件发现 RPC。`InputActions` 保留统一输入操作入口,组件不单独注入另一条内容初始化回调。
|
||||
|
||||
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`,字段为 `prompt?: string` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
|
||||
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 扩展 `DraftInitializationOptions`,字段为 `prompt?: string`、`whenDraftExists?: 'replace' | 'append' | 'keep'` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
|
||||
|
||||
### 数据流
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for 2026-10-01-replace-changed-package-modules-on-recomposition.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-01-replace-changed-package-modules-on-recomposition.md
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition:
|
||||
en: 72fe06d4e304418d
|
||||
zh: bc3c96e957889bc1
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/problem:
|
||||
en: c43fb0d2df54b28f
|
||||
zh: 6636548449663fc5
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: a15c7c13f167e5ac
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/one-recomposition-path:
|
||||
en: f2874c3fc946cf7a
|
||||
zh: 4c32b9f94e1f9709
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/detecting-changed-modules:
|
||||
en: 1fedf2dde87f41bf
|
||||
zh: c2249267d1c2bc78
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/replacing-them:
|
||||
en: 6fe2cbe5aa3a9786
|
||||
zh: fe30fbf672ea8c21
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/plugin-manager-and-runtime-resolution:
|
||||
en: 794ed33204d91014
|
||||
zh: b08daa2a43cbe3b3
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/alternatives-considered:
|
||||
en: a67d0c6bc4957060
|
||||
zh: 32e02ab45b8e71cc
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/consequences:
|
||||
en: 2cb3f39c6a910685
|
||||
zh: e14938c868390f39
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/verification:
|
||||
en: d4750abb810faac2
|
||||
zh: 78c9c4d86d719b9b
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Agent Note: Replace plugins whose loaded package files changed during profile recomposition
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Node caches an evaluated module by file URL. A profile installs packages with pnpm's hoisted layout, so a package lives at `node_modules/<name>` whatever its version. Overwriting, upgrading or reinstalling it keeps every file URL, and a later import or Loader entry restart returns the cached module of the previous version. The [package-refresh Note](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) refreshes runtime resolution and package configuration but leaves this module cache; Plugin Manager therefore reported overwrites as `restart-required`, and a reinstall at the same path after removal reported `applied` while the previous version kept running. A `link:` development package hits the same cache whenever its build output changes, unless its directory is listed in HMR's source watch roots.
|
||||
|
||||
## Decision
|
||||
|
||||
### One recomposition path
|
||||
|
||||
`Hmr.reconcileProfile()` is the profile recomposition used by HMR's profile watches and by Plugin Manager; the Config Editor still recomposes through app-boot directly. It runs inside `runExclusive()`. Plugin Manager only edits profile files and runs pnpm, and refuses to install over an installed package it can name before pnpm runs; another version is a removal and an installation, each with its own recomposition. The steps of a recomposition are:
|
||||
|
||||
1. Read the profile patches and compare loaded modules below the profile directory and its linked package targets with disk.
|
||||
2. Plan the replacement as below; a patch read or comparison failure throws or is reported before any module changes.
|
||||
3. Evict the replaceable changed modules and their importers. A changed module whose importers reach the process entry point's static graph or a plugin that no Loader entry owns stays loaded with its importers; the other changes proceed.
|
||||
4. Stop the Loader entries running the replaced plugins by deleting their runtimes, so the Loader does not record them as disabled.
|
||||
5. Call app-boot's `recomposeProfile()` once with the entries that ran before the stops. It records the activation state, stops the rows the generation no longer lists by removing them from their groups, publishes the runtime resolution, runs the compatibility preflight against the published packages, and applies the generation. The Loader imports the current files when it starts an entry: rows the generation keeps start once with the new code and their new configuration; rows it disables, refuses or removes stay stopped and never run the new code; an entry it inserts links against the current files. Stopped entries the update did not try to start, below an unchanged Include or outside the profile composition, start afterwards; an entry whose import failed during the update is imported once more. Activation is judged: a stopped entry that failed afterwards fails the operation, while an entry waiting for a service is a warning, since it starts once a provider appears. If no generation applied, the published resolution and the stopped rows are restored, and the stopped entries start again.
|
||||
6. Evict the loaded modules below the directories of the mappings the recomposition dropped, so a removed or deselected package, including its dynamically imported dependencies, is imported afresh when it returns. A bundle switched off while its package stays mapped keeps its unchanged modules.
|
||||
|
||||
### Detecting changed modules
|
||||
|
||||
[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) records inode, size and modification time for the loaded ES and CommonJS modules below the profile directory and the real directories of its linked packages. Modules of the dsh installation are outside this scope. Change time is excluded: pnpm imports packages by hard link from its store, and adding a link elsewhere changes a loaded file's change time without changing its content. Records are taken at application readiness, after each recomposition, and for modules a source reload evaluated again. A recomposition records the identities it read before replacing anything, so a write during the recomposition stays detectable; modules first loaded during it are recorded after settlement to avoid an unnecessary restart during the next installation. An edit between first evaluation and that record can be missed; the timestamp fallback still covers modules first loaded outside recomposition. If the readiness observation fails, the fallback starts at HMR construction time, not the Unix epoch. A module is stale when its file disappeared or its identity differs. A module first loaded after the last recording has no record; it is stale when its file was modified at or after that recording, less a 2-second filesystem timestamp resolution, so a reinstall between loading and the next comparison is not missed. Version numbers are not consulted: lockfiles record no content identity for `file:` and `link:` dependencies, and file identity covers registry, tarball, directory and link installs alike.
|
||||
|
||||
### Replacing them
|
||||
|
||||
A stale module whose importer closure reaches the process entry point's static graph, including installed packages, stays loaded and is reported. The remaining stale modules and their static importers, across ES module links and CommonJS `require` children, form the affected set, and every manifest from a stale file up to its profile root or linked target expires through `PackageManifests.invalidate()`. The whole affected set is evicted without a backup before recomposition, so every entry that imports during recomposition reaches the current files. A plugin registered outside a Loader entry has no owner that can start its replacement, so the changes its modules import stay pending; plugins are read from the ES module namespace and from cached CommonJS exports, so a CommonJS plugin registered through `require` keeps its cache too. HMR imports no replacement code itself, so admission is the Loader's ordinary activation: a disabled, removed or refused row never evaluates the new module, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. Every recomposition imports again each enabled entry whose last import failed, after expiring the package configuration below the profile roots and evicting the modules whose links or evaluation failed, which Node keeps cached, so a corrected file or a later-installed dependency takes effect without a restart. A plugin counts as owned only when an affected plugin's own Loader entry fiber starts it; a changed plugin another plugin registers through a dynamic import stays pending. Unchanged dependencies of a replaced plugin are not re-evaluated, so shared libraries keep one instance. Source reloads through `partialReload()` keep their own in-place replacement with rollback.
|
||||
|
||||
`reconcileProfile()` returns `pending`, each stale module that loads only after a restart with its reason (`host-module`, `unmanaged-plugin`, or `module-comparison` when planning failed). Pending modules stay stale, including modules first loaded after the last recording, so the next recomposition retries them. A module first loaded during a recomposition whose file was modified after the comparison also stays stale: activation may have evaluated it before that write. A failure while listing or comparing modules keeps the previous records and sets the profile-wide `restartReason: module-comparison`, even without known module URLs. Neither prevents recomposition. An activation failure, including a replacement that fails to import, is thrown with the Loader's import error; an unchanged failure is returned as a warning by later recompositions. Plugin Manager reports a thrown failure as `failed` with that diagnostic, and turns `pending` into `restart-required` with its `restartReason`, unless the operation reports `overridden`. The Web page localizes the reason, and the agent tool derives a `nextStep` sentence from it, so no surface parses a diagnostic string.
|
||||
|
||||
The [packed worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) stores evaluated modules in a private map and its `require` exposes no cache. HMR cannot replace them through Node APIs and reports a restart requirement. Native Node still exposes its caches when using asynchronous module hooks.
|
||||
|
||||
### Plugin Manager and runtime resolution
|
||||
|
||||
Plugin Manager refuses to install over an installed package it can name before pnpm runs, so a new version or location is a removal and an installation: the removal stops the plugins, evicts their modules and drops the mappings, and the installation maps the package afresh. Runtime resolution accepts a retained profile mapping whose version changes while its normalized directory and scope stay, as a shared dependency upgraded by another installation does, and rejects one that moved to another directory. A linked root may point to another directory; a file Node's cached link real path selects below the previous target, for an importer outside it, is relocated below the current one. Rejected publications, including changes to installation mappings or local overrides, leave the resolution and the composition unchanged and report `restart-required` with reason `package-moved`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate package-replacement call from Plugin Manager.** Plugin Manager knows which package it installed, but not which Loader entries import it, and row additions, removals and configuration changes in the new bundle patch must apply in the same step. A second operation would split one profile change into two transactions with an undefined order. Recomposition already reads the new profile; comparing loaded files there covers Plugin Manager, CLI installs followed by a recomposition, and link builds.
|
||||
|
||||
**Compare package versions from the profile manifest or lockfile.** The profile manifest stores specs, not resolved versions, and `pnpm update` can change a version without changing it. Lockfiles carry no content identity for `file:` and `link:` dependencies, and a same-version reinstall of a tarball changes content without changing the version.
|
||||
|
||||
**Let app-boot call HMR from inside its recomposition.** Hooks for the steps before applying and before judging would split the order of one operation between two owners. HMR instead stops everything it replaces first and passes the stopped entries to one app-boot call.
|
||||
|
||||
**Expose the recomposition's stages to HMR.** Separate calls to record activation, prepare after publication, apply, restore and drop mappings let HMR insert its stops between them, but they leave callers ordering obligations that the types cannot express. Stopping before the call costs a restart of the stopped plugins when preparation fails.
|
||||
|
||||
**Publish twice, keeping the dropped mappings while the generation applies.** A temporary table with both the new and the dropped mappings lets one Loader update stop and start entries, but it is a third generation that must stay recomputable when the update fails. Stopping the dropped rows first lets one publication follow them.
|
||||
|
||||
**Overwrite an installed bundle in one operation.** Stopping the bundle before pnpm and starting it afterwards, or detecting moved packages from the loaded module files, keeps one call for an upgrade, but needs a restart path for every pnpm failure, a selection restored in its order, and a release rule for moved directories. Refusing the overwrite leaves a removal and an installation, which the existing operations already run in this order.
|
||||
|
||||
**Preload the replacement and keep the previous plugin when it fails to load.** HMR would import new code before stopping the previous runtime and restore the previous caches on failure. Preloading must not evaluate a row the generation disables, removes or refuses, so HMR would decide admission before the Loader does; dynamic `disabled` expressions and nested Includes cannot be decided without activation, so those changes would need a restart. Rollback must also remove new importers of restored modules, and the previous code would still receive the new configuration. A restart would load the same broken files, so keeping the previous plugin only delays a failure that needs a fix anyway.
|
||||
|
||||
**Restart replaced plugins in place before recomposition.** New code would start with the previous configuration, which an upgraded version may reject, and a row the new composition disables or refuses, for example an incompatible version left on disk by a failed install, would briefly run.
|
||||
|
||||
**Replace running plugins after recomposition.** Recomposition would restart the previous code with the new configuration, which the previous version may reject, so a successful upgrade would be reported as a failure; an entry inserted during recomposition would also link against the previous copy of a changed dependency still cached for a running plugin.
|
||||
|
||||
**Stop affected plugins before pnpm runs.** This prevents previous code from lazily loading files pnpm has already replaced, but adds downtime for the whole install and holds the HMR queue during pnpm.
|
||||
|
||||
**Re-evaluate every dependency of a replaced plugin.** Source reloads reload a plugin's non-`node_modules` dependency tree. Doing the same below `node_modules` would create second instances of unchanged shared libraries and Cordis packages used by other plugins.
|
||||
|
||||
**Record identities in a module load hook.** `module.registerHooks()` could record each file at load time and close the window for modules loaded between recordings, but it adds a hook to every module load and interacts with TSX and the vendored Loader's internals. The timestamp rule only causes an extra reload in that window.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Upgrades through a removal and an installation, and packages whose files changed in place, take effect without a restart when the replacement loads and starts; a replacement that fails to load or start stops the previous plugin and reports `failed` with the error, and it starts once its files or dependencies are corrected.
|
||||
- Each recomposition stats every loaded file of the profile; the module graph is walked when a file changed, and the host graph is collected on first use.
|
||||
- Dynamic `import()` edges are not followed: an importer keeps running while its next dynamic import evaluates the new file. Existing Workers and the TSX loader thread keep their caches.
|
||||
- Replaced Loader entries stop before recomposition and start once afterwards; the previous version does not return.
|
||||
- Loader entries outside the profile composition restart with replaced code without a new compatibility check ([limitation](../../../../packages/boot/hmr/README.md#known-limitations-and-deferred-work)).
|
||||
- `ModuleFiles` belongs to one HMR instance. A recomposition that restarts the `hmr` entry itself starts a new instance whose first observation records the current files, so changes pending at that point are no longer reported or retried.
|
||||
- `pending` describes the whole profile, so a host module brought into scope by a linked workspace package makes later unrelated operations report `restart-required` until the process restarts.
|
||||
- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected.
|
||||
- A change confined to `package.json` is not detected. HMR's profile watches do not recompose for dependency-only manifest changes, so installs performed outside Plugin Manager wait for the next recomposition or restart.
|
||||
|
||||
## Verification
|
||||
|
||||
| Coverage | Location |
|
||||
|---|---|
|
||||
| In-place upgrades with bundle patch changes, configurations the previous version rejects, inserted entries and disabled rows, plugins without a Loader entry, shared-dependency importers, linked packages, hard links, CommonJS `require` children and their failures, failed replacement and retry, nested groups and Includes, dynamic disabling, same-path reinstall, late-loaded modules, host-graph modules, comparison and recomposition failures, eviction of packages a recomposition stopped using or whose mappings it dropped, and the restart of stopped entries after a rejected publication or failed preparation | `packages/boot/hmr/tests/package-reload.spec.ts` |
|
||||
| Refused overwrites, removal then installation with HMR, failed installation, relinked and link-to-registry reinstalls, unmanaged plugins, comparison failures | `packages/boot/plugin-manager/tests/package-reload.spec.ts`, `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| In-place profile mapping version change, retained directory and scope rejection including a relinked path inside the profiles tree, relinked roots and native lookups relocated below the current target | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
|
||||
Tests need no API key and make no model calls.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Agent Note: profile 重新组合时替换已加载包文件发生变化的插件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-01-replace-changed-package-modules-on-recomposition.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Node 按文件 URL 缓存已求值的模块。profile 使用 pnpm 的 hoisted 布局安装包,因此无论版本如何,包都位于 `node_modules/<name>`。覆盖、升级或重装包时所有文件 URL 保持不变,之后的导入或 Loader entry 重启返回的是旧版本的缓存模块。[包刷新说明](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)刷新 runtime resolution 和包配置,但不处理这一模块缓存;因此 Plugin Manager 把覆盖报告为 `restart-required`,而删除后在同一路径重装时报告 `applied`,旧版本却仍在运行。`link:` 开发包的构建输出变化时也会命中同一缓存,除非其目录列在 HMR 的源码监听根目录中。
|
||||
|
||||
## Decision
|
||||
|
||||
### 唯一的重新组合路径
|
||||
|
||||
`Hmr.reconcileProfile()` 是 HMR 的 profile 监听和 Plugin Manager 共用的 profile 重新组合入口;Config Editor 仍直接通过 app-boot 重新组合。它在 `runExclusive()` 内运行。Plugin Manager 只编辑 profile 文件并运行 pnpm;对于在运行 pnpm 之前能确定包名的已安装包,它拒绝覆盖安装;安装另一个版本需要先卸载再安装,每一步各有一次重新组合。一次重新组合的步骤如下:
|
||||
|
||||
1. 读取 profile patch,将 profile 目录及其已链接包目标之下的已加载模块与磁盘比较。
|
||||
2. 按下文规划替换;patch 读取或比较失败时,在任何模块变化之前抛出错误或报告失败。
|
||||
3. 逐出可替换的已变化模块及其导入方。若已变化模块的导入方可到达进程入口的静态依赖图,或可到达没有 Loader entry 拥有的插件,该模块连同其导入方保持加载;其余变化照常处理。
|
||||
4. 通过删除运行时停止运行被替换插件的 Loader entry,使 Loader 不把它们记录为已停用。
|
||||
5. 以停止之前正在运行的条目调用一次 app-boot 的 `recomposeProfile()`。它记录激活状态,把新一代组合不再列出的行从其所在组中移除,以停止这些行,发布 runtime resolution,基于已发布的包运行兼容性预检,然后应用新一代组合。Loader 在启动条目时导入当前文件:新一代组合保留的行以新代码和新配置启动一次;停用、拒绝或删除的行保持停止,不会运行新代码;新插入的条目链接当前文件。更新未尝试启动的已停止条目(位于未变化 Include 之下或 profile 组合之外)随后启动;更新期间导入失败的条目会再导入一次。随后判定激活结果:之后失败的已停止条目使操作失败;等待服务的条目只是警告,因为出现提供方后它即启动。若没有新一代组合被应用,已发布的 resolution 和已停止的行被恢复,已停止的条目重新启动。
|
||||
6. 逐出重新组合删除的映射所在目录之下的已加载模块,使已删除或取消选入的包连同其动态导入的依赖在重新加入时重新导入。关闭组合包而其包仍有映射时,它未变化的模块保持加载。
|
||||
|
||||
### 检测已变化的模块
|
||||
|
||||
[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) 为 profile 目录及其已链接包的真实目录之下已加载的 ES 和 CommonJS 模块记录 inode、大小和修改时间。dsh 安装目录中的模块不在此范围内。不记录状态变更时间:pnpm 通过硬链接从其 store 导入包,在其他位置添加链接会改变已加载文件的状态变更时间,而内容不变。记录发生在应用就绪时和每次重新组合之后;源码重载重新求值模块时,也为这些模块记录。重新组合记录的是它在替换任何模块之前读取的文件身份,因此重新组合期间发生的写入仍可被检测到;在重新组合期间首次加载的模块在结算后记录,避免下一次安装时发生不必要的重启。首次求值与记录之间的修改可能漏检;时间戳兜底仍覆盖重新组合之外首次加载的模块。就绪时的观察失败时,兜底从 HMR 构造时间开始,而非 Unix 纪元。文件已消失或身份不同的模块视为陈旧。在上次记录之后才首次加载的模块没有记录;若其文件的修改时间不早于该次记录时间减去 2 秒文件系统时间戳精度,则视为陈旧,因此不会漏掉在加载与下一次比较之间发生的重装。比较不参考版本号:lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而文件身份同样覆盖 registry、tarball、目录和 link 安装。
|
||||
|
||||
### 替换
|
||||
|
||||
导入方闭包可到达进程入口静态依赖图(包括已安装包)的陈旧模块保持加载,并被报告。其余陈旧模块及其静态导入方(沿 ES 模块链接和 CommonJS `require` 子模块)构成受影响集合,从每个陈旧文件到其 profile 根目录或链接目标的各级 manifest 都通过 `PackageManifests.invalidate()` 失效。整个受影响集合在重新组合之前被逐出且不保留备份,因此重新组合期间执行导入的每个条目都读取当前文件。在 Loader entry 之外注册的插件没有能启动其替换版本的所有者,因此其模块导入的变化保持待处理;插件从 ES 模块命名空间和已缓存的 CommonJS exports 中读取,因此通过 `require` 注册的 CommonJS 插件同样保留其缓存。HMR 本身不导入替换代码,因此是否允许加载由 Loader 的普通激活决定:停用、删除或被拒绝的行永远不会对新模块求值,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。每次重新组合都会先让 profile 根目录下的包配置失效,并逐出链接或求值失败的模块(Node 会在缓存中保留这些模块),再次导入上次导入失败的每个已启用条目,因此修正后的文件或之后安装的依赖无需重启即生效。只有当受影响插件自身的 Loader entry fiber 启动某个插件时,该插件才算有所有者;由另一个插件通过动态导入注册的已变化插件保持待处理。被替换插件中未变化的依赖不会重新求值,因此共享库保持单一实例。通过 `partialReload()` 进行的源码重载保留其自身带回滚的原地替换。
|
||||
|
||||
`reconcileProfile()` 返回 `pending`,即每个只有重启后才能加载的陈旧模块及其原因(`host-module`、`unmanaged-plugin`,规划失败时为 `module-comparison`)。待处理模块保持陈旧,包括在上次记录之后才首次加载的模块,因此下一次重新组合会重试它们。在重新组合期间首次加载、且其文件在比较之后被修改的模块同样保持陈旧:激活可能在该写入之前已对其求值。列出或比较模块时发生的失败会保留原有记录,并设置 profile 级 `restartReason: module-comparison`,即使无法确定模块 URL。两种情况都不会阻止重新组合。激活失败(包括替换版本导入失败)会连同 Loader 的导入错误一起抛出;未变化的失败在后续重新组合中作为警告返回。Plugin Manager 把抛出的失败报告为带该诊断的 `failed`,把 `pending` 转为带其 `restartReason` 的 `restart-required`,除非操作报告 `overridden`。Web 页面本地化该原因,agent 工具据此生成一句 `nextStep`,因此没有任何界面解析诊断字符串。
|
||||
|
||||
[打包 worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) 在私有映射中保存已求值模块,其 `require` 不暴露缓存。HMR 无法通过 Node API 替换它们,因此报告需要重启。原生 Node 使用异步模块 hook 时仍暴露其缓存。
|
||||
|
||||
### Plugin Manager 与 runtime resolution
|
||||
|
||||
对于在运行 pnpm 之前能确定包名的已安装包,Plugin Manager 拒绝覆盖安装,因此新版本或新位置需要先卸载再安装:卸载停止插件、逐出其模块并删除映射,安装再重新映射该包。runtime resolution 接受保留的 profile 映射在规范化目录和作用域不变时更改版本,即共享依赖被另一次安装升级的情形;移动到其他目录的映射被拒绝。linked root 可以指向其他目录;对于原目标之外的导入方,Node 缓存的链接真实路径若选中原目标之下的文件,该文件会被重新定位到当前目标之下。被拒绝的发布,包括对安装范围映射或本地覆盖的更改,不改变 resolution 和组合,并报告原因是 `package-moved` 的 `restart-required`。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**由 Plugin Manager 单独调用包替换。** Plugin Manager 知道自己安装了哪个包,但不知道哪些 Loader entry 导入了它;新组合包 patch 中的行增加、删除和配置变更也必须在同一步应用。第二个操作会把一次 profile 变更拆成两个顺序未定义的事务。重新组合本来就会读取新的 profile;在此处比较已加载文件,同时覆盖 Plugin Manager、之后执行重新组合的 CLI 安装以及 link 构建。
|
||||
|
||||
**比较 profile manifest 或 lockfile 中的包版本。** profile manifest 存储的是 spec,不是解析后的版本,`pnpm update` 可以在不修改 spec 的情况下改变版本。lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而同版本重装 tarball 会改变内容却不改变版本。
|
||||
|
||||
**让 app-boot 在其重新组合内部调用 HMR。** 为应用之前和判定之前的步骤提供 hook,会把一次操作的顺序拆分给两个所有者。HMR 改为先停止它替换的所有内容,再把已停止的条目传给一次 app-boot 调用。
|
||||
|
||||
**向 HMR 暴露重新组合的各个阶段。** 分别调用记录激活状态、发布后准备、应用、恢复和丢弃映射,可让 HMR 在其间插入停止操作,但会给调用方留下类型无法表达的顺序义务。在调用之前停止插件,代价是准备失败时需要重新启动已停止的插件。
|
||||
|
||||
**发布两次,在应用新一代组合期间保留被丢弃的映射。** 同时包含新映射和被丢弃映射的临时表,可以让一次 Loader 更新既停止又启动条目,但它是第三个 generation,更新失败时必须仍能重新计算。先停止被丢弃的行,之后只需发布一次。
|
||||
|
||||
**在一次操作中覆盖已安装的组合包。** 在 pnpm 运行之前停止该组合包、之后再启动,或根据已加载模块文件检测已移动的包,可以让升级只需一次调用;但这需要为每种 pnpm 失败提供重启路径、按原顺序恢复选择列表,并为已移动的目录制定释放规则。拒绝覆盖安装后只剩卸载和安装,现有操作已按此顺序执行。
|
||||
|
||||
**预加载替换版本,并在其加载失败时保留原插件。** HMR 将在停止旧运行时之前导入新代码,并在失败时恢复旧缓存。预加载不得对新一代组合停用、删除或拒绝的行求值,因此 HMR 要先于 Loader 决定是否允许加载;动态 `disabled` 表达式和嵌套 Include 不经激活就无法判定,因此这些变化需要重启。回滚还必须删除已恢复模块的新导入方,而旧代码仍会收到新配置。重启会加载相同的损坏文件,因此保留原插件只是推迟一个无论如何都需要修复的失败。
|
||||
|
||||
**在重新组合之前原地重启被替换的插件。** 新代码会先以旧配置启动,而升级后的版本可能拒绝旧配置;新组合停用或拒绝的行(例如安装失败后留在磁盘上的不兼容版本)也会短暂运行。
|
||||
|
||||
**在重新组合之后替换运行中的插件。** 重新组合会以新配置重启旧代码,而旧版本可能拒绝新配置,导致一次成功的升级被报告为失败;重新组合期间插入的条目也会链接到仍为运行中插件缓存的、已变化依赖的旧副本。
|
||||
|
||||
**在 pnpm 运行前停止受影响插件。** 这能防止旧代码惰性加载 pnpm 已经替换的文件,但会让整个安装期间插件不可用,并在 pnpm 运行期间占用 HMR 队列。
|
||||
|
||||
**重新求值被替换插件的全部依赖。** 源码重载会重载插件在 `node_modules` 之外的依赖树。若对 `node_modules` 之下做同样处理,会为其他插件使用的、未变化的共享库和 Cordis 包创建第二个实例。
|
||||
|
||||
**在模块加载 hook 中记录文件身份。** `module.registerHooks()` 可以在加载时记录每个文件,消除两次记录之间加载的模块的检测空窗,但这会给每次模块加载增加一个 hook,并与 TSX 和 vendored Loader 的内部机制相互影响。时间戳规则在该空窗内只会导致一次多余的重载。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 通过卸载再安装进行的升级,以及文件被原地更改的包,在替换版本成功加载并启动时无需重启即生效;替换版本加载或启动失败时,原插件停止并报告 `failed` 及该错误,其文件或依赖被修正后它即启动。
|
||||
- 每次重新组合对 profile 的所有已加载文件执行一次 stat;有文件变化时才遍历模块图,宿主依赖图在首次使用时收集。
|
||||
- 不跟踪动态 `import()` 边:导入方继续运行,而其下一次动态导入会求值新文件。已存在的 Worker 和 TSX loader 线程保留各自的缓存。
|
||||
- 被替换的 Loader entry 在重新组合之前停止,之后启动一次;旧版本不会恢复。
|
||||
- profile 组合之外的 Loader entry 以替换后的代码重启,不重新进行兼容性检查([限制](../../../../packages/boot/hmr/README.zh.md#known-limitations-and-deferred-work))。
|
||||
- `ModuleFiles` 属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会启动一个新实例,其首次观察记录当前文件,因此此时待处理的变化不再被报告或重试。
|
||||
- `pending` 描述整个 profile,因此由已链接工作区包纳入范围的宿主模块,会使后续无关操作也报告 `restart-required`,直到进程重启。
|
||||
- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。
|
||||
- 仅限于 `package.json` 的变化不会被检测到。HMR 的 profile 监听不会因仅涉及依赖的 manifest 变化而重新组合,因此在 Plugin Manager 之外执行的安装要等到下一次重新组合或重启。
|
||||
|
||||
## Verification
|
||||
|
||||
| 覆盖面 | 位置 |
|
||||
|---|---|
|
||||
| 伴随组合包 patch 变更的原地升级、旧版本拒绝的配置、新插入的条目和停用行、没有 Loader entry 的插件、共享依赖的导入方、已链接包、硬链接、CommonJS `require` 子模块及其失败、替换失败与重试、嵌套 group 和 Include、动态停用、同路径重装、延后加载的模块、宿主依赖图中的模块、比较失败与重新组合失败、逐出重新组合不再使用或已删除其映射的包,以及发布被拒绝或准备失败后已停止条目的重启 | `packages/boot/hmr/tests/package-reload.spec.ts` |
|
||||
| 被拒绝的覆盖安装、有 HMR 时先卸载再安装、安装失败、重新链接和从链接改为 registry 的重装、非托管插件、比较失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts`、`packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| profile 映射原地更改版本、保留目录和作用域的拒绝(包括 profiles 树内重新链接的路径)、重新链接的 root 以及被重新定位到当前目标之下的原生查询 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
|
||||
测试不需要 API key,不调用模型。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-09-composer-command-chip.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-09-composer-command-chip.md
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips:
|
||||
en: 66d4f149698760c8
|
||||
zh: de219f394dfd4a4c
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/problem:
|
||||
en: 831c97e0de131639
|
||||
zh: ea35b4b179ff33f0
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/decision:
|
||||
en: 6ac53f6cd1307897
|
||||
zh: 2f1dbe762b52f18e
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/alternatives-considered:
|
||||
en: 9c694a21237d7772
|
||||
zh: b35ba7bf8b73b679
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/consequences:
|
||||
en: 089365d6a4be0ebb
|
||||
zh: b30384c717a7ea1d
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Claimed commands as atomic composer chips
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-09-composer-command-chip.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [Lexical composer decision](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md) kept a claimed command as literal leading text. A TextNode transform styled the token and split typed overflow back out of it, and the submit machine held the claim while the draft string started with the token. Every edit at the token boundary ran through that transform. Lexical merges same-style neighbours before the overflow node's own transform runs, so the split and the merge looped until Lexical aborted the update with error #14 and the DOM diverged from the editor state (issue #4490). PR #5480 cleared the copied style for the reported gesture; pasting an image after a claim and IME composition reach the same transform and remained unverified. The plain-text reference entity also needed a precedence rule to leave the claimed token alone.
|
||||
|
||||
## Decision
|
||||
|
||||
A claim replaces the leading trigger span with `CommandChipNode`, an atomic inline `DecoratorNode`, followed by the token's argument separator as an ordinary text node.
|
||||
|
||||
- The node carries the `CommandClaim`, including its submit closure. History restores nodes with their fields, so undoing a chip deletion restores the claim. JSON export writes the token text; JSON import yields a plain text node because the closure has no JSON form.
|
||||
- The detect projection counts the chip as one U+FFFC, so trigger detection never re-reads the command name. The clipboard projection is the token without its separator. The chip is not a reference occurrence. `EditorProjection.command` is the claim of a chip that opens the document.
|
||||
- Outside a submit transaction, the machine's claim follows the document: `draft-changed` carries the leading chip's claim, so deleting or displacing the chip releases the claim and a history restore re-enters claimed. A failed command submission settles to the same rule, so it returns to claimed exactly when a chip still leads; a claim adjudicated from typed text at Enter has no chip and returns to plain.
|
||||
- Node transforms keep the chip on the leading seat. A chip with a preceding sibling or inside a later block becomes its literal token, which releases the claim as typing before the old token did. The chip's own transform sees sibling insertions and moves between blocks; a paragraph transform sees a block inserted before the chip's block, so no update walks the whole document.
|
||||
- Deletion takes two steps. A character deletion that reaches the chip from a collapsed caret marks it selected (`data-selected` on its host; the root's `data-composer-chip-selected` hides the caret), and the next deletion in the same direction removes it whole. Deletions are decided both on the character-deletion command and on deletion input events, because iOS applies a ranged `deleteContentBackward` without that command. A key whose dispatch produced no character deletion, a click, blur, or a caret that leaves the chip clears the mark, so platform deletion shortcuts such as macOS Ctrl+H keep it. The editor selection stays a collapsed range and `isKeyboardSelectable()` stays false for the reason recorded on reference chips. Escape still does not release a claim.
|
||||
- The persisted draft records the leading chip's claim token (`DraftSnapshot.command`), never the claim. On restore the input asks the trigger pipeline's `resolveClaim`, which polls the optional source hook `matchClaim`: it awaits the source's catalog and answers only a claim, never invoking or executing the command, unlike Enter's `matchEnter`. The chip is rebuilt only at the revision the restore produced and merged into the seed's history entry; an earlier edit, an unclaimed token, or a catalog failure keeps the literal token, which Enter still adjudicates.
|
||||
- `claim-decor.ts` and the text-reference precedence rule are deleted.
|
||||
|
||||
The `slash/input-*` events, `CommandClaim`, `InputState.claim`, and trigger sources keep their fields. `CommandClaim.token` still ends with the separator, and command arguments are still read from the clipboard text after it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep literal text and repair each gesture.** Every gesture that reaches the boundary node needs its own proof, as #5480 showed, and the precedence rule with text references remains.
|
||||
|
||||
**Reuse `ReferenceChipNode`.** Reference chips are occurrences serialized through their owner codec at submit; a command is not model content and must not enter `occurrences`.
|
||||
|
||||
**Put the separator inside the chip text.** Lexical appends a managed line break after an inline decorator that ends its block, so the blank-arguments hint rendered by `p:last-child::after` would move to the next line. A separate text node keeps the hint on the chip's line.
|
||||
|
||||
**Select the chip with a NodeSelection.** The plain-text binding's arrow, typing, and deletion handlers ignore non-range selections, so each of those gestures would need its own handling at the chip.
|
||||
|
||||
**Keep claims in a shell map keyed by NodeKey.** The map duplicates the field the node already carries and needs pruning whenever history is cleared.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Backspace no longer edits the command name character by character to reopen the menu. At the chip's edge, two Backspace or Delete presses remove the chip and leave the arguments as plain text.
|
||||
- A reload restores the chip after the command catalog loads; until then, and when restoration does not apply, the draft shows the literal token.
|
||||
- Whole-chip deletion through a real key event is observable only in the browser lane because jsdom lacks `Selection.modify`; `lifecycle-chrome.e2e.ts` pins the separator, select, and delete presses.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 已认领命令作为输入框原子 chip
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-09-composer-command-chip.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[Lexical 输入框决策](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md)把已认领命令保留为行首字面文本。一个 TextNode transform 给 token 加样式,并把输入进去的溢出文字拆出来;提交状态机在草稿字符串以 token 开头时保持认领。token 交界处的每次编辑都会经过这个 transform。Lexical 会在溢出节点自身的 transform 执行前合并样式相同的相邻节点,于是拆分与合并循环往复,直到 Lexical 以错误码 14 中止这次更新,DOM 与编辑器状态随之分叉(issue #4490)。PR #5480 针对报告的手势清除了被复制的样式;认领后粘贴图片和 IME 组字会进入同一个 transform,仍未验证。纯文本引用实体也需要一条优先级规则来避开已认领的 token。
|
||||
|
||||
## 决策
|
||||
|
||||
认领把行首触发区间替换为 `CommandChipNode`(一个原子 inline `DecoratorNode`),其后是作为普通文本节点的 token 参数分隔符。
|
||||
|
||||
- 节点携带 `CommandClaim`,包括其 submit 闭包。历史记录恢复节点时连同字段一起恢复,因此撤销 chip 删除会恢复认领。JSON 导出写入 token 文本;闭包没有 JSON 形式,因此 JSON 导入得到纯文本节点。
|
||||
- 检测投影把 chip 计为一个 U+FFFC,触发检测不会再读到命令名。剪贴板投影是不含分隔符的 token。chip 不是引用 occurrence。`EditorProjection.command` 是位于文档开头的 chip 所携带的认领。
|
||||
- 在提交事务之外,状态机的认领跟随文档:`draft-changed` 携带行首 chip 的认领,因此删除或挤开 chip 会释放认领,历史恢复会重新进入 claimed。命令提交失败后按同一规则结算,只要行首仍有 chip 就回到 claimed;Enter 时从输入文本裁决出的认领没有 chip,回到 plain。
|
||||
- 节点 transform 让 chip 保持在行首位置。前面有兄弟节点或位于后续块中的 chip 会变回字面 token,并像在旧 token 前输入那样释放认领。chip 自身的 transform 覆盖兄弟节点插入和跨块移动;段落 transform 覆盖在 chip 所在块之前插入块的情况,因此没有任何更新需要遍历整个文档。
|
||||
- 删除分两步。从折叠光标出发、方向触及 chip 的字符删除先把它标记为选中(宿主元素上的 `data-selected`;根元素的 `data-composer-chip-selected` 隐藏光标),同方向的下一次删除才整体移除它。字符删除命令和删除类输入事件都会参与判断,因为 iOS 处理带范围的 `deleteContentBackward` 时不经过该命令。分发过程中没有产生字符删除的按键、点击、失焦或光标离开 chip 会清除标记,因此 macOS Ctrl+H 这类平台删除快捷键会保留它。编辑器选区保持为折叠的 range,`isKeyboardSelectable()` 仍为 false,原因与引用 chip 记录的相同。Escape 仍不释放认领。
|
||||
- 持久化的草稿记录行首 chip 的认领 token(`DraftSnapshot.command`),从不保存认领本身。恢复时输入框调用触发管线的 `resolveClaim`,它轮询可选的 source 钩子 `matchClaim`:等待 source 的目录就绪,只应答认领,与 Enter 的 `matchEnter` 不同,从不调用或执行命令。chip 只在恢复产生的那个版本上重建,并合入种子的历史条目;在此之前发生的编辑、无人认领的 token 或目录加载失败都会保留字面 token,Enter 仍会对其裁决。
|
||||
- 删除 `claim-decor.ts` 和文本引用的优先级规则。
|
||||
|
||||
`slash/input-*` 事件、`CommandClaim`、`InputState.claim` 与触发源的字段保持不变。`CommandClaim.token` 仍以分隔符结尾,命令参数仍从其后的剪贴板文本读取。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留字面文本并逐个修复手势。** 如 #5480 所示,每个触及交界节点的手势都需要单独证明,与文本引用之间的优先级规则也仍然存在。
|
||||
|
||||
**复用 `ReferenceChipNode`。** 引用 chip 是在提交时经 owner codec 序列化的 occurrence;命令不是模型内容,不能进入 `occurrences`。
|
||||
|
||||
**把分隔符放进 chip 文本。** inline decorator 位于块末尾时,Lexical 会在其后追加受管换行,由 `p:last-child::after` 渲染的空参数提示会移到下一行。独立的文本节点让提示留在 chip 所在行。
|
||||
|
||||
**用 NodeSelection 选中 chip。** 纯文本绑定的方向键、输入和删除处理器都会忽略非 range 选区,这些手势在 chip 处都需要单独处理。
|
||||
|
||||
**在 shell 中用按 NodeKey 索引的表保存认领。** 该表重复了节点已携带的字段,并且每次清空历史时都需要修剪。
|
||||
|
||||
## 影响
|
||||
|
||||
- Backspace 不再逐字编辑命令名并重新打开菜单。在 chip 边缘按两次 Backspace 或 Delete 会删除 chip,参数保留为普通文字。
|
||||
- 刷新后,命令目录加载完成即恢复 chip;在此之前以及恢复不适用时,草稿显示字面 token。
|
||||
- 通过真实按键整体删除 chip 只能在浏览器 lane 中观察,因为 jsdom 缺少 `Selection.modify`;`lifecycle-chrome.e2e.ts` 固定了删除分隔符、选中、删除这三次按键。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-09-cua-shared-native-helper.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-09-cua-shared-native-helper.md
|
||||
/agent-note-shared-native-cua-process-with-agent-scoped-state:
|
||||
en: 65056aee19bf6b22
|
||||
zh: 9823fa0c33497ff9
|
||||
/agent-note-shared-native-cua-process-with-agent-scoped-state/problem:
|
||||
en: 386864a282e5ad58
|
||||
zh: a4ea75ec70eb7778
|
||||
/agent-note-shared-native-cua-process-with-agent-scoped-state/decision:
|
||||
en: ebf231a6aad109bb
|
||||
zh: 53b553e679569da1
|
||||
/agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered:
|
||||
en: 883f71387f599160
|
||||
zh: 4b5f1450be799397
|
||||
/agent-note-shared-native-cua-process-with-agent-scoped-state/consequences:
|
||||
en: 518a4abf2c4c95be
|
||||
zh: 72bf2b9817975368
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Shared native CUA process with Agent-scoped state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-09-cua-shared-native-helper.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
On macOS 26.6.2, replayd derives capture-client identity from the executable path. Two live helpers at the same path repeatedly replace each other's connections even when their screenshot requests are sequential. Byte-identical executables at distinct real paths complete sequential and concurrent captures; two symlinks to one executable reproduce the failure. A per-request lock cannot isolate retained system connections.
|
||||
|
||||
## Decision
|
||||
|
||||
One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service.
|
||||
|
||||
Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization.
|
||||
|
||||
Cancellation and timeout first stop one request and wait for acknowledgement. A timed-out screenshot retains the single system-RPC slot until the real callback completes; new screenshots fail with `CAPTURE_BUSY` instead of accumulating requests. Late pixels are discarded, while other AX and input operations remain available. Ending an await or creating a wrapper does not prove that the system connection has recovered. An operation or cleanup that cannot stop can invalidate the process, making every old handle unusable. Uncertain input is never replayed automatically.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Separate executable copies per Agent avoid the observed identity collision but multiply system capture clients. One shared service preserves a single capture client while retaining Agent state isolation. An in-process Swift binding changes crash containment without removing the system RPC. The [continuous-hover decision](2026-10-13-cua-continuous-hover.md) keeps individual operations serialized without cross-call application ownership.
|
||||
|
||||
## Consequences
|
||||
|
||||
Two Hosts can race to start the service and receive the same instance. A may retain its app object while B screenshots and changes the same application; A reads fresh AX afterward. An Agent closing cannot retire another Agent's handles. Recovery must distinguish request, snapshot, handle, capture-backend and process failure. A lost system callback occupies a bounded capture slot; reliable in-process backend reset requires separate platform evidence. The historical DeepSeek failures have matching RPC errors, but their competing-client identity was not recovered from retained logs.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note:共享原生 CUA 进程并隔离 Agent 状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-09-cua-shared-native-helper.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在 macOS 26.6.2 上,replayd 按可执行文件路径生成捕获客户端标识。同一路径的两个活动 helper 即使依次截图,也会反复替换对方的连接。相同字节的可执行文件位于不同实际路径时,交替和并发截图均成功;两个符号链接指向同一文件仍会失败。逐请求加锁不能隔离仍存活的系统连接。
|
||||
|
||||
## 决策
|
||||
|
||||
同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。
|
||||
|
||||
每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。
|
||||
|
||||
取消或超时先停止单次请求并等待确认。截图超时后,唯一的系统 RPC 槽位保留到真实回调结束;新截图返回 `CAPTURE_BUSY`,不继续堆积请求。迟到图片被丢弃,其他 AX 与输入操作仍可执行。结束 await 或创建包装对象不能证明系统连接已恢复。操作或清理无法停止时,才可能使进程失效,届时所有旧句柄均不可用。结果不确定的输入不会自动重放。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
每个 Agent 使用独立的可执行文件副本可以避开已观察到的标识冲突,但会增加系统捕获客户端数量。共享服务保留一个捕获客户端,同时隔离 Agent 状态。进程内 Swift 绑定会改变崩溃隔离方式,不能消除系统 RPC。[连续悬停决策](2026-10-13-cua-continuous-hover.zh.md)保留单次操作串行执行,不分配跨调用的应用所有权。
|
||||
|
||||
## 后果
|
||||
|
||||
两个 Host 同时启动服务时可连接同一个实例。A 可以保留 app 对象,B 对同一应用截图并修改,之后 A 重新读取 AX。一个 Agent 关闭不会释放另一个 Agent 的句柄。恢复需要区分请求、快照、句柄、截图后端和进程失效。系统回调丢失会占用有界的捕获槽位,可靠的进程内后端重置需要另外取得平台验证。历史 DeepSeek 失败具有相同 RPC 错误,但保留日志未能确定当时另一个冲突客户端的身份。
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-10-09-operator-scoped-connect-handshake.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-09-operator-scoped-connect-handshake.md
|
||||
/agent-note-peer-scoped-connect-handshake:
|
||||
en: 90137ec146d1a60e
|
||||
zh: 3dbb0c96426beb36
|
||||
/agent-note-peer-scoped-connect-handshake/problem:
|
||||
en: 3658160e034e0e58
|
||||
zh: 980d922aded8713b
|
||||
/agent-note-peer-scoped-connect-handshake/decision:
|
||||
en: 414dfe949a53c4d9
|
||||
zh: 7e6efdd57f2eb240
|
||||
/agent-note-peer-scoped-connect-handshake/decision/admission-and-ownership:
|
||||
en: 95ad4c8c433fdc9b
|
||||
zh: 379ccd5e077d7dee
|
||||
/agent-note-peer-scoped-connect-handshake/decision/accepted-retention:
|
||||
en: f58c1b39855740da
|
||||
zh: 99343095a357fa3f
|
||||
/agent-note-peer-scoped-connect-handshake/decision/events-and-client-scope:
|
||||
en: e0bdf2f82d79d2a8
|
||||
zh: 5df84ee473916161
|
||||
/agent-note-peer-scoped-connect-handshake/alternatives-considered:
|
||||
en: bcc97e06d4565be1
|
||||
zh: d11e9e9680b26c03
|
||||
/agent-note-peer-scoped-connect-handshake/verification:
|
||||
en: 81ce03ab4e1fd0d4
|
||||
zh: c2729e7e2db56f67
|
||||
/agent-note-peer-scoped-connect-handshake/consequences:
|
||||
en: 91f5f80f733c174c
|
||||
zh: 76cbb31b0d2e8764
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Agent Note: Peer-scoped connect handshake
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-09-operator-scoped-connect-handshake.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Automatically subscribing automation clients to forwarded events lets method-only callers receive and answer human interactions. CLI and MCP callers need independent participation and cleanup without changing browser authentication or assigning per-browser identities.
|
||||
|
||||
## Decision
|
||||
|
||||
Browsers retain their existing signed Cookie, root launch-token exchange, shared `connection.browserPeer` with owner principal and `interactions: 'answer'`, and recovery behavior. Only API callers create independent Peers through `GET /connect?token=<launch-token>&interactions=answer|none`. The endpoint is API-only, defaults to `none`, rejects invalid or repeated declarations with 400, and retains Host/Origin checks and token authentication. It returns 302 with the Remote Mux destination and an API Cookie.
|
||||
|
||||
### Admission and ownership
|
||||
|
||||
- Connect publishes the API Peer before returning. Its signed API Cookie carries the authority, Peer id, and issue/expiry interval; interaction participation belongs to the live Peer, not another credential field. Browser v1 Cookies remain valid and always select the shared Peer. API v2 Cookies select only an existing independent Peer.
|
||||
- HTTP validates the Cookie and finds its Peer without waiting for a WebSocket. Connect, HTTP, and WebSocket require no cross-transport readiness ordering.
|
||||
- One API lifecycle WebSocket owns the Peer. Duplicate attachment is refused; closure removes the Peer before asynchronous disposal completes. Later HTTP and reconnect attempts using that API Cookie are refused. A fresh connect is caller-owned.
|
||||
- Already-admitted unary requests retain their request-level cancellation semantics. Closing the lifecycle socket does not cancel independent HTTP requests or accepted Session work.
|
||||
- Browser sockets never own the shared Peer. Refresh, concurrent tabs, repeated launch-URL visits, and Host restart require no per-browser Peer restoration or credential reuse machinery.
|
||||
|
||||
### Accepted retention
|
||||
|
||||
An API caller may exit after connect without establishing its WebSocket. If it has performed no other work, its Peer retains only lightweight identity, scope, and registry state, not a socket, subscription, or background job. It may remain until Host shutdown. No initial-connect deadline, idle sweep, or in-memory expiry timer exists; Cookie expiration is checked on admission independently of record retention. Repeated abandoned connects accumulate lightweight records, an accepted cost for authenticated local callers. Actual operations still own cleanup of resources they acquire.
|
||||
|
||||
### Events and Client scope
|
||||
|
||||
The Client creates forwarded-event machinery only for `answer`. The Host checks `peer.signal.aborted` first, then `peer.interactions` at both `$events` and `$events/result`: `none` refuses both regardless of principal. An answering owner receives forwarded waterfalls and may reply only through the same Peer as the stream; an answering non-owner receives only RBAC-authorized broadcasts and cannot reply to waterfalls. Only active owner answering streams count as live answering clients. Browsers remain one shared answering party; distinguishing browser credentials from each other is deliberately unsupported.
|
||||
|
||||
Non-page Clients supply HTTP and WebSocket transports using the same API Cookie and Host base URL. Passive clients open their lifecycle socket independently of unary calls and may call generated raw stream methods. They do not use `$stream()`, the browser-generation recovery helper: it rejects immediately in `none` mode. Raw stream failure is terminal; the caller owns any new connect and retry. Existing answering-client generation behavior and Host fact delivery remain unchanged; there is no additional API recovery subsystem.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Independent Peers for browsers too.** This adds credential identities, restart reconstruction, and repeated-login reuse to preserve tabs. Browser callers can remain one party, so the original browser model stays intact.
|
||||
|
||||
**Publish only after upgrade or require Mux readiness before unary.** Both reject or delay valid HTTP that arrives first. Connect is the publication point.
|
||||
|
||||
**Deadlines and pending/bound activation states.** They reclaim lightweight idle records at the cost of timers and transition races. Unused Peers may remain until shutdown.
|
||||
|
||||
**Adapt browser stream recovery to passive callers.** This spreads interaction mode into generation-aware stream consumers and their tests. Raw streams already support CLI/MCP calls; passive callers manage failure themselves.
|
||||
|
||||
**Client-only events switch or Host-global configuration.** Neither enforces independent participation. The authenticated Peer supplies the mode checked by the Host.
|
||||
|
||||
## Verification
|
||||
|
||||
Connection tests cover API publication, Cookie verification, idle retention, socket ownership, and unchanged browser admission. The [Loader tests](../../../../packages/api/gateway/tests/operator-connect.host.spec.ts) cover HTTP before WebSocket, duplicate attachment, prompt removal during slow cleanup, unary cancellation independence, and event refusal/ownership. [Node Client tests](../../../../packages/api/gateway/tests/authenticated-node.client.spec.ts) cover authenticated transports, delayed socket establishment, and raw streams without passive recovery.
|
||||
|
||||
The [recorded approval scenario](../../../../apps/web/tests/approval-composer.e2e.ts) adds a passive caller alongside the answering browser. Full replay requires a usable command sandbox for its file-writing operation; local environments without one cannot validate that final step. No Session format or model-visible input changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Unconnected API records need not be promptly reclaimed; resource-heavy additions to idle Peers require revisiting that choice. API Cookies cannot restore a Peer after its socket closes or the Host restarts. Browser Cookie format and authentication behavior remain unchanged, with no reauthentication migration. API and browser Cookies use the same authority-specific name, so API callers keep their own Cookie storage rather than exchanging credentials in a shared browser jar. Any launch-token holder may request `answer`; this is interaction participation, not user isolation.
|
||||
|
||||
The [duplex-stream decision](2026-09-19-remote-duplex-stream.md) retains Peer scopes and stream framing. The [browser authentication decision](2026-08-24-browser-token-authentication.md) retains browser token, signature, authority, and lifetime semantics; only independent API admission is added here. Both records remain active.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Agent Note: 以 Peer 为作用域的建连握手
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-09-operator-scoped-connect-handshake.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
让自动化客户端自动订阅转发事件,会使只需调用方法的客户端也收到并回答人机交互。CLI 与 MCP 调用方需要独立参与和清理机制,而不改变浏览器认证或为每个浏览器赋予独立身份。
|
||||
|
||||
## 决策
|
||||
|
||||
浏览器保留现有签名 Cookie、根路径 launch token 交换、共享 `connection.browserPeer` 和恢复行为。只有 API 调用方通过 `GET /connect?token=<launch-token>&interactions=answer|none` 创建独立 Peer。该端点仅供 API 使用,默认 `none`,非法或重复声明返回 400,并保留 Host/Origin 检查和 token 认证。它返回 302、Remote Mux 目标地址和 API Cookie。
|
||||
|
||||
### 接纳与所有权
|
||||
|
||||
- Connect 在返回前发布 API Peer。签名 API Cookie 包含 authority、Peer 标识和签发/过期区间;交互参与模式属于有效 Peer,不再重复写入凭证字段。浏览器 v1 Cookie 保持有效,始终选择共享 Peer;API v2 Cookie 只选择已存在的独立 Peer。
|
||||
- HTTP 校验 Cookie 并查找 Peer,不等待 WebSocket。Connect、HTTP 与 WebSocket 不要求跨传输的就绪顺序。
|
||||
- 一条 API 生命周期 WebSocket 拥有 Peer。重复关联被拒绝;关闭时在异步释放完成前移除 Peer。之后使用该 API Cookie 的 HTTP 与重连请求被拒绝,调用方负责重新 connect。
|
||||
- 已受理的 unary 请求保留请求级取消语义。生命周期连接关闭不会取消独立 HTTP 请求或已接受的 Session 工作。
|
||||
- 浏览器 socket 不拥有共享 Peer。刷新、多标签页、重复打开启动 URL 和 Host 重启不需要每浏览器 Peer 的恢复或凭证复用机制。
|
||||
|
||||
### 接受的保留行为
|
||||
|
||||
API 调用方可能在 connect 后退出,始终没有建立 WebSocket。如果它没有执行其他工作,Peer 仅保留轻量身份、作用域与登记状态,不持有 socket、订阅或后台任务,可以保留到 Host 退出。不设置首次建联期限、空闲扫描或内存过期定时器;Cookie 过期在接纳时检查,与记录保留无关。反复放弃 connect 会累积轻量记录,这是已认证本地调用方场景下接受的成本。实际操作仍负责清理其获取的资源。
|
||||
|
||||
### 事件与 Client 范围
|
||||
|
||||
Client 仅在 `answer` 模式创建转发事件机制。Host 首先检查 `peer.signal.aborted`,随后在 `$events` 与 `$events/result` 两处检查 `peer.interactions`:无论 principal 是什么,`none` 都被拒绝。回答型 owner 收到转发的 waterfall,且只能从拥有事件流的同一 Peer 回答;回答型非 owner 仅收到经 RBAC 授权的广播,不能回答 waterfall。只有未取消的 owner 回答流计入在线回答客户端。浏览器仍是同一个共享回答方;本设计明确不区分不同浏览器凭证的身份。
|
||||
|
||||
非页面 Client 提供使用同一 API Cookie 与 Host 基址的 HTTP 和 WebSocket 传输。被动客户端独立于 unary 调用打开生命周期连接,也可以调用生成的原始流方法。它们不使用浏览器代际恢复辅助方法 `$stream()`:该方法在 `none` 模式立即拒绝。原始流失败直接结束;调用方负责重新 connect 和重试。既有回答型客户端的代际行为与 Host facts 下发不变,不另建 API 恢复系统。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**也为浏览器创建独立 Peer。** 为保留多标签页行为,这需要凭证身份、重启重建和重复登录复用。浏览器调用方可以保持为同一方,因此保留原模型。
|
||||
|
||||
**升级后才发布,或在 unary 前等待 Mux 就绪。** 两者都会拒绝或延迟先到达的有效 HTTP。Connect 才是发布时机。
|
||||
|
||||
**建联期限和 pending/bound 激活状态。** 它们以定时器和状态转换竞争为代价回收轻量空闲记录。未使用的 Peer 可以留到退出。
|
||||
|
||||
**为被动调用方适配浏览器流恢复。** 这会把交互模式传入依赖代际的流消费者及其测试。原始流已支持 CLI/MCP 调用,被动调用方自行处理失败。
|
||||
|
||||
**仅客户端事件开关或 Host 全局配置。** 两者都不能强制独立参与规则。由已认证 Peer 提供 Host 检查的模式。
|
||||
|
||||
## 验证
|
||||
|
||||
Connection 测试覆盖 API 发布、Cookie 校验、闲置保留、连接所有权和不变的浏览器接纳。[Loader 测试](../../../../packages/api/gateway/tests/operator-connect.host.spec.ts)覆盖 HTTP 先于 WebSocket、重复关联、慢清理期间及时移除、unary 取消独立性和事件拒绝/所有权。[Node Client 测试](../../../../packages/api/gateway/tests/authenticated-node.client.spec.ts)覆盖认证传输、延迟建联和不使用被动恢复的原始流。
|
||||
|
||||
[录制审批场景](../../../../apps/web/tests/approval-composer.e2e.ts)在回答页面旁增加一个被动调用方。完整回放需要可用命令 sandbox 执行写文件;缺少该后端的本地环境无法验证最后一步。本次不改变 Session 格式或模型可见输入。
|
||||
|
||||
## 后果
|
||||
|
||||
未建联的 API 记录不要求及时回收;若为空闲 Peer 增加重资源,必须重新评估这一取舍。连接关闭或 Host 重启后,API Cookie 不能恢复 Peer。浏览器 Cookie 格式与认证行为保持不变,无需重新认证迁移。API 与浏览器 Cookie 使用同一 authority 专属名称,因此 API 调用方维护自己的 Cookie 存储,不在共享浏览器 Cookie jar 中交换凭证。任何 launch token 持有者都可以请求 `answer`;这是交互参与,不是用户隔离。
|
||||
|
||||
[双工流决策](2026-09-19-remote-duplex-stream.zh.md)继续定义 Peer 作用域和流帧格式。[浏览器认证决策](2026-08-24-browser-token-authentication.zh.md)保留浏览器 token、签名、authority 与有效期语义;本记录只增加独立 API 接纳。两份既有记录继续保留。
|
||||
Loaded 100 of 4878 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user