Merge remote-tracking branch 'origin/master' into worktree/dynamic-tool-activation-pr-64796e
# Conflicts: # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/persistence-catalog.i18n.yaml # packages/extensions/tool-cordis/src/api-catalog.ts
This commit is contained in:
commit
633bdd18ef
1533 files changed
+81664
-6975
No files matched your search
+2
-2
@@ -27,8 +27,8 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
|
||||
|
||||
## 包归属
|
||||
|
||||
|
||||
@@ -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 规则,并从包的真实目录展开依赖。
|
||||
|
||||
|
||||
+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
@@ -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` 辅助函数共享。
|
||||
|
||||
+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 换代不声称卸载模块。
|
||||
|
||||
### 文件系统与运行时载体
|
||||
|
||||
|
||||
+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
|
||||
@@ -75,8 +75,8 @@
|
||||
en: 46a0c0b3ce1e5671
|
||||
zh: 7743707de42b1d1d
|
||||
/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: ecfaff2b9b2ef021
|
||||
zh: 85b392d79364e0c3
|
||||
/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
|
||||
|
||||
@@ -312,7 +318,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 +332,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 +369,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
|
||||
}
|
||||
|
||||
@@ -396,7 +402,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 +452,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`; `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 +568,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 +584,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 +597,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 面
|
||||
|
||||
@@ -312,7 +318,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 +332,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 +369,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
|
||||
}
|
||||
|
||||
@@ -396,7 +402,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 +452,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`;`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 +568,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 +584,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 +597,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 方法的测试不改期望值即通过。
|
||||
|
||||
|
||||
@@ -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: 92247ff599c1103f
|
||||
zh: 26845420a9711c83
|
||||
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
|
||||
en: 4693dcd217c4fedb
|
||||
zh: 1dc9bf9067314006
|
||||
/agent-note-application-consent-for-native-computer-use/consequences:
|
||||
en: 0146a69ab2086edc
|
||||
zh: 4257bc1ab4432c3f
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
|
||||
|
||||
A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive task access only, tied to a process instance and Agent connection lifetime. Labels do not establish identity.
|
||||
|
||||
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
|
||||
|
||||
Selections of the same application within one task are serialized, so a pending question does not produce competing decisions. Saved-grant additions read the current configuration inside one mutation queue. Refusals prevent another prompt within the task; an explicitly restored saved grant takes precedence at dispatch.
|
||||
|
||||
Removing a saved grant invalidates Agent connections retaining the application's handles and cancels active operations; Agent/provider disposal awaits native connection cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Trust model instructions or hide controls.** Retained objects and direct tool calls can still reach native operations. The executor must enforce the decision at dispatch.
|
||||
|
||||
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; task-only access supports unsigned development applications without that promise.
|
||||
|
||||
**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers.
|
||||
|
||||
**Keep the native context after revocation.** Its application lookup can return an earlier handle and retain window snapshots. Closing the Agent connection discards its native context through existing transport cleanup; preserving unrelated handles would require a native per-application retirement protocol.
|
||||
|
||||
## Consequences
|
||||
|
||||
Revoking one application also invalidates other application handles on the same Agent connection. Those applications require fresh selection; saved grants remain reusable, while task grants expire. Other tasks with independent connections remain unaffected unless they also hold access to the revoked application.
|
||||
|
||||
|
||||
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,38 @@
|
||||
# Agent Note: 原生 Computer Use 的应用授权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-26-cua-application-consent.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应用。保留的应用和窗口对象可能比用户的许可存活更久。并发授权决定不能在用户撤销后恢复访问。
|
||||
|
||||
## 决策
|
||||
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。
|
||||
|
||||
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得任务授权,许可绑定进程实例和 Agent 连接生命周期。显示名称不用于确定身份。
|
||||
|
||||
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
|
||||
|
||||
同一任务对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增在同一写入队列内读取当前配置。拒绝会阻止任务内的再次询问;用户显式恢复的长期许可在执行检查时优先于旧拒绝记录。
|
||||
|
||||
删除长期许可会使保留目标应用句柄的 Agent 连接失效,并取消活动操作;Agent 或提供方销毁时等待对应的原生连接清理。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。
|
||||
|
||||
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;任务授权支持未签名的开发应用,但不承诺跨进程复用。
|
||||
|
||||
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。
|
||||
|
||||
**撤销后保留原生状态。** 原生应用查找可能返回旧句柄并保留窗口快照。关闭 Agent 连接可以通过现有传输清理释放其原生状态;保留其他应用的句柄则需要新增按应用销毁状态的原生协议。
|
||||
|
||||
## 影响
|
||||
|
||||
撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;长期许可仍可复用,任务许可随之失效。其他任务使用独立连接,除非也持有被撤销应用的许可,否则不受影响。
|
||||
|
||||
|
||||
授权决定和失败结果由策略回归测试及 CUA 录制会话验证。桌面安装包的二进制分发、签名和打包宿主权限不属于源码启动流程。
|
||||
+10
-10
@@ -12,23 +12,23 @@
|
||||
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
|
||||
|
||||
+11
-13
@@ -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.
|
||||
|
||||
|
||||
+11
-13
@@ -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 保留普通的依赖驱动重载。
|
||||
|
||||
|
||||
+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 接纳。两份既有记录继续保留。
|
||||
+4
-4
@@ -15,11 +15,11 @@
|
||||
en: db8d77cd773ad646
|
||||
zh: ab2f03b8fc58e952
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
|
||||
en: a2b48738666a7d5f
|
||||
zh: 53377f4f1109af26
|
||||
en: fd9152c3f5edb035
|
||||
zh: 259337cdcc5969a5
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
|
||||
en: f51678f9f30bad59
|
||||
zh: b525e9ed10b9928c
|
||||
en: 2a98bb21bb1315d1
|
||||
zh: be4a320e9b427a79
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/testing:
|
||||
en: e2e3982718e16ef4
|
||||
zh: 4f3802946e51c55c
|
||||
@@ -33,7 +33,7 @@ The host claims the waking input right after `turn/start`, assembles the system
|
||||
|
||||
**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position.
|
||||
|
||||
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab.
|
||||
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold. Folds caused by human input without an echo on this page are handled by [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md).
|
||||
|
||||
**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position.
|
||||
|
||||
@@ -45,7 +45,7 @@ The host claims the waking input right after `turn/start`, assembles the system
|
||||
- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them.
|
||||
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
|
||||
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
|
||||
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here.
|
||||
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; its landing follows [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md).
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
|
||||
|
||||
**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。
|
||||
|
||||
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
|
||||
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。
|
||||
|
||||
**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。
|
||||
|
||||
@@ -45,7 +45,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
|
||||
- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。
|
||||
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
|
||||
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
|
||||
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
|
||||
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-peer-principal-and-remote-access.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control:
|
||||
en: 0c0fd0f567bd5c89
|
||||
zh: 464e34d05737a7bc
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/problem:
|
||||
en: f5b70ee2c0a3e32a
|
||||
zh: 68776a916cb41f11
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/scope:
|
||||
en: 86061e5578d06871
|
||||
zh: 08eb605fcd38329d
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/two-layers-principal-and-peer:
|
||||
en: 6663ea33561eee82
|
||||
zh: 722fd3b8fae0ef90
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-model:
|
||||
en: d9ac7c94e40c2494
|
||||
zh: fa6f5e6690b2fb90
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/identity-types:
|
||||
en: 31430c5b95295ead
|
||||
zh: 8f4ba35b4406558d
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-lifetime-and-revocation:
|
||||
en: a01ac57fc71a84f0
|
||||
zh: 3e488ef3da5e4b8c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control:
|
||||
en: a55516339b0d49dd
|
||||
zh: 3281df9ea7878fc0
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac:
|
||||
en: a78a2410eb52f777
|
||||
zh: a868bf1b0e690c9f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry:
|
||||
en: 625b20552962e268
|
||||
zh: 1136c0fd7cdc6901
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
|
||||
en: ec1677d8e338e115
|
||||
zh: 6bedd163f208eaa9
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access:
|
||||
en: e57f396239ea9f56
|
||||
zh: ddb26278b1521ceb
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/example-share-viewer:
|
||||
en: 3c911f57f0f6ca4b
|
||||
zh: 1509781eeda4c922
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order:
|
||||
en: 6c58dc8777e107ef
|
||||
zh: bc14605c96e4d8f3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events:
|
||||
en: 5fe34ee5ec31942a
|
||||
zh: ef39b020b0539b8f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/list-and-aggregate-methods:
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/security-invariants:
|
||||
en: 2094587c320b6ccc
|
||||
zh: 3a1d2a6b343500f4
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/recording-the-message-source:
|
||||
en: 2ee3abed24d418f4
|
||||
zh: 25653dd24b95c220
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/code-review-rule-and-documentation:
|
||||
en: e9a2e0d1534b8fe7
|
||||
zh: 085813c0b2b3c0d8
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/out-of-scope-and-future-work:
|
||||
en: a84e07716719e3b5
|
||||
zh: 9649ccb4a035a1df
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/relation-to-existing-notes:
|
||||
en: 305ec45a7cd85ed7
|
||||
zh: d2cd4209a56b1d0c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered:
|
||||
en: 130df2981ba550df
|
||||
zh: e9a0a89af6fb50a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences:
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/migration-list-for-pr-5945:
|
||||
en: f904c62b68d46bb5
|
||||
zh: 34eb45b97302badb
|
||||
+675
@@ -0,0 +1,675 @@
|
||||
# Agent Note: Peer principals and method-level Remote access control
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-peer-principal-and-remote-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The [`PeerScope`](../../../../packages/typert/protocol/src/types.ts) contract stated "Who the Peer is and what it may do are not recorded here"; [Connection](../../../../packages/client/connection/src/rpc-host.ts) had a single operator Peer, `admit()` returned it for every authenticated request, and the `/api` route and the interceptor's Fetch handler used it unconditionally instead of the admission result; [Gateway](../../../../packages/api/gateway/src/index.ts) silently fell back to `operatorPeer()` when a caller named no Peer.
|
||||
|
||||
The first non-owner caller is a liveshare viewer. Viewer requests reach the owner's liveshare Host plugin through the Relay, and the plugin dispatched them in the owner's process as the operator, so the owner's Session treated a viewer's prompt as the owner's own input. PR #3854 pushed liveshare semantics into session-controller for this: the request body carries `origin`, every prompt writes a required `prompt/origin` event correlated by requestId, the controller folds origins, `serializeAdmission` extends to every prompt, and a new `api-session/external-admission` hook appears; liveshare maintains its own `READ_ENDPOINTS` allowlist and trims list/follow results; the frontend `clientWritePolicy` lives in ui-conversation, with three ways of reading it across five packages and seven places that hide UI by whether a callback was injected.
|
||||
|
||||
One general mechanism is needed:
|
||||
|
||||
- In-process Host code assigns the caller identity; no wire field can set or override it.
|
||||
- Every call path names its Peer explicitly; there is no implicit fallback.
|
||||
- Method-level permission is computed and enforced in one place on the Host; the frontend reads the same result to show or hide entries and follows rule changes automatically.
|
||||
- A prompt records its caller with the user message, so a later check can tell who initiated each turn.
|
||||
|
||||
## Decision
|
||||
|
||||
### Scope
|
||||
|
||||
| Part | Content |
|
||||
|---|---|
|
||||
| Peer identity | `PeerScope.principal` and `PeerScope.signal`; one Peer implementation `Peer` (renamed from `OperatorPeer`) with an options constructor; removal of the operator concept and every implicit Peer fallback; `connection.browserPeer`, `connection.createPeer()`, and `fetch(request, peer)`; a disposed Peer is always refused with `gateway/peer-disposed`; the `user-rpc` source records `principal?` |
|
||||
| Method-level access control | `resources` readers on `@Remote`, `TypertResourceMap` resource kinds, and the `typert.resources` registry (typert-protocol, typert-registry); Gateway resolution of calls and forwarded events to resources; new package `@deepseek-ai/dsh-api-remote-access` (Host and Client faces): RBAC decisions over roles, bindings, and policies through `decide(request)`, list filtering through `allows()`, the first snapshot in the index and the access snapshot stream, and access hooks |
|
||||
| Documentation | One code-review rule and terminology |
|
||||
|
||||
### Two layers: principal and peer
|
||||
|
||||
| Layer | Concept | Example | Cardinality |
|
||||
|---|---|---|---|
|
||||
| Authorization | principal: the caller identity that permission decisions read | `share-viewer{shareId, actor}` proven by a share token; the local user `{ kind: 'owner' }` | one principal has N peers |
|
||||
| Connection | peer: the lifetime scope of one connection | a browser connection, one connection of a liveshare viewer | one peer per connection |
|
||||
|
||||
| Concern | Layer |
|
||||
|---|---|
|
||||
| Method-level decisions, access hooks, access snapshots | principal |
|
||||
| Recording `source.principal` | principal |
|
||||
| Subscriptions, streams, connection-lifetime registrations (`peer.ctx`) | peer |
|
||||
| Revocation | both: after revocation the policy gives that principal no binding, so its calls are all refused; the creator disposes every peer under it |
|
||||
|
||||
The creator owns the principal→peers table; liveshare, for example, keeps one object per share grant that records every peer opened under it.
|
||||
|
||||
### Peer model
|
||||
|
||||
There is one Peer concept and one implementation; the operator concept is removed.
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
/** Connection-lifetime registrations; disposed with the Peer. */
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
|
||||
readonly signal: AbortSignal
|
||||
/** Forwarded human-interaction participation; independent of authorization. */
|
||||
readonly interactions: 'answer' | 'none'
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
// @deepseek-ai/dsh-client-connection
|
||||
export class Peer implements PeerScope {
|
||||
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
|
||||
}
|
||||
|
||||
interface HostConnectionHandle {
|
||||
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
|
||||
readonly browserPeer: PeerScope
|
||||
/** Network admission: the admitted Peer or the refusal status. */
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
|
||||
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
|
||||
}
|
||||
|
||||
interface ConnectionFetchHandler {
|
||||
fetch(request: Request, peer: PeerScope): Promise<Response>
|
||||
}
|
||||
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
|
||||
|
||||
// @deepseek-ai/dsh-api-gateway
|
||||
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
|
||||
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
|
||||
```
|
||||
|
||||
| Rule | Detail |
|
||||
|---|---|
|
||||
| Every path names its Peer | Network paths take it from `admit()`; in-process paths receive it from the caller. Gateway deletes `operatorPeer()` and `request.peer ?? …` and never creates a Peer |
|
||||
| Browser connections share `browserPeer` | Every browser connection shares one Peer, and every use site names `connection.browserPeer` explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes `browserPeer` explicitly |
|
||||
| In-process Host automation | Code that calls Remote methods creates its own Peer with `createPeer({ principal })`. No production caller exists yet, so no fixed Host Peer is pre-provisioned |
|
||||
| Multiple Peers | Each `createPeer()` returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) |
|
||||
| Interaction participation | `Peer(owner, { principal, interactions? })` and `createPeer()` default omitted `interactions` to `answer`. `none` refuses `$events` and `$events/result` for every principal, even owner; this is independent of method-level RBAC. Answering owner Peers receive waterfalls, and non-owner Peers receive only RBAC-authorized broadcasts and cannot answer |
|
||||
| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer |
|
||||
|
||||
### Identity types
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
|
||||
/**
|
||||
* Merge-extensible caller identities. Each driver declares its own entry.
|
||||
* Every entry is a JSON value with a unique literal `kind`, carries no
|
||||
* credential, and is safe to persist in a Session log.
|
||||
*/
|
||||
export interface PrincipalMap {
|
||||
owner: { readonly kind: 'owner' }
|
||||
}
|
||||
export type Principal = PrincipalMap[keyof PrincipalMap]
|
||||
|
||||
/**
|
||||
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
|
||||
* @returns the caller's principal when it is not the owner; `undefined` for the
|
||||
* owner and for trusted in-process code outside a Remote call.
|
||||
*/
|
||||
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
|
||||
```
|
||||
|
||||
The single read point is `this.ctx.invocation.peer.principal`; an owner test is written inline as `principal.kind === 'owner'`, and an unknown kind is never the owner. External drivers extend `PrincipalMap` by declaration merging in their own package; liveshare's `share-viewer{ shareId, actor }` is declared on the #3854 side, and liveshare itself is unchanged here.
|
||||
|
||||
### Peer lifetime and revocation
|
||||
|
||||
| Event | Behavior |
|
||||
|---|---|
|
||||
| Gateway dispatch | The first step checks `peer.signal.aborted` and refuses a disposed Peer with `gateway/peer-disposed`, on HTTP, WebSocket, and in-process paths alike; wire paths check before they read the payload, so a malformed payload from a disposed Peer gets the same code |
|
||||
| Streams when a Peer is disposed | The signal a Remote stream method observes joins `peer.signal`, and so does the `$events` stream lifetime; a WebSocket still closes with 1001 |
|
||||
| Unary calls when a Peer is disposed | A unary call already running is not cancelled; later calls are refused |
|
||||
| Revocation | After a business package revokes a grant, its policy gives that principal no binding, and the business package calls `invalidate(principal)`; the creator disposes every peer in its principal→peers table |
|
||||
|
||||
### Method-level access control
|
||||
|
||||
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
|
||||
|
||||
#### Correspondence with RBAC
|
||||
|
||||
| This design | RBAC / IAM term | Meaning |
|
||||
|---|---|---|
|
||||
| principal | Subject / Principal | The caller identity, one entry of `PrincipalMap` |
|
||||
| Peer | Session (RBAC session) | One connection of a principal; one principal has N Peers |
|
||||
| owner | Superuser | Skips every check |
|
||||
| action | Action / Permission | A Remote endpoint `ns/method`, or a forwarded event `event:<name>`; the Gateway still calls it an endpoint internally |
|
||||
| resource | Resource / Object | `{ kind: 'global' }`, or `{ kind, id }` of a declared resource kind |
|
||||
| role | Role | An action set over one resource kind; a plain value of the business package |
|
||||
| binding | RoleBinding (role assignment) | The principal holds a role on a resource, optionally with a condition |
|
||||
| condition | Condition (IAM Conditions) | Attached to a binding; allows by the request's action, resource, and arguments, or refuses with a reason |
|
||||
| policy | Allow policy | The bindings and optional `explain` a business package gives for one non-owner principal kind |
|
||||
| explain | Denial explanation | Names a refusal reason when no binding allows; never changes the decision |
|
||||
| Gateway | PEP (Policy Enforcement Point) | Resolves resources and enforces the decision |
|
||||
| remote-access | PDP (Policy Decision Point) | Computes decisions, list filtering, and access snapshots |
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Default deny | A non-owner is allowed only when the role of some binding explicitly lists the action; an action no role lists, a resource without a binding, and a principal kind without a policy are all refused. Every Remote method is under access control; there is no separate accessibility marker |
|
||||
| No inheritance between resources | `global` is not a parent of other resources: a grant on `global` implies no grant on any Session or Workspace, and a grant on a Workspace implies no grant on its Sessions |
|
||||
| A `global` grant is an explicit global grant | A call that resolves to no resource touches `global`. Putting an action in a `global` role lets that principal call it without resource restriction, which is a high-risk grant; once the method is allowed, it can still decide each item inside with `allows(resource)` |
|
||||
|
||||
#### Resources and resource readers (`@deepseek-ai/dsh-typert-protocol`, `@deepseek-ai/dsh-typert-registry`)
|
||||
|
||||
```text
|
||||
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
|
||||
export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
export interface TypertHostContextAdapter<Wire = unknown> {
|
||||
readonly wire: string
|
||||
readonly wireTypeSymbol: string
|
||||
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
|
||||
readonly resource?: TypertResourceKind
|
||||
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
|
||||
}
|
||||
|
||||
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
|
||||
export type RemoteAccessResource =
|
||||
| { readonly kind: 'global' }
|
||||
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
|
||||
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
|
||||
|
||||
/** Per-kind readers, called with the method's positional wire arguments. */
|
||||
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
|
||||
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
|
||||
}
|
||||
|
||||
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
|
||||
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function RemoteScope<This extends object, Args extends unknown[], Result>(
|
||||
key: Extract<keyof TypertContextMap, string>,
|
||||
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
```
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
|
||||
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource<SessionId>` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource<WorkspaceId>` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws |
|
||||
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` |
|
||||
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
|
||||
| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
|
||||
| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added |
|
||||
| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) |
|
||||
| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` |
|
||||
| Completeness | `resources` and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to `global`, which only a `global` role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent |
|
||||
| Type inference | The parameter types of `@Remote` and `@RemoteScope` readers are inferred from the decorated method: the factory parameter uses `NoInfer`, so `Args` is inferred only from the returned decorator; lookup parameters map to wire types through `TypertLookupMap` (for a method whose first parameter is `agent: Agent`, the reader's first parameter is `SessionId`). A missing property, more parameters than the method has, and a `global` key fail compilation |
|
||||
| Storage | `resources` readers live on the class prototype with the other `@Remote` / `@RemoteScope` metadata, are recorded on instance construction, and are safe across duplicate installations; they do not enter Typert artifacts |
|
||||
| Context methods | `@RemoteScope(key, option?)` matches `@Remote`: a string is the export name and an object is `{ name?, resources? }`. A Context's wire identity takes part in resolution like a lookup parameter that declares a `resource`, with the same precedence, and a `resources` reader for that kind overrides it; the Gateway reads that identity directly from args before `resolveReceiverContext`, so no Agent is activated. A Context that declares no `resource` contributes no resource from its identity, and the method resolves by the ordinary rules |
|
||||
|
||||
#### Endpoint resources
|
||||
|
||||
The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field:
|
||||
|
||||
| Endpoint | Resource | Resolution source | Filtering inside the method |
|
||||
|---|---|---|---|
|
||||
| `workspace/follow` | `global` | no resource | yes: Workspaces and Sessions |
|
||||
| `session/control` | `global` | no resource | yes: Sessions |
|
||||
| `session/list` | `global` (`_request` has no `sessionId`) | no resource | yes: Session rows |
|
||||
| `session/modelCatalog` | `global` | no resource | no |
|
||||
| `session/canOpenWorkspacePath` | `global` | no resource | no |
|
||||
| `llm/listConfigurableProviders` | `global` | no resource | no |
|
||||
| `llm/listProviders` | `global` | no resource | no |
|
||||
| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no |
|
||||
| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no |
|
||||
| `session/attachment` | session: `args.request.sessionId` | default field | no |
|
||||
| `skills/list` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/fork` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/prompt` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/cancel` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/projections` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no |
|
||||
| `session/follow` | same as above | `resources` | no |
|
||||
| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no |
|
||||
|
||||
A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource.
|
||||
|
||||
Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`).
|
||||
|
||||
#### Roles, bindings, and policies (`@deepseek-ai/dsh-api-remote-access`)
|
||||
|
||||
```text
|
||||
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
|
||||
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
|
||||
|
||||
/** A named action set over one resource kind; a plain value owned by the business package. */
|
||||
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
|
||||
readonly name: string
|
||||
readonly resource: K
|
||||
readonly actions: readonly RemoteAccessAction[]
|
||||
}
|
||||
|
||||
/** Answer of decide() and of a condition. */
|
||||
export type RemoteAccessDecision =
|
||||
| { readonly kind: 'allow' }
|
||||
| { readonly kind: 'deny'; readonly reason: string }
|
||||
|
||||
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
|
||||
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
|
||||
readonly action: RemoteAccessAction
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}) => RemoteAccessDecision
|
||||
|
||||
/** One role on one resource of the role's kind; the resource type follows from the role. */
|
||||
export type RemoteAccessBinding = {
|
||||
[K in RemoteAccessResourceKind]: {
|
||||
readonly role: RemoteAccessRole<K>
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly condition?: RemoteAccessCondition<K>
|
||||
}
|
||||
}[RemoteAccessResourceKind]
|
||||
|
||||
/** One access question; the Gateway passes only valid resources, [global] when none. */
|
||||
export interface RemoteAccessRequest<P extends Principal> {
|
||||
readonly principal: P
|
||||
readonly action: RemoteAccessAction
|
||||
/** Allow only when the action is allowed on every listed resource. */
|
||||
readonly resources: readonly RemoteAccessResource[]
|
||||
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
|
||||
export interface RemoteAccessPolicy<P extends Principal> {
|
||||
bindings(principal: P): readonly RemoteAccessBinding[]
|
||||
/** Reason for a request no binding allows; never changes the decision. */
|
||||
explain?(request: RemoteAccessRequest<P>): string | undefined
|
||||
}
|
||||
|
||||
/** details of `remote-access/denied` */
|
||||
export interface RemoteAccessDeniedDetails {
|
||||
readonly action: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
|
||||
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
|
||||
invalidate(principal?: Principal): void
|
||||
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
|
||||
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
|
||||
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
|
||||
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
|
||||
}
|
||||
```
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Owner | The Gateway does not resolve resources or call `decide()` for the owner, and reads no policy |
|
||||
| Registration | `registerPolicy(kind, policy)` returns a disposer, and a business package calls it inside `ctx.effect()`. Each non-owner principal kind has at most one policy, and a second registration for the same kind throws; `bindings()` and `explain()` receive principals typed by the kind |
|
||||
| Built-in allow | `remoteAccess/follow` is allowed for every principal, so every Peer can read its own snapshot |
|
||||
| Decision | A principal kind without a policy is refused with `no-allow`. Otherwise `bindings(principal)` is read, and a linear scan finds for each requested resource one binding whose resource is equal (the same `kind` and, for a non-`global` resource, the same `id`), whose role's `actions` contains the action, and that has no condition or whose condition allows; the request is allowed only when every resource has one (AND). A condition runs only for a binding whose resource is equal and whose role contains the action |
|
||||
| Refusal reason | The first resource not allowed decides the reason, in this precedence: when matching bindings exist on that resource but all their conditions refuse, the first condition's reason; otherwise the return value of `explain(request)`; without `explain`, or when it returns `undefined`, `no-allow`. `explain` runs only after the decision is already a refusal and never changes it |
|
||||
| Failure isolation | When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot computation, a throwing `bindings()` leaves that principal no grant and a throwing condition excludes that action. A faulty rule therefore never breaks the shared `$events` source, a snapshot stream, or page rendering; after each `invalidate()` or policy change, the first failure of each policy is logged once |
|
||||
| Synchronous | `bindings()`, conditions, and `explain()` return synchronously from state the business package preloads in memory, so list filtering can decide per item and snapshots can be computed |
|
||||
| Evaluation timing | Each `decide()`, each `allows()`, and each snapshot computation calls `bindings()` once, with no caching; time-dependent state (such as expiry) applies immediately, and a long stream (such as `workspace/follow` or `session/control`) reflects added or removed bindings at its next per-item decision; snapshots are recomputed only after `invalidate()`, so a business package also calls `invalidate()` when a time event such as expiry happens |
|
||||
| Notification | When a business state change (grant, revocation, mode switch) alters a binding or a condition's answer, the business package must call `invalidate(principal?)`; snapshots are recomputed only after `invalidate()` and policy registration or removal |
|
||||
| `remote-access/denied` | Only a call refusal becomes an error, with details `{ action, reason }`, where reason is the reason a condition or `explain` gave, `no-allow`, `policy-failed`, or the `resource-invalid` the Gateway gives for invalid resources; a refused event is simply not pushed |
|
||||
| Required service | The Gateway declares `static inject = ['typert', 'remoteAccess']`; a composition that mounts the Gateway mounts remote-access, which [`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) assembles right after `typert-gateway`; the plugin manager protects it like the Gateway, so it cannot be disabled from the Plugins page |
|
||||
|
||||
#### Example: share viewer
|
||||
|
||||
The `share-viewer` principal that liveshare declares shows that the derivation from business state to bindings stays in the business package; `shares` in the example stands for the share state the business package holds in memory:
|
||||
|
||||
```text
|
||||
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
|
||||
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
|
||||
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
|
||||
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
|
||||
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
|
||||
|
||||
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
|
||||
bindings(viewer) {
|
||||
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
|
||||
if (share === undefined) return []
|
||||
const source = { kind: 'session', id: share.sessionId }
|
||||
return [
|
||||
{ role: globalReader, resource: { kind: 'global' } },
|
||||
{ role: sessionReader, resource: source },
|
||||
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
|
||||
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
|
||||
...shares.forksOf(viewer).flatMap(child => [
|
||||
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
|
||||
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
|
||||
]),
|
||||
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
|
||||
]
|
||||
},
|
||||
explain({ principal, action }) {
|
||||
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
|
||||
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
|
||||
},
|
||||
}))
|
||||
|
||||
// Without args (snapshots, list filtering) a condition answers for the action alone.
|
||||
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
|
||||
const childPrompt = child => ({ action, args }) =>
|
||||
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
|
||||
```
|
||||
|
||||
| Owned by | Content |
|
||||
|---|---|
|
||||
| Business package | The action lists of roles; the derivation from share state to bindings (stop, expiry, admission, mode, the viewer's own forks, revocation, Workspaces containing visible Sessions); per-request conditions; refusal reasons for the viewer page |
|
||||
| Generic layer | Resource resolution, AND across resources, role and action matching, condition calls, reason precedence, list filtering, snapshot computation, and failure isolation |
|
||||
|
||||
That viewer's snapshot contains grants on `global`, the source Session, each of its own forks, and each visible Workspace; `session/list` appears in both a `global` role and a session role, see [List and aggregate methods](#list-and-aggregate-methods).
|
||||
|
||||
#### Host access-check order
|
||||
|
||||
Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`) passes through one Gateway sequence:
|
||||
|
||||
| Step | Action | Failure |
|
||||
|---|---|---|
|
||||
| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` |
|
||||
| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) |
|
||||
| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — |
|
||||
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
|
||||
| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors |
|
||||
|
||||
The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`.
|
||||
|
||||
Gateway-owned endpoints: a non-owner may subscribe to `$events` but never receives waterfall frames it could answer; `$events/result` from a non-owner is refused with the existing `gateway/invocation-unavailable`; both run the step-1 check.
|
||||
|
||||
#### Forwarded events
|
||||
|
||||
An `emit` entry of `API_REMOTE_FORWARDED_EVENTS` may declare `resources`: readers keyed by resource kind and typed by that event's own parameters through `TypertForwardableEventEntry`, for example `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`. API Remotes attaches the readers to each forwarded frame; for every non-owner `$events` stream the Gateway resolves the resources once per frame and pushes the frame only when `remoteAccess.decide({ principal, action: 'event:<name>', resources })` allows it.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Declared events | `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session |
|
||||
| Undeclared resources | An event without `resources`, or with an empty `resources: {}`, touches `global` and reaches a non-owner only when a `global` role lists `event:<name>` |
|
||||
| Missing resource | An event carries every resource it declares, so a reader returning `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`; a call missing a resource touches `global` instead |
|
||||
| Whole or nothing | An allowed event arrives with its payload unchanged; no payload is trimmed or projected per Peer |
|
||||
| One resource per event | A notification carries at most one resource; collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()` |
|
||||
| Waterfalls | Non-owners never receive waterfall frames, and no policy decides them |
|
||||
| Non-owner stream facts | A non-owner `$events` stream opens with the same `host` as an owner stream, including `home`; `hasLiveClient()` counts only owner streams, the ones that can answer waterfalls |
|
||||
|
||||
<a id="list-and-aggregate-methods"></a>
|
||||
#### List and aggregate methods
|
||||
|
||||
Calls of list, paging, and aggregate methods resolve to `global` and are allowed by a `global` role; inside, the method calls `this.ctx.remoteAccess.allows(resource)` for every item to decide whether the current call's action is granted on that resource (without args). A list action therefore appears both in a `global` role (allowing the call) and in resource roles (allowing each item): `session/list` must be granted on `global` to be called, and each Session row also requires `session/list` granted on that Session. `allows()` reads the invocation on the Context that accessed the service; it is always true for the owner and for in-process code outside a Remote call, which are not filtered.
|
||||
|
||||
| Method | Filtered items |
|
||||
|---|---|
|
||||
| `session/list` | Session rows (`session` resource) |
|
||||
| `session/search` | search hits (`session` resource) |
|
||||
| `session/control` | baseline projections and projection increments (`session` resource) |
|
||||
| `workspace/follow` | Workspaces in the baseline, `upsert` frames, and `order` ids (`workspace` resource), the Sessions inside Workspaces, and the archived / pinned Session sets (`session` resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a `replace` increment with the complete filtered state instead of a `remove`, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary `upsert` once access returns. A `remove` frame reports a deletion and reaches only a caller that received that Workspace |
|
||||
|
||||
#### Access snapshot stream
|
||||
|
||||
```text
|
||||
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
|
||||
type RemoteAccessFrame =
|
||||
| { readonly type: 'baseline'; readonly owner: true }
|
||||
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
|
||||
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
|
||||
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
|
||||
|
||||
interface RemoteAccessGrant {
|
||||
readonly resource: RemoteAccessResource // global, or one declared-kind resource
|
||||
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
|
||||
}
|
||||
|
||||
/** Window global carrying a page's opening baseline. */
|
||||
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
```
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
| Increments | `invalidate()` or a policy registration or removal recomputes and pushes `upsert` / `remove`, and a resource whose allowed set becomes empty pushes `remove`; unchanged resources push nothing |
|
||||
| Lifetime | A generation ends when its signal aborts or when the remote-access service unloads, so a reloaded service answers every reader from a fresh baseline; the Client treats the end as a carrier loss and reconnects |
|
||||
| Owner | Receives only `{ owner: true }`, so every frontend answer is true |
|
||||
| Wire type | `actions` is `readonly string[]`: Typert cannot generate a codec for template-literal types |
|
||||
|
||||
Access hooks judge only by action and resource and never look at call arguments; at call time a condition may also read args for a finer decision, and the Host result prevails.
|
||||
|
||||
#### Client face
|
||||
|
||||
The Client uses framework standard-hook injection and no React context. Components receive hooks only through props and import no remote-access value.
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-api-remote-access/client
|
||||
interface ClientRemoteAccess { // Client service ctx.remoteAccess
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
}
|
||||
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
|
||||
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
|
||||
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
|
||||
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
|
||||
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
|
||||
|
||||
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
```
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Snapshot and mount | The remote-access Client plugin starts from the baseline the index injected (read synchronously at startup and validated), follows the snapshot stream, applies each baseline in one pass, provides the Client service `ctx.remoteAccess.source(kind, id, action)` (an identity-stable boolean source), and injects `useRemoteAccess` on the `global` resource. The entry module imports the generated `/remote` contribution and hands it with the injected baseline to `mountAccess(ctx, contribution, initial)`, which mounts it through `ctx.remote.$mount` (unit specs pass a fake contribution), following experimental client-ui-cot-translation, voice-input, and claude-code-mods; a central mount in api/remotes would form the project-reference cycle ui-session → api/remotes → remote-access → ui-session |
|
||||
| Session resource | ui-session gives the bound Session's answer through `uiSession.provide({ keyedHooks: ['access'], resolve })`; the framework binds the Session as it does for `useProjection`. `useSessionAccess` is injected through `provideRoot` |
|
||||
| Workspace resource | ui-workspace injects `useWorkspaceAccess` through `provideRoot` |
|
||||
| Distinct names | The `global` answer is `useRemoteAccess` and the Session answer is `useAccess`, so neither shadows the other; in a session-maybe scope without a Session, `useAccess` is `undefined`, which callers treat as not allowed, and a `global` decision reads `useRemoteAccess` |
|
||||
| Two-argument calls | Framework keyed hooks take a standard second argument: `KeyedStandardSource` is `(key, member?) => source`; a string second argument makes the hook read the resolver's `(key, member)` source without a selector, and the resolver returns the same source for the same arguments. Existing `(key, selector?, equal?)` and `(selector, equal?)` calls are unchanged. Curried forms are forbidden |
|
||||
| Return value | Every answer on a resource is a boolean; action types come from the `ctx.remote` namespaces and methods |
|
||||
| No fallback | Asking a Session resource about a non-session method (such as `useAccess('session/create')`) reads the decision on that Session resource and never falls back to the `global` answer |
|
||||
| No snapshot | True for the owner, also after its stream fails terminally; a non-owner page without an injected baseline, or whose stream fails terminally, answers false, and the terminal failure is reported with its error and the advice to reload; a reconnect keeps the last snapshot; a malformed injected baseline fails the mount; the Host still checks every call. Hooks never issue requests |
|
||||
| Example consumers | The ui-workspace Session row menu's fork entry reads `useSessionAccess(sessionId, 'session/fork')` and is not rendered when it is false; the ui-chat turn tail's branch button reads `useAccess('session/fork')` on the Session resource and is not rendered when it is false. Replacing #3854's `clientWritePolicy` stays with #3854 |
|
||||
| Tests | `@deepseek-ai/dsh-client-test-runtime` exports `allowAllAccess` props and `allowAllAccessPlugin`; TestClient drops the remote-access row as it drops api-remotes and installs the allow-all service, and SlotTestRuntime installs it before ui-session |
|
||||
|
||||
### Security invariants
|
||||
|
||||
| Invariant | Basis and enforcement point |
|
||||
|---|---|
|
||||
| Only in-process Host code supplies a principal, when it creates a Peer | `PeerScope.principal` is read-only; `createPeer()` has no route |
|
||||
| No wire field can set or override a principal | Principals never come from args, the envelope, headers, the query, or cookies. Extra top-level args fields fail `assertExactArguments`; the payload must be exactly `{ args }`; [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) drops unknown envelope fields; a same-named field inside a request body is business data |
|
||||
| Browser network admission yields the owner | Connections that pass [browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md) receive `browserPeer` |
|
||||
| No implicit Peer | Neither the Gateway nor Connection supplies a default Peer; required parameters expose a missing Peer at compile time |
|
||||
| A disposed Peer cannot dispatch | `gateway/peer-disposed` |
|
||||
| A non-owner cannot call without authorization | A call is allowed only when a binding's role explicitly lists the action and its resource matches; a kind without a policy is refused; the Gateway depends on `remoteAccess` and does not start without it |
|
||||
| A `global` grant implies no resource grant | Resources do not inherit; `allows()` inside a method decides each item independently |
|
||||
| An unknown principal kind is not the owner | Only `kind === 'owner'` is the owner |
|
||||
| Non-owners answer no waterfall | `$events` gives non-owners no waterfall frame, and `$events/result` refuses them |
|
||||
| A non-owner receives only authorized notifications | Each forwarded frame is decided by `event:<name>` and its resources, and reaches a non-owner only when allowed |
|
||||
|
||||
### Recording the message source
|
||||
|
||||
The `MessageSourceMap` entry in the [session-controller types](../../../../packages/api/session-controller/src/types.ts) gains an optional field:
|
||||
|
||||
```text
|
||||
'user-rpc': {
|
||||
kind: 'user'
|
||||
rpcId: SessionRequestId
|
||||
clientTimeZone?: string
|
||||
/** Non-owner caller that submitted this prompt; absent means the owner. */
|
||||
principal?: Principal
|
||||
}
|
||||
```
|
||||
|
||||
Two producers build a `kind: 'user'` + `rpcId` user-message source inside a Remote call. Both call `principalOf(this.ctx.invocation)` in the Remote method and write `principal` only when it is defined:
|
||||
|
||||
| Producer | Location |
|
||||
|---|---|
|
||||
| `SessionController.prompt()` builds the source and `SessionCommandController.prompt(request, source)` only delivers it | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts), [`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) |
|
||||
| subagent `prompt()` (`BrowserPromptSource`); no role lists it, so a non-owner reaches it only through in-process Host code, not through the Gateway | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) |
|
||||
|
||||
Business code does not pass a principal down as a function parameter: the identity is read once from the invocation in the Remote method, and the built source travels down. The Agent inbox has no interception point that could add the field on enqueue (`followup` / `steer` accept built messages, and in-process producers such as the SDK and scheduling run outside Remote calls), so each producer records it, and the code-review rule covers new producers.
|
||||
|
||||
| Fact | Detail |
|
||||
|---|---|
|
||||
| `kind` is unchanged | Still `'user'`; existing `kind === 'user'` checks are unaffected |
|
||||
| Owner messages are unchanged | An owner call writes no field, so its source is byte-identical to the source before principals existed; a message without the field (including every older Session) is the owner's |
|
||||
| Not part of model requests | LLM adapters read only the model source ([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) |
|
||||
| Session format | `SESSION_FORMAT_VERSION` is not bumped; per the [persistence type-change cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) the change records an acknowledgement |
|
||||
|
||||
Example consumer use, outside this Note's scope: tool guards and pre-step rechecks read `source.principal` of the user messages in a turn and judge by the non-owner caller when any message carries one; a turn mixing owner and viewer messages takes the strictest.
|
||||
|
||||
### Code-review rule and documentation
|
||||
|
||||
The review rule's home is the "`@Remote` marks capability methods" section of [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.md): `@Remote({ resources })` and automatic resolution must cover every resource a method touches; aggregate results filter every item through `allows()`; the caller identity is read only from `invocation.peer.principal`, never from arguments; every call path names its Peer explicitly; business code does not pass a principal as a function parameter.
|
||||
|
||||
| Document | Content |
|
||||
|---|---|
|
||||
| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.md), [`docs/api-gateway.md`](../../../../docs/api-gateway.md) | `PeerScope`, `RemoteInvocation`, `InvokeRemoteRequest`, explicit Peers, `resources` on `@Remote`, and the access-check order |
|
||||
| READMEs of connection, gateway, typert-protocol, typert-registry, api-remotes, remote-access, session-controller, workspace-controller, ui-session, ui-workspace, ui-slots, and client-test-runtime | Peer / `browserPeer` in place of the operator; the remote-access role, binding, and policy contract; access hooks |
|
||||
|
||||
### Out of scope and future work
|
||||
|
||||
- The API-only `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes are described in [Peer-scoped connect handshake](2026-10-09-operator-scoped-connect-handshake.md).
|
||||
- Multiple browser Peers (such as one per tab): every browser connection shares `browserPeer`.
|
||||
- Interaction participation is attached to each Peer. A non-owner with `interactions: 'answer'` still receives only RBAC-authorized broadcast frames and cannot answer owner waterfalls; per-principal approval handling remains outside this mechanism.
|
||||
- `agent-preset/selected` and the other forwarded events declare no resources and are decided on `global`; when its viewers need a Session event, #3854 declares that event's resources and lists `event:<name>` in a role.
|
||||
- Liveshare integration (declaring `share-viewer`, one Peer per viewer connection, defining roles and registering a policy, removing `origin` / `prompt/origin` / `READ_ENDPOINTS`) happens on the #3854 side.
|
||||
- `session/cancel` ownership checks, the fork `childSessionId`, and `ToolExecutionInput.turn` do not change.
|
||||
- Precise resource recognition (for example, the Typert generator locating request fields by branded id type and writing them into descriptors): this change uses the `resource` that lookups and Contexts declare, default fields, and `resources` readers, and resource resolution can later be replaced without changing the policy interface.
|
||||
- Answering 403 to every non-owner on exact Fetch routes is temporary; each route is to declare its own access rule later.
|
||||
- The resource declaration of subagent `prompt` is open: its request fields are `parentSessionId` / `childSessionId`, so it currently resolves to `global`. The producer records `source.principal` for in-process callers; no role lists it, so non-owners cannot reach it through the Gateway.
|
||||
- Predefined and aggregate roles: when a second non-owner principal kind appears, domain packages declare the generic roles a method belongs to (such as `session:viewer`) with `@Remote({ roles })`, and each policy binds those roles; roles are currently defined by the business package as explicit action lists.
|
||||
|
||||
### Relation to existing Notes
|
||||
|
||||
- [Remote duplex stream](../../implemented/architecture/2026-09-19-remote-duplex-stream.md): partially superseded. The replaced parts are "the Peer records no identity or permission", "this Host has exactly one Peer, the operator", "an in-process carrier without a Peer means the operator", and its alternative stating that "the Access mechanism is business semantics"; the rest (the duplex stream, uplink, and `ctx.invocation` binding) stands. Both Notes stay active and cross-linked, and that Note's current facts are updated.
|
||||
- [Browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md): unaffected. Its rejection of per-method privilege concerns owner authentication; an authenticated browser is still the owner and is not checked per method.
|
||||
- [Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md): unaffected; `@Remote` and `@RemoteScope` gain optional `resources` readers (the second argument of `@RemoteScope` may be `{ name?, resources? }`), read only by the Gateway's access check, and change neither endpoint naming, call modes, nor generated artifacts.
|
||||
- [Producer-owned message sources](../../implemented/architecture/2026-09-09-producer-owned-message-sources.md): unaffected; the producer that builds a source writes `principal`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate `prompt/origin` event correlated by requestId (the #3854 approach).** Every prompt writes a required event, so even ordinary Sessions become unreadable by older versions; correlation serializes every prompt and maintains an origin fold, and messages outside the fold window lose their initiator.
|
||||
|
||||
**Principal and peer as parallel invocation fields.** One caller's identity would have two sources; every admission point would have to produce and pass both, and a path that passes only one would mismatch them.
|
||||
|
||||
**Keep "the Peer records no identity".** When business plugins attach identity through `peer.ctx`, every reader must know who attached it and how to read it, and the wire admission path cannot guarantee the attachment happened.
|
||||
|
||||
**Keep a default-Peer fallback (`request.peer ?? …`).** An implicit default silently grants owner identity to a call that forgot its Peer; a required parameter turns the omission into a compile error.
|
||||
|
||||
**A new source kind.** Every existing `kind === 'user'` check would need review, and one miss makes viewer messages disappear from titles, outlines, queues, or chat rendering.
|
||||
|
||||
**Identity in a request-body field.** Any caller able to build a request body could forge or override it.
|
||||
|
||||
**React context for frontend resources.** Business components read no React context; the framework binds standard hooks per resource, and components receive them only through props.
|
||||
|
||||
**Driver-maintained endpoint allowlists that trim results (the #3854 approach).** The allowlist and the result trimming both live in the driver, the Gateway does not enforce them, and the snapshot and the frontend each derive their own; the generic layer enforces role action lists, and `allows()` inside the method performs list filtering.
|
||||
|
||||
**Business packages implement `decide()` / `abstain` providers whose answers are merged.** Each business package hand-writes action × resource branches plus a separate resource list for snapshots, and the two stay consistent only by hand; a policy gives only bindings, decisions and snapshots are computed from the same bindings, and each principal kind has one policy, so no merge rule is needed.
|
||||
|
||||
**A separate `@RemoteAccess` marker.** Role action lists already guarantee default deny, so a marker is only a second consent; a method that resolves no resource falls to `global`, and only an explicit global grant allows it. Resource readers describe the method, so they live on `@Remote`.
|
||||
|
||||
**Calling resource-less calls `page` or `host`.** `page` describes a frontend location, not a resource; `host` reads easily as the parent of every resource on this Host. `global` means a grant tied to no resource.
|
||||
|
||||
**Resource inheritance (GCP style: a grant on `global` or on a Workspace covers lower resources).** List methods allowed by a `global` grant must decide each item, and inheritance would make any `global` grant imply every Session; without inheritance, a binding grants each resource explicitly.
|
||||
|
||||
**A role registry and predefined aggregate roles.** The only non-owner principal today is managed by one business package, so roles can be plain values of that package; aggregate roles would let methods a new package declares enter a share viewer's reach automatically, so they wait for a second non-owner principal kind.
|
||||
|
||||
**Conditions attached to a role's actions.** A role stays reusable plain data; a refinement by business state belongs to the binding that grants the role to a principal.
|
||||
|
||||
**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it.
|
||||
|
||||
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field.
|
||||
|
||||
**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation.
|
||||
|
||||
**Generator recognition of resource fields by branded id type.** Requires changing Typert artifacts and the descriptor format, and SRC descriptors carry no types; kept as future work.
|
||||
|
||||
**A central mount of the remote-access generated contribution in api/remotes.** The remote-access Client depends on ui-session and ui-session depends on api/remotes, so a central mount forms a project-reference cycle; remote-access mounts its own contribution instead.
|
||||
|
||||
**Decisions inside the Gateway (a `check()` with rules written in the Gateway).** The Gateway would carry access-control semantics and extra error codes; it resolves resources as it resolves lookups and calls `remoteAccess.decide()` before dispatch, and the rules and refusal code belong to remote-access.
|
||||
|
||||
**Asynchronous policy decisions.** List filtering and snapshot computation need synchronous answers; business packages preload asynchronous state into memory.
|
||||
|
||||
**On-demand permission queries from the frontend.** Request volume grows with the number of entries and cannot follow rule changes; the Host pushes a snapshot stream and the frontend reads it synchronously.
|
||||
|
||||
**An access check after lookup resolution.** A lookup can activate an Agent, so an unauthorized call would cause side effects first.
|
||||
|
||||
**Resource fields registered in remote-access (`registerScope`).** The access package would recognize argument fields although lookups already know which wire field names a Session; typert holds resource kinds beside lookups and Contexts, so the Gateway resolves calls and events in one place and remote-access reads no argument field.
|
||||
|
||||
**Trimming forwarded event payloads per Peer.** A per-event projection would repeat list filtering inside every notification; an event carries at most one resource and is delivered whole or not at all, while collection changes travel on follow streams that filter inline.
|
||||
|
||||
**Answering true until the first snapshot arrives.** A non-owner page would briefly show entries it cannot use; the index carries the first snapshot, so a page without one has no access.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Verification
|
||||
|
||||
| Case | Behavior | Test location |
|
||||
|---|---|---|
|
||||
| The wire tries to set a principal (top-level args, a payload sibling, the envelope, inside the request body) | The first two fail argument or payload validation; the rest are dropped or treated as business data; `invocation.peer` remains the admitted Peer | Gateway host spec |
|
||||
| Every call path names its Peer | `InvokeRemoteRequest.peer`, the `wireStream.open` peer, `fetch(request, peer)`, and the `bridge` peer are required; the Gateway has no fallback; the `/api` route passes `admission.peer` | typecheck, Connection host spec |
|
||||
| Browser connections and `createPeer()` | `browserPeer` has the owner principal; each `createPeer()` returns an independent Peer released with the Context that read Connection; exact routes answer 403 to non-owners | Connection host spec |
|
||||
| Disposed Peers | Later unary, stream, and `$events` dispatches fail with `gateway/peer-disposed`, also when the wire payload is malformed; its streams end; a running unary call completes; other Peers are unaffected | Gateway host spec |
|
||||
| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec |
|
||||
| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec |
|
||||
| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec |
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
| Access hooks | `source()`, `useRemoteAccess`, `useAccess`, `useSessionAccess`, and `useWorkspaceAccess` follow the snapshot; `useAccess` on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec |
|
||||
| Two-argument keyed hooks | A string second argument reads the `(key, member)` source; existing call forms are unchanged | ui-renderer client spec |
|
||||
| A non-owner dispatches `session/prompt` | The recorded user message's `source.principal` equals that principal, and a cold restore reads the same value | session-controller host spec |
|
||||
| An in-process non-owner invocation of subagent `prompt` | The child Session's recorded user message has `source.principal` equal to that principal | subagent host spec |
|
||||
| Owner messages | The source is byte-identical to the source before this change; existing keyless recorded-session snapshots are unchanged | `pnpm run test:snapshot` needs no re-recording |
|
||||
| Persistence record | `verify-persistence-changes` passes | doc-sync |
|
||||
|
||||
The SDK projections are unaffected: the TypeScript SDK server builds its own `{ kind: 'user' }` messages ([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)) outside the Gateway and does not project the source; the Python SDK does not read the user source.
|
||||
|
||||
### Costs and limits
|
||||
|
||||
- The change spans protocol, Connection, Gateway, two domain controllers, subagent, a new package, and the frontend, with breaking API renames; it necessarily conflicts with pr-5945 in Connection and the Gateway, handled by the migration list.
|
||||
- The Gateway depends on `remoteAccess`: a custom composition that mounts the Gateway without remote-access leaves the Gateway waiting.
|
||||
- Session logs store viewer identities and travel with exported or shared Sessions, so no principal kind may carry a credential.
|
||||
- Every browser connection shares one Peer, so disposal cannot distinguish tabs until multiple browser Peers exist.
|
||||
- The default field convention reads by field name: a method that gains a same-named field touches that resource, which review must watch; precise recognition is future work.
|
||||
- A method that resolves only some resources lets a principal exceed its grant: unresolved resources go unchecked, and the method author ensures `resources` is complete.
|
||||
- Role actions are strings and are not checked against loaded endpoints: a misspelled or renamed action never matches and shows up as a refusal.
|
||||
- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns the Session's `parentSessionId`.
|
||||
- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the Gateway does not hide `home` from non-owner `$events` streams separately.
|
||||
- `workspace/follow` notices a Workspace whose visibility a policy revoked only at that Workspace's next change or the next order change, and then sends the non-owner a `replace` increment; an access change alone sends no frame.
|
||||
|
||||
### Migration list for pr-5945
|
||||
|
||||
| File | Migration |
|
||||
|---|---|
|
||||
| `packages/client/connection/src/peer.ts` | Replaces `operator-peer.ts`; construct `OperatorPeer` as `new Peer(owner, { principal })` |
|
||||
| `packages/client/connection/src/rpc-host.ts` | `this.operator` becomes `browserPeer` with principal `{ kind: 'owner' }` |
|
||||
| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` merges `admitConnection` with `browserPeer` / `createPeer` |
|
||||
| `packages/api/gateway/src/index.ts` | Replace `peer ?? this.operatorPeer()` where `$events` opens with an explicit Peer; reuse `gateway/peer-disposed` |
|
||||
| The operator-connect Note, upgrade guide, and subsystem changes | Rewrite operator terms as Peer / `browserPeer`; an API connection's Peer has principal `{ kind: 'owner' }` |
|
||||
+675
@@ -0,0 +1,675 @@
|
||||
# Agent Note: Peer 身份属性与 Remote 方法级访问控制
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-peer-principal-and-remote-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Host 上的 Remote 方法此前无法得知本次调用代表谁,也无法按调用方限制可调用的方法。[`PeerScope`](../../../../packages/typert/protocol/src/types.ts) 的契约曾写明「Who the Peer is and what it may do are not recorded here」;[Connection](../../../../packages/client/connection/src/rpc-host.ts) 只有一个 operator Peer,`admit()` 对每个通过认证的请求都返回它,`/api` 路由与 interceptor 的 Fetch handler 固定使用它而不使用准入结果;[Gateway](../../../../packages/api/gateway/src/index.ts) 在调用方未给出 Peer 时隐式回退到 `operatorPeer()`。
|
||||
|
||||
首个非 owner 调用方是 liveshare 读者。读者请求经 Relay 到达 owner 的 liveshare Host 插件,插件此前在 owner 进程内以 operator 身份分派,owner 的 Session 因此把读者的 prompt 当作 owner 本人的输入。PR #3854 为此把 liveshare 语义塞进 session-controller:请求体携带 `origin`,每条 prompt 都写 required 的 `prompt/origin` 事件并按 requestId 关联,controller 内做来源 fold,`serializeAdmission` 扩到全部 prompt,新增 `api-session/external-admission` 钩子;liveshare 自己维护接口白名单 `READ_ENDPOINTS` 并裁剪 list/follow 结果;前端的 `clientWritePolicy` 定义在 ui-conversation,5 个包三种读法,7 处靠「是否注入某回调」隐藏界面。
|
||||
|
||||
需要一套通用机制:
|
||||
|
||||
- 调用方身份由 Host 进程内代码赋予,wire 上任何字段都无法设置或覆盖。
|
||||
- 每条调用路径都显式给出 Peer,不存在隐式兜底。
|
||||
- 方法级权限在后端一处计算、一处把关;前端读同一份结果决定入口显隐,规则变化时自动同步。
|
||||
- prompt 投递时身份随用户消息落盘,供之后按 turn 判定发起者。
|
||||
|
||||
## 决策
|
||||
|
||||
### 范围
|
||||
|
||||
| 部分 | 内容 |
|
||||
|---|---|
|
||||
| Peer 身份 | `PeerScope.principal` 与 `PeerScope.signal`;唯一 Peer 实现 `Peer`(`OperatorPeer` 改名)与 options 构造;删除 operator 概念与所有隐式 Peer 兜底;`connection.browserPeer`、`connection.createPeer()` 与 `fetch(request, peer)`;已 dispose 的 Peer 一律以 `gateway/peer-disposed` 拒绝;`user-rpc` source 落盘 `principal?` |
|
||||
| 方法级访问控制 | `@Remote` 的 `resources` 读取函数、`TypertResourceMap` 资源种类与 `typert.resources` 注册表(typert-protocol、typert-registry);Gateway 把调用与转发事件解析为资源;新包 `@deepseek-ai/dsh-api-remote-access`(Host 面 + Client 面):按 role、binding 与 policy 的 RBAC 判定 `decide(request)`、列表过滤 `allows()`、index 中的首份快照与访问快照流、access hooks |
|
||||
| 文档 | 一条代码评审标准与术语 |
|
||||
|
||||
### 两层概念:principal 与 peer
|
||||
|
||||
| 层 | 概念 | 例子 | 数量关系 |
|
||||
|---|---|---|---|
|
||||
| 授权层 | principal:调用方身份,权限判定的依据 | 一份分享授权 token 证明的 `share-viewer{shareId, actor}`;本机用户 `{ kind: 'owner' }` | 一个 principal 对应 N 个 peer |
|
||||
| 连接层 | peer:一条连接的生命周期作用域 | 浏览器连接、liveshare 读者的一条连接 | 每条连接一个 peer |
|
||||
|
||||
| 事项 | 归属层 |
|
||||
|---|---|
|
||||
| 方法级权限判定、access hooks、访问快照 | principal |
|
||||
| `source.principal` 落盘 | principal |
|
||||
| 订阅、stream、连接期注册(`peer.ctx`) | peer |
|
||||
| 撤销授权 | 两层:撤权后 policy 不为该 principal 给出 binding,其调用一律被拒;创建方 dispose 其名下全部 peer |
|
||||
|
||||
principal→peers 的对应表由创建方持有,例如 liveshare 每份授权一个对象,记录该授权下建立的全部 peer。
|
||||
|
||||
### Peer 模型
|
||||
|
||||
只有 Peer 一个概念、一个实现,删除 operator 概念。
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
/** Connection-lifetime registrations; disposed with the Peer. */
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
|
||||
readonly signal: AbortSignal
|
||||
/** Forwarded human-interaction participation; independent of authorization. */
|
||||
readonly interactions: 'answer' | 'none'
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
// @deepseek-ai/dsh-client-connection
|
||||
export class Peer implements PeerScope {
|
||||
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
|
||||
}
|
||||
|
||||
interface HostConnectionHandle {
|
||||
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
|
||||
readonly browserPeer: PeerScope
|
||||
/** Network admission: the admitted Peer or the refusal status. */
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
|
||||
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
|
||||
}
|
||||
|
||||
interface ConnectionFetchHandler {
|
||||
fetch(request: Request, peer: PeerScope): Promise<Response>
|
||||
}
|
||||
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
|
||||
|
||||
// @deepseek-ai/dsh-api-gateway
|
||||
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
|
||||
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 每条路径显式给出 Peer | 网络路径由 `admit()` 的结果给出;进程内路径由调用方传入。Gateway 删除 `operatorPeer()` 与 `request.peer ?? …`,不再自建 Peer |
|
||||
| 浏览器连接共享 `browserPeer` | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 `connection.browserPeer`,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 `browserPeer` |
|
||||
| Host 进程内自动化 | 需要调用 Remote 方法时用 `createPeer({ principal })` 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer |
|
||||
| 多 Peer | `createPeer()` 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) |
|
||||
| 交互参与 | `Peer(owner, { principal, interactions? })` 与 `createPeer()` 省略 interactions 时默认 `answer`。`none` 对所有 principal 拒绝 `$events` 与 `$events/result`;交互模式不赋予方法权限。回答型 owner 能接收并回答 waterfall,非 owner 只能接收 RBAC 授权的广播,不能回答 |
|
||||
| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 |
|
||||
|
||||
### 身份类型
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
|
||||
/**
|
||||
* Merge-extensible caller identities. Each driver declares its own entry.
|
||||
* Every entry is a JSON value with a unique literal `kind`, carries no
|
||||
* credential, and is safe to persist in a Session log.
|
||||
*/
|
||||
export interface PrincipalMap {
|
||||
owner: { readonly kind: 'owner' }
|
||||
}
|
||||
export type Principal = PrincipalMap[keyof PrincipalMap]
|
||||
|
||||
/**
|
||||
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
|
||||
* @returns the caller's principal when it is not the owner; `undefined` for the
|
||||
* owner and for trusted in-process code outside a Remote call.
|
||||
*/
|
||||
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
|
||||
```
|
||||
|
||||
读取入口统一为 `this.ctx.invocation.peer.principal`;owner 判断就地写 `principal.kind === 'owner'`,未知 kind 永远不是 owner。外部 driver 在自己的包内用声明合并扩展 `PrincipalMap`;liveshare 的 `share-viewer{ shareId, actor }` 在 #3854 侧声明,此处不改 liveshare。
|
||||
|
||||
### Peer 生命周期与撤权
|
||||
|
||||
| 事件 | 行为 |
|
||||
|---|---|
|
||||
| Gateway 分派 | 第一步检查 `peer.signal.aborted`,已 dispose 即以 `gateway/peer-disposed` 拒绝。对 HTTP、WebSocket 与进程内路径都生效;wire 路径在读取 payload 之前检查,已 dispose 的 Peer 携带畸形 payload 也得到同一错误码 |
|
||||
| Peer dispose 时的 stream | Remote stream 方法看到的 signal 并入 `peer.signal`,`$events` 流的生命周期同样并入;WebSocket 照旧以 1001 关闭 |
|
||||
| Peer dispose 时的 unary | 已在执行的 unary 调用不被取消;之后的调用被拒绝 |
|
||||
| 撤销授权 | 业务包撤权后 policy 不为该 principal 给出 binding,业务包调用 `invalidate(principal)`;创建方按 principal→peers 表 dispose 全部 peer |
|
||||
|
||||
### 方法级访问控制
|
||||
|
||||
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
|
||||
|
||||
#### 与 RBAC 的对应
|
||||
|
||||
| 本设计 | RBAC / IAM 术语 | 含义 |
|
||||
|---|---|---|
|
||||
| principal | Subject / Principal | 调用方身份,`PrincipalMap` 的一个条目 |
|
||||
| Peer | Session(RBAC 会话) | principal 的一条连接;一个 principal 对应 N 个 Peer |
|
||||
| owner | 超级用户 | 跳过全部检查 |
|
||||
| action | Action / Permission | Remote 端点 `ns/method`,或转发事件 `event:<name>`;Gateway 内部仍称端点 |
|
||||
| resource | Resource / Object | `{ kind: 'global' }`,或某个已声明资源种类的 `{ kind, id }` |
|
||||
| role | Role | 限定一种资源种类的 action 集合,是业务包的普通值 |
|
||||
| binding | RoleBinding(角色指派) | principal 在某个资源上拥有某个 role,可附 condition |
|
||||
| condition | Condition(IAM Conditions) | 挂在 binding 上,按请求的 action、资源与参数放行或带原因拒绝 |
|
||||
| policy | Allow policy | 业务包为一种非 owner principal kind 给出 binding 与可选 `explain` |
|
||||
| explain | 拒绝说明 | 没有 binding 放行时给出拒绝原因,不改变结论 |
|
||||
| Gateway | PEP(Policy Enforcement Point) | 解析资源、执行结论 |
|
||||
| remote-access | PDP(Policy Decision Point) | 计算结论、列表过滤与访问快照 |
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 默认拒绝 | 非 owner 只有在某个 binding 的 role 显式列出该 action 时才被放行;role 未列出的 action、没有 binding 的资源、没有 policy 的 principal kind 一律被拒。所有 Remote 方法都受访问控制,没有单独的可访问标记 |
|
||||
| 资源之间无继承 | `global` 不是其他资源的上级:`global` 上的授权不蕴含任何会话或工作区上的授权,工作区上的授权也不蕴含其中会话的授权 |
|
||||
| `global` 授权即显式全局授权 | 调用解析不出任何资源即访问 `global`。把某个 action 放进 `global` 级 role,等于允许该 principal 不受资源限制地调用它,属高危授权;方法被放行后仍可在内部用 `allows(resource)` 逐项细判 |
|
||||
|
||||
#### 资源与资源读取(`@deepseek-ai/dsh-typert-protocol`、`@deepseek-ai/dsh-typert-registry`)
|
||||
|
||||
```text
|
||||
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
|
||||
export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
export interface TypertHostContextAdapter<Wire = unknown> {
|
||||
readonly wire: string
|
||||
readonly wireTypeSymbol: string
|
||||
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
|
||||
readonly resource?: TypertResourceKind
|
||||
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
|
||||
}
|
||||
|
||||
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
|
||||
export type RemoteAccessResource =
|
||||
| { readonly kind: 'global' }
|
||||
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
|
||||
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
|
||||
|
||||
/** Per-kind readers, called with the method's positional wire arguments. */
|
||||
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
|
||||
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
|
||||
}
|
||||
|
||||
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
|
||||
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function RemoteScope<This extends object, Args extends unknown[], Result>(
|
||||
key: Extract<keyof TypertContextMap, string>,
|
||||
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
```
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
|
||||
| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource<SessionId>` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource<WorkspaceId>` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 |
|
||||
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` |
|
||||
| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
|
||||
| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
|
||||
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` |
|
||||
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) |
|
||||
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 |
|
||||
| 完整性 | `resources` 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 `global`,只有 `global` 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 |
|
||||
| 类型推断 | `@Remote` 与 `@RemoteScope` 读取函数的参数类型从被装饰方法自动推断:工厂参数用 `NoInfer`,`Args` 只从返回的装饰器推断;lookup 参数按 `TypertLookupMap` 映射为 wire 类型(首个参数为 `agent: Agent` 的方法,其读取函数的第一个参数为 `SessionId`)。访问不存在的属性、参数多于方法参数与 `global` 键均为编译错误 |
|
||||
| 存放 | `resources` 读取函数与 `@Remote` / `@RemoteScope` 的其他元数据一同存在类原型上,随实例构造登记,跨重复安装安全;不进入 Typert 生成物 |
|
||||
| Context 方法 | `@RemoteScope(key, option?)` 与 `@Remote` 一致:字符串为导出名,对象为 `{ name?, resources? }`。Context 的 wire 身份按声明了 `resource` 的 lookup 参数参与解析,优先级与 lookup 相同,该种类有 `resources` 读取函数时被覆盖;Gateway 在 `resolveReceiverContext` 之前直接从 args 读取该身份,不激活 Agent。Context 未声明 `resource` 时其身份不贡献资源,方法按普通规则解析 |
|
||||
|
||||
#### 端点的资源
|
||||
|
||||
下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段:
|
||||
|
||||
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|
||||
|---|---|---|---|
|
||||
| `workspace/follow` | `global` | 无资源 | 是:工作区与会话 |
|
||||
| `session/control` | `global` | 无资源 | 是:会话 |
|
||||
| `session/list` | `global`(`_request` 无 `sessionId`) | 无资源 | 是:会话行 |
|
||||
| `session/modelCatalog` | `global` | 无资源 | 否 |
|
||||
| `session/canOpenWorkspacePath` | `global` | 无资源 | 否 |
|
||||
| `llm/listConfigurableProviders` | `global` | 无资源 | 否 |
|
||||
| `llm/listProviders` | `global` | 无资源 | 否 |
|
||||
| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 |
|
||||
| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 |
|
||||
| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 |
|
||||
| `session/follow` | 同上 | `resources` | 否 |
|
||||
| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 |
|
||||
|
||||
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
|
||||
|
||||
不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。
|
||||
|
||||
#### role、binding 与 policy(`@deepseek-ai/dsh-api-remote-access`)
|
||||
|
||||
```text
|
||||
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
|
||||
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
|
||||
|
||||
/** A named action set over one resource kind; a plain value owned by the business package. */
|
||||
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
|
||||
readonly name: string
|
||||
readonly resource: K
|
||||
readonly actions: readonly RemoteAccessAction[]
|
||||
}
|
||||
|
||||
/** Answer of decide() and of a condition. */
|
||||
export type RemoteAccessDecision =
|
||||
| { readonly kind: 'allow' }
|
||||
| { readonly kind: 'deny'; readonly reason: string }
|
||||
|
||||
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
|
||||
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
|
||||
readonly action: RemoteAccessAction
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}) => RemoteAccessDecision
|
||||
|
||||
/** One role on one resource of the role's kind; the resource type follows from the role. */
|
||||
export type RemoteAccessBinding = {
|
||||
[K in RemoteAccessResourceKind]: {
|
||||
readonly role: RemoteAccessRole<K>
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly condition?: RemoteAccessCondition<K>
|
||||
}
|
||||
}[RemoteAccessResourceKind]
|
||||
|
||||
/** One access question; the Gateway passes only valid resources, [global] when none. */
|
||||
export interface RemoteAccessRequest<P extends Principal> {
|
||||
readonly principal: P
|
||||
readonly action: RemoteAccessAction
|
||||
/** Allow only when the action is allowed on every listed resource. */
|
||||
readonly resources: readonly RemoteAccessResource[]
|
||||
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
|
||||
export interface RemoteAccessPolicy<P extends Principal> {
|
||||
bindings(principal: P): readonly RemoteAccessBinding[]
|
||||
/** Reason for a request no binding allows; never changes the decision. */
|
||||
explain?(request: RemoteAccessRequest<P>): string | undefined
|
||||
}
|
||||
|
||||
/** details of `remote-access/denied` */
|
||||
export interface RemoteAccessDeniedDetails {
|
||||
readonly action: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
|
||||
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
|
||||
invalidate(principal?: Principal): void
|
||||
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
|
||||
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
|
||||
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
|
||||
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
|
||||
}
|
||||
```
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| owner | Gateway 对 owner 不解析资源、不调用 `decide()`,也不读取任何 policy |
|
||||
| 注册 | `registerPolicy(kind, policy)` 返回 disposer,业务包在 `ctx.effect()` 中调用。每个非 owner principal kind 至多一个 policy,同一 kind 重复注册抛错;`bindings()` 与 `explain()` 收到的 principal 按 kind 定型 |
|
||||
| 内置放行 | `remoteAccess/follow` 对任何 principal 放行,每个 Peer 都能读自己的快照 |
|
||||
| 判定 | 没有该 principal kind 的 policy 以 `no-allow` 拒绝。否则取 `bindings(principal)`,对请求的每个资源线性扫描找一个 binding:资源相等(`kind` 相同,且非 `global` 时 `id` 相同)、role 的 `actions` 含该 action、没有 condition 或 condition 放行;每个资源都找到才放行(AND)。condition 只对资源相等且 role 含该 action 的 binding 调用 |
|
||||
| 拒绝原因 | 第一个未被放行的资源决定原因,优先级为:该资源上存在匹配 binding 但其 condition 全部拒绝时,取第一个 condition 的原因;否则取 `explain(request)` 的返回值;没有 `explain` 或其返回 `undefined` 时为 `no-allow`。`explain` 只在结论已为拒绝后调用,不改变结论 |
|
||||
| 失败隔离 | `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时 `bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染;每次 `invalidate()` 或 policy 变更之后,每个 policy 的第一次失败只记录一次日志 |
|
||||
| 同步 | `bindings()`、condition 与 `explain()` 同步返回,状态由业务包预先载入内存,以便列表逐项过滤与快照计算 |
|
||||
| 求值时机 | 每次 `decide()`、`allows()` 与快照计算都调用一次 `bindings()`,不缓存;依赖时间的状态(如过期)即时生效,长流(如 `workspace/follow`、`session/control`)在 binding 增删后的下一次逐项判定即反映;快照只在 `invalidate()` 后重算,业务包在过期等时间事件发生时也须调用 `invalidate()` |
|
||||
| 通知 | 业务状态变化(授权、撤权、模式切换)改变 binding 或 condition 的结论时,业务包必须调用 `invalidate(principal?)`;快照只在 `invalidate()` 与 policy 注册、撤回后重算 |
|
||||
| `remote-access/denied` | 只有调用的拒绝变成错误,details `{ action, reason }`,reason 为 condition 或 `explain` 给出的原因、`no-allow`、`policy-failed`,或 Gateway 对非法资源给出的 `resource-invalid`;被拒的事件只是不推送 |
|
||||
| 必需服务 | Gateway `static inject = ['typert', 'remoteAccess']`;挂载 Gateway 的组合必须挂载 remote-access,[`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配;plugin manager 像保护 Gateway 一样保护它,插件页面无法禁用 |
|
||||
|
||||
#### 示例:分享读者
|
||||
|
||||
以 liveshare 声明的 `share-viewer` principal 为例,说明业务状态到 binding 的推导留在业务包;示例中的 `shares` 代表业务包在内存中持有的分享状态:
|
||||
|
||||
```text
|
||||
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
|
||||
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
|
||||
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
|
||||
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
|
||||
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
|
||||
|
||||
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
|
||||
bindings(viewer) {
|
||||
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
|
||||
if (share === undefined) return []
|
||||
const source = { kind: 'session', id: share.sessionId }
|
||||
return [
|
||||
{ role: globalReader, resource: { kind: 'global' } },
|
||||
{ role: sessionReader, resource: source },
|
||||
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
|
||||
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
|
||||
...shares.forksOf(viewer).flatMap(child => [
|
||||
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
|
||||
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
|
||||
]),
|
||||
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
|
||||
]
|
||||
},
|
||||
explain({ principal, action }) {
|
||||
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
|
||||
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
|
||||
},
|
||||
}))
|
||||
|
||||
// Without args (snapshots, list filtering) a condition answers for the action alone.
|
||||
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
|
||||
const childPrompt = child => ({ action, args }) =>
|
||||
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
|
||||
```
|
||||
|
||||
| 归属 | 内容 |
|
||||
|---|---|
|
||||
| 业务包 | role 的 action 清单;分享状态到 binding 的推导(停止、过期、准入、模式、读者自己的分支、撤销、包含可见会话的工作区);按请求细化的 condition;面向读者页面的拒绝原因 |
|
||||
| 通用层 | 资源解析、按资源 AND、role 与 action 匹配、condition 调用、原因优先级、列表过滤、快照计算与失败隔离 |
|
||||
|
||||
该读者的快照包含 `global`、源会话、自己的每个分支与每个可见工作区上的 grant;`session/list` 同时出现在 `global` 级与 session 级 role 中,见[列表与聚合方法](#list-and-aggregate-methods)。
|
||||
|
||||
#### Host 侧鉴权调用顺序
|
||||
|
||||
所有调用路径(HTTP `/api`、WebSocket stream、进程内 `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`)都经过同一段 Gateway 逻辑:
|
||||
|
||||
| 步 | 动作 | 失败结果 |
|
||||
|---|---|---|
|
||||
| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` |
|
||||
| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) |
|
||||
| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — |
|
||||
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
|
||||
| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 |
|
||||
|
||||
鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。
|
||||
|
||||
Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回答的 waterfall 帧;`$events/result` 对非 owner 以既有的 `gateway/invocation-unavailable` 拒绝;两者都做第 1 步的检查。
|
||||
|
||||
#### 转发事件
|
||||
|
||||
`API_REMOTE_FORWARDED_EVENTS` 的 `emit` 条目可以声明 `resources`:以资源种类为键、经 `TypertForwardableEventEntry` 按该事件自身参数定型的读取函数,例如 `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`。API Remotes 把读取函数附在每个转发帧上;对每条非 owner 的 `$events` 流,Gateway 每帧解析一次资源,只有 `remoteAccess.decide({ principal, action: 'event:<name>', resources })` 允许时才推送。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 已声明事件 | `api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session |
|
||||
| 未声明资源 | 未声明 `resources` 或声明为空 `resources: {}` 的事件访问 `global`,只有 `global` 级 role 列出 `event:<name>` 时才送达非 owner |
|
||||
| 缺失资源 | 事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner;调用缺少资源时则为 `global` |
|
||||
| 整条放行或拒绝 | 允许的事件 payload 原样送达;不按 Peer 裁剪或投影 payload |
|
||||
| 每个事件一个资源 | 通知至多携带一个资源;集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定 |
|
||||
| waterfall | 非 owner 从不收到 waterfall 帧,不经 policy 判定 |
|
||||
| 非 owner 流的 Host 信息 | 非 owner 的 `$events` 流与 owner 流一样以完整 `host`(含 `home`)打开;`hasLiveClient()` 只统计能回答 waterfall 的 owner 流 |
|
||||
|
||||
<a id="list-and-aggregate-methods"></a>
|
||||
#### 列表与聚合方法
|
||||
|
||||
列表、分页与聚合类方法的调用解析为 `global`,由 `global` 级 role 放行;方法内部对每一项调用 `this.ctx.remoteAccess.allows(resource)`,判定当前调用的 action 在该资源上是否被授权(不带 args)。因此列表类 action 同时出现在 `global` 级 role(放行调用)与资源级 role(放行逐项)中:`session/list` 在 `global` 上被授权才能调用,每个会话行还要求 `session/list` 在该会话上被授权。`allows()` 读访问该服务的 Context 上的 invocation,owner 与 Remote 调用之外的进程内代码恒为 true,不过滤。
|
||||
|
||||
| 方法 | 过滤对象 |
|
||||
|---|---|
|
||||
| `session/list` | 会话行(`session` 资源) |
|
||||
| `session/search` | 搜索命中(`session` 资源) |
|
||||
| `session/control` | baseline 的 projections 与 projection 增量(`session` 资源) |
|
||||
| `workspace/follow` | baseline、`upsert` 帧与 `order` id 中的工作区(`workspace` 资源)、工作区内会话、archived / pinned 会话集合(`session` 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该工作区的调用方 |
|
||||
|
||||
#### 访问快照流
|
||||
|
||||
```text
|
||||
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
|
||||
type RemoteAccessFrame =
|
||||
| { readonly type: 'baseline'; readonly owner: true }
|
||||
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
|
||||
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
|
||||
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
|
||||
|
||||
interface RemoteAccessGrant {
|
||||
readonly resource: RemoteAccessResource // global, or one declared-kind resource
|
||||
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
|
||||
}
|
||||
|
||||
/** Window global carrying a page's opening baseline. */
|
||||
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
```
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
| 增量 | `invalidate()` 或 policy 注册、撤回时重算并推送 `upsert` / `remove`,放行集合变空的资源推送 `remove`;未变化的资源不推送 |
|
||||
| 生命周期 | 一代快照流在其 signal abort 或 remote-access 服务卸载时结束,因此重载后的服务从新的 baseline 回答每个读者;Client 把结束视为载体中断并重连 |
|
||||
| owner | 只推送 `{ owner: true }`,前端全部为 true |
|
||||
| wire 类型 | `actions` 为 `readonly string[]`:Typert 无法为模板字面量类型生成 codec |
|
||||
|
||||
access hooks 只按 action 与资源判断,不看调用参数;调用时 condition 可额外读取 args 做更细判断,以服务端结果为准。
|
||||
|
||||
#### Client 面
|
||||
|
||||
Client 面走框架标准 hook 注入,不引入 React context。组件只经 props 拿到 hook,不 import remote-access 的值。
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-api-remote-access/client
|
||||
interface ClientRemoteAccess { // Client service ctx.remoteAccess
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
}
|
||||
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
|
||||
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
|
||||
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
|
||||
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
|
||||
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
|
||||
|
||||
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
```
|
||||
|
||||
| 事项 | 定稿 |
|
||||
|---|---|
|
||||
| 快照与挂载 | remote-access Client 插件从 index 注入的 baseline 起步(启动时同步读取并校验),订阅快照流,对每个 baseline 一次构建完成,提供 Client 服务 `ctx.remoteAccess.source(kind, id, action)`(身份稳定的布尔 source)并注入 `global` 资源上的 `useRemoteAccess`。入口模块 import 生成的 `/remote` contribution,连同注入的 baseline 交给 `mountAccess(ctx, contribution, initial)` 经 `ctx.remote.$mount` 自行挂载(单测传入假 contribution),先例为 experimental client-ui-cot-translation、voice-input、claude-code-mods;若由 api/remotes 集中挂载,会形成 ui-session → api/remotes → remote-access → ui-session 的 project reference 环 |
|
||||
| 会话资源 | ui-session 经 `uiSession.provide({ keyedHooks: ['access'], resolve })` 给出本会话答案,会话由框架绑定,与 `useProjection` 相同;`useSessionAccess` 经 `provideRoot` 注入 |
|
||||
| 工作区资源 | ui-workspace 经 `provideRoot` 注入 `useWorkspaceAccess` |
|
||||
| 名称区分 | `global` 答案是 `useRemoteAccess`,会话答案是 `useAccess`,两者互不遮蔽;session-maybe 无会话时 `useAccess` 为 `undefined`,调用方按不允许处理,需要 `global` 判断时读 `useRemoteAccess` |
|
||||
| 双参数调用 | 框架 keyed hook 的标准双参数:`KeyedStandardSource` 为 `(key, member?) => source`;第二个参数为字符串时 hook 读取 resolver 对 `(key, member)` 返回的 source,不带 selector;resolver 对同一参数返回同一 source。既有 `(key, selector?, equal?)` 与 `(selector, equal?)` 调用不变。禁止柯里化形式 |
|
||||
| 返回值 | 有资源的答案均为布尔值;action 类型来自 `ctx.remote` 的 namespace 与方法 |
|
||||
| 不回退 | 会话资源上查询非会话方法(如 `useAccess('session/create')`)读该会话资源上的判定结果,不回退 `global` 答案 |
|
||||
| 无快照 | owner 为 true,快照流终止失败后亦然;没有注入 baseline 或快照流终止失败的非 owner 页面为 false,终止失败会连同错误与“刷新页面”的提示一并报告;断线重连期间保留上次快照;注入的 baseline 格式错误时挂载失败;Host 仍对每次调用执行检查。hook 从不发请求 |
|
||||
| 示例消费方 | ui-workspace 会话行菜单的 fork 入口读 `useSessionAccess(sessionId, 'session/fork')`,不允许时不渲染;ui-chat turn tail 的分叉按钮读会话资源上的 `useAccess('session/fork')`,不允许时不渲染。替换 #3854 的 `clientWritePolicy` 留给 #3854 |
|
||||
| 测试 | `@deepseek-ai/dsh-client-test-runtime` 导出 `allowAllAccess` props 与 `allowAllAccessPlugin`;TestClient 像 api-remotes 一样去掉 remote-access 行并装入全允许服务,SlotTestRuntime 在 ui-session 之前装入 |
|
||||
|
||||
### 安全不变量
|
||||
|
||||
| 不变量 | 依据与落点 |
|
||||
|---|---|
|
||||
| principal 只能由进程内 Host 代码在创建 Peer 时给出 | `PeerScope.principal` 只读;`createPeer()` 不对应任何路由 |
|
||||
| wire 上任何字段都不能设置或覆盖 principal | principal 不来自 args、envelope、header、query 或 cookie。args 顶层多余字段被 `assertExactArguments` 拒绝;payload 必须恰为 `{ args }`;envelope 未知字段被 [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) 丢弃;请求体内同名字段只是业务数据 |
|
||||
| 浏览器网络准入为 owner | 通过[浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)的连接得到 `browserPeer` |
|
||||
| 没有隐式 Peer | Gateway 与 Connection 都不补默认 Peer;required 参数让漏传在编译期暴露 |
|
||||
| 已 dispose 的 Peer 不能再分派 | `gateway/peer-disposed` |
|
||||
| 非 owner 未经授权不能调用 | 只有 binding 的 role 显式列出该 action 且资源匹配时才放行;没有该 kind 的 policy 即拒;Gateway 依赖 `remoteAccess`,缺失时 Gateway 不启动 |
|
||||
| `global` 授权不蕴含资源授权 | 资源之间无继承;方法内 `allows()` 对每一项独立判定 |
|
||||
| 未知 principal kind 不是 owner | 只有 `kind === 'owner'` 是 owner |
|
||||
| 非 owner 不回答 waterfall | `$events` 不给非 owner waterfall 帧,`$events/result` 拒绝非 owner |
|
||||
| 非 owner 只收到被授权的通知 | 每个转发帧按 `event:<name>` 与其资源判定,被允许才转发给非 owner |
|
||||
|
||||
### 消息来源落盘
|
||||
|
||||
[session-controller 类型](../../../../packages/api/session-controller/src/types.ts) 的 `MessageSourceMap` 条目增加可选字段:
|
||||
|
||||
```text
|
||||
'user-rpc': {
|
||||
kind: 'user'
|
||||
rpcId: SessionRequestId
|
||||
clientTimeZone?: string
|
||||
/** Non-owner caller that submitted this prompt; absent means the owner. */
|
||||
principal?: Principal
|
||||
}
|
||||
```
|
||||
|
||||
在 Remote 调用中构造 `kind: 'user'` + `rpcId` 用户消息 source 的生产者共两处,都在 Remote 方法内调用 `principalOf(this.ctx.invocation)`,返回值非 `undefined` 才写 `principal`:
|
||||
|
||||
| 生产者 | 位置 |
|
||||
|---|---|
|
||||
| `SessionController.prompt()` 构造 source,`SessionCommandController.prompt(request, source)` 只负责投递 | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts)、[`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) |
|
||||
| subagent `prompt()`(`BrowserPromptSource`);没有 role 列出它,非 owner 只能经进程内 Host 代码到达,不能经 Gateway 到达 | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) |
|
||||
|
||||
业务代码不把 principal 当函数参数逐层传递:身份只在 Remote 方法内从 invocation 读一次,向下传的是已构造的 source。Agent inbox 没有可在消息入队时统一补字段的拦截点(`followup` / `steer` 接受已构造的消息,SDK、调度等进程内生产者不在 Remote 调用中),因此按生产者落实,并由代码评审标准约束新增的生产者。
|
||||
|
||||
| 事实 | 说明 |
|
||||
|---|---|
|
||||
| `kind` 不变 | 仍为 `'user'`;现有 `kind === 'user'` 判断不受影响 |
|
||||
| owner 消息不变 | owner 调用时不写字段,source 与引入 principal 之前逐字节相同;没有该字段的消息(含全部旧会话)即为 owner |
|
||||
| 不进入模型请求 | llm 适配器只读 model source([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) |
|
||||
| 会话格式 | 不 bump `SESSION_FORMAT_VERSION`;按 [persistence 类型变更 cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md) 为该持久化类型变更记录 acknowledgement |
|
||||
|
||||
消费方用法示例(不在本 Note 范围内实现):工具守卫与 pre-step 复检按 turn 读取该 turn 内用户消息的 `source.principal`,任一条带非 owner principal 即按该调用方判定;同一 turn 混有 owner 与读者消息时取最严格者。
|
||||
|
||||
### 代码评审标准与文档
|
||||
|
||||
评审标准的正家为 [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的「`@Remote` 标在能力方法上」一节:`@Remote({ resources })` 与自动解析必须覆盖方法访问的全部资源;聚合结果逐项经 `allows()` 过滤;调用方身份只读 `invocation.peer.principal`,不从参数读取;每条调用路径显式给出 Peer;业务代码不把 principal 当函数参数传递。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.zh.md)、[`docs/api-gateway.md`](../../../../docs/api-gateway.zh.md) | `PeerScope`、`RemoteInvocation`、`InvokeRemoteRequest`、显式 Peer、`@Remote` 的 `resources`、访问检查顺序 |
|
||||
| connection、gateway、typert-protocol、typert-registry、api-remotes、remote-access、session-controller、workspace-controller、ui-session、ui-workspace、ui-slots、client-test-runtime 的 README | 以 Peer / `browserPeer` 取代 operator;remote-access 的 role、binding、policy 契约;access hooks |
|
||||
|
||||
### 不在范围与 Future work
|
||||
|
||||
- 仅供 API 的 `/connect` 握手、API Cookie、每条 API 连接一个 Peer 与 WebSocket 生命周期见 [Peer 作用域建连握手](2026-10-09-operator-scoped-connect-handshake.zh.md)。
|
||||
- 浏览器多 Peer(如每标签页一个):所有浏览器连接共享 `browserPeer`。
|
||||
- 交互参与属于 Peer;`interactions: 'answer'` 的非 owner 依然只能收到 RBAC 授权的广播,不能回答 owner 的 waterfall;按 principal 处理审批不在本机制内。
|
||||
- `agent-preset/selected` 等其余转发事件未声明资源,按 `global` 判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出 `event:<name>`。
|
||||
- liveshare 接入(声明 `share-viewer`、每条读者连接一个 Peer、定义 role 并注册 policy、删除 `origin` / `prompt/origin` / `READ_ENDPOINTS`):#3854 侧。
|
||||
- `session/cancel` 归属检查、fork 的 `childSessionId`、`ToolExecutionInput.turn`:不改。
|
||||
- 资源识别的精确方式(例如 Typert 生成器按 branded id 类型在请求对象中定位字段并写入描述符):目前用 lookup 与 Context 声明的 `resource`、默认字段加 `resources` 读取函数,以后可替换资源解析而不改 policy 接口。
|
||||
- exact Fetch route 对非 owner 一律 403 是临时处理;以后由各路由声明访问规则。
|
||||
- subagent `prompt` 的资源声明待定:其请求字段为 `parentSessionId` / `childSessionId`,目前解析为 `global`。生产者为进程内调用方记录 `source.principal`;没有 role 列出它,非 owner 不能经 Gateway 到达。
|
||||
- 预定义与聚合 role:出现第二种非 owner principal kind 时,由各领域包以 `@Remote({ roles })` 声明方法所属的通用 role(如 `session:viewer`),各 policy 绑定这些 role;目前 role 由业务包以显式 action 清单定义。
|
||||
|
||||
### 与现有 Note 的关系
|
||||
|
||||
- [Remote 双工流](../../implemented/architecture/2026-09-19-remote-duplex-stream.zh.md):部分 supersede。被替换的部分包括:「Peer 不记录身份与权限」「本 Host 只有一个 Peer:operator」「in-process carrier 不给 Peer 即 operator」,以及其替代方案中「Access 机制属于业务语义」的表述;其余(双工流、uplink、`ctx.invocation` 绑定)保持。两篇保持 active 并互链,该 Note 的现行事实已同步更新。
|
||||
- [浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md):不受影响。该 Note 否决「按方法区分特权」针对的是 owner 的认证;通过认证的浏览器仍是 owner 且不受方法级检查。
|
||||
- [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md):不受影响;`@Remote` 与 `@RemoteScope` 增加可选的 `resources` 读取函数(`@RemoteScope` 的第二个参数可为 `{ name?, resources? }`),只供 Gateway 的访问检查读取,不改变端点命名、调用模式与生成物。
|
||||
- [生产方拥有的消息来源](../../implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md):不受影响;`principal` 由构造 source 的生产者写入。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**独立 `prompt/origin` 事件并按 requestId 关联(#3854 做法)。** 每条 prompt 都写 required 事件,普通会话也无法被旧版本读取;关联要求全部 prompt 串行并维护来源 fold,fold 窗口外的消息失去发起者。
|
||||
|
||||
**principal 与 peer 并列挂在 invocation 上。** 同一调用方的身份拆成两处来源,准入点要同时产出并传递两者,任一路径只传其一就会错配。
|
||||
|
||||
**保留「Peer 不记录身份」的原约定。** 身份由业务插件经 `peer.ctx` 附加时,每个读取方都要知道由谁附加、如何读取,wire 准入路径也无法保证附加发生。
|
||||
|
||||
**保留默认 Peer 兜底(`request.peer ?? …`)。** 隐式默认让漏传 Peer 的调用静默获得 owner 身份;required 参数把漏传变成编译错误。
|
||||
|
||||
**新开 source kind。** 现有 `kind === 'user'` 判断都要逐个审视,漏一处就让读者消息在标题、大纲、队列或聊天渲染中消失。
|
||||
|
||||
**请求体字段携带身份。** 任何能构造请求体的调用方都能伪造或覆盖它。
|
||||
|
||||
**前端 React context 提供资源。** 业务组件不得读 React context;标准 hook 由框架按资源绑定,组件只经 props 拿到。
|
||||
|
||||
**由驱动方自行维护端点白名单并裁剪结果(#3854 做法)。** 白名单与结果裁剪都在驱动方内,Gateway 不执行,快照与前端各自推导;role 的 action 清单由通用层执行,列表过滤由方法内的 `allows()` 完成。
|
||||
|
||||
**业务包实现 `decide()` / `abstain` 的 provider,并合并多个 provider 的答案。** 每个业务包手写 action × 资源的判别分支,另写一份资源列表供快照,两者靠人工保持一致;policy 只给出 binding,判定与快照由同一份 binding 算出,每种 principal kind 只有一个 policy,不需要合并规则。
|
||||
|
||||
**独立的 `@RemoteAccess` 标记。** 默认拒绝已由 role 的显式 action 清单保证,标记只是第二道同意;一个资源都没解析出的方法落到 `global`,只有显式的全局授权能放行。资源读取函数是方法描述信息,放在 `@Remote` 上。
|
||||
|
||||
**把无资源的调用称为 `page` 或 `host`。** `page` 描述前端位置而不是资源;`host` 容易被读作这台 Host 上全部资源的上级。`global` 表示不关联任何资源的授权。
|
||||
|
||||
**资源继承(GCP 式:`global` 或工作区上的授权覆盖下级资源)。** 列表方法被 `global` 授权放行后需要逐项细判,继承会让任何 `global` 授权蕴含全部会话;无继承时每个资源的授权都由 binding 显式给出。
|
||||
|
||||
**role 注册表与预定义聚合 role。** 目前唯一的非 owner principal 由一个业务包管理,role 作为该包的普通值即可;聚合 role 会让新包声明的方法自动进入分享读者的可访问范围,推迟到出现第二种非 owner principal。
|
||||
|
||||
**condition 挂在 role 的 action 上。** role 保持为可复用的纯数据;按业务状态细化的判断属于把 role 授予某个 principal 的 binding。
|
||||
|
||||
**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。
|
||||
|
||||
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。
|
||||
|
||||
**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
|
||||
|
||||
**由生成器按 branded id 类型识别资源字段。** 需要改 Typert 生成物与描述符格式,SRC 描述符没有类型信息;留作 Future work。
|
||||
|
||||
**由 api/remotes 集中挂载 remote-access 的 generated contribution。** remote-access Client 依赖 ui-session,ui-session 依赖 api/remotes,集中挂载形成 project reference 环;改为自挂载。
|
||||
|
||||
**在 Gateway 内实现判定(带规则的 `check()` 写在 Gateway)。** Gateway 会承载访问控制语义并多出错误码;Gateway 像解析 lookup 一样解析资源,在分派前调用 `remoteAccess.decide()`,规则与拒绝码归 remote-access。
|
||||
|
||||
**policy 异步判定。** 列表逐项过滤与快照计算需要同步答案;异步状态由业务包预先载入内存。
|
||||
|
||||
**前端按需查询权限。** 请求量随入口数增长,且与规则变化无法同步;快照流由 Host 主动推送,前端同步读取。
|
||||
|
||||
**访问检查放在 lookup 解析之后。** lookup 可能激活 Agent,未授权调用会先产生副作用。
|
||||
|
||||
**在 remote-access 中登记资源字段(`registerScope`)。** 访问控制包要识别参数字段,而 lookup 本已知道哪个 wire 字段指向 Session;typert 把资源种类与 lookups、Contexts 并列保存,Gateway 在一处解析调用与事件,remote-access 不读取任何参数字段。
|
||||
|
||||
**按 Peer 裁剪转发事件的 payload。** 逐事件投影会在每个通知里重复一遍列表过滤;一个事件至多携带一个资源,整条放行或拒绝,集合类变化走内联过滤的 follow 类流。
|
||||
|
||||
**首份快照到达前一律回答 true。** 非 owner 页面会短暂显示无权使用的入口;index 携带首份快照,没有快照的页面即无权限。
|
||||
|
||||
## 后果
|
||||
|
||||
### 验证
|
||||
|
||||
| 用例 | 行为 | 测试落点 |
|
||||
|---|---|---|
|
||||
| wire 试图设置 principal(args 顶层、payload 并列、envelope、请求体内) | 前两种以参数或 payload 校验拒绝;其余被丢弃或当作业务数据;`invocation.peer` 仍为准入给出的 Peer | Gateway host spec |
|
||||
| 所有调用路径显式给出 Peer | `InvokeRemoteRequest.peer`、`wireStream.open` 的 peer、`fetch(request, peer)`、`bridge` 的 peer 为 required;Gateway 中不存在兜底;`/api` 路由传递 `admission.peer` | typecheck、Connection host spec |
|
||||
| 浏览器连接与 `createPeer()` | `browserPeer` 的 principal 为 owner;`createPeer()` 每次返回独立 Peer,随读取 Connection 的 Context 释放;exact route 对非 owner 返回 403 | Connection host spec |
|
||||
| 已 dispose 的 Peer | 之后的 unary、stream 与 `$events` 分派以 `gateway/peer-disposed` 拒绝,wire payload 畸形时亦然;其名下 stream 结束;运行中的 unary 完成;不影响其他 Peer | Gateway host spec |
|
||||
| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec |
|
||||
| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec |
|
||||
| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec |
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
| access hooks | `source()`、`useRemoteAccess`、`useAccess`、`useSessionAccess`、`useWorkspaceAccess` 与快照一致;会话资源上的 `useAccess` 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec |
|
||||
| keyed hook 双参数 | 第二个参数为字符串时读取 `(key, member)` source;既有调用形式不变 | ui-renderer client spec |
|
||||
| 非 owner 分派 `session/prompt` | 落盘 user 消息 `source.principal` 等于该 principal;冷恢复后仍读到同一值 | session-controller host spec |
|
||||
| 进程内以非 owner invocation 调用 subagent `prompt` | 子会话落盘的 user 消息 `source.principal` 等于该 principal | subagent host spec |
|
||||
| owner 消息 | source 与本变更之前逐字节相同;现有 keyless recorded-session snapshot 不变 | `pnpm run test:snapshot` 不需要重录 |
|
||||
| 持久化记录 | `verify-persistence-changes` 通过 | doc-sync |
|
||||
|
||||
SDK 投影不受影响:TypeScript SDK server 自行构造 `{ kind: 'user' }` 消息([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)),不经 Gateway,也不投影 source;Python SDK 不读取 user source。
|
||||
|
||||
### 代价与限制
|
||||
|
||||
- 改动面横跨 protocol、Connection、Gateway、两个领域控制器、subagent、新包与前端,并带破坏性 API 改名;与 pr-5945 在 Connection 与 Gateway 上必然冲突,按迁移清单处理。
|
||||
- Gateway 依赖 `remoteAccess`:自建组合若挂载 Gateway 而不挂载 remote-access,Gateway 停在等待状态。
|
||||
- Session 日志会保存读者身份;导出或分享会话时随之外传,每个 principal 种类不得携带凭据。
|
||||
- 浏览器所有连接共享一个 Peer:dispose 无法按标签页区分,直到浏览器多 Peer 落地。
|
||||
- 默认字段约定按字段名读取:方法新增同名字段即访问对应资源,评审需留意;精确识别列为 Future work。
|
||||
- 只解析出部分资源的方法会放行越权:未解析的资源不受检查,由方法作者保证 `resources` 完整。
|
||||
- role 的 action 是字符串,不与已加载端点核对:拼错或改名后的 action 永不匹配,表现为拒绝。
|
||||
- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知该会话的 `parentSessionId`。
|
||||
- 被授权会话的 cwd 与工具路径本就包含 owner 的绝对路径,因此 Gateway 不对非 owner 的 `$events` 流单独隐藏 `home`。
|
||||
- policy 撤销某个工作区的可见性后,`workspace/follow` 只在该工作区下一次变化或顺序下一次变化时察觉并向非 owner 发送 `replace` 增量帧;仅访问变化不会发送帧。
|
||||
|
||||
### 留给 pr-5945 的迁移清单
|
||||
|
||||
| 文件 | 迁移 |
|
||||
|---|---|
|
||||
| `packages/client/connection/src/peer.ts` | 取代 `operator-peer.ts`;`OperatorPeer` 构造改为 `new Peer(owner, { principal })` |
|
||||
| `packages/client/connection/src/rpc-host.ts` | `this.operator` 改为 `browserPeer`,principal 为 `{ kind: 'owner' }` |
|
||||
| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` 合并 `admitConnection` 与 `browserPeer` / `createPeer` |
|
||||
| `packages/api/gateway/src/index.ts` | `$events` 打开处的 `peer ?? this.operatorPeer()` 改为显式 Peer;复用 `gateway/peer-disposed` |
|
||||
| operator-connect 的 Note、升级指南与 subsystems 改动 | operator 术语改写为 Peer / `browserPeer`;API 连接的 Peer 为 `{ kind: 'owner' }` |
|
||||
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-chat-single-fold-motion.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-13-chat-single-fold-motion.md
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message:
|
||||
en: 45594545dabfaa78
|
||||
zh: a1eac669647b2bac
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem:
|
||||
en: 88faaf0c589e12cb
|
||||
zh: dda7efc5eaed2788
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision:
|
||||
en: 972bbe45fe8ac28e
|
||||
zh: 593b851c142894e1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
|
||||
en: f44b784545c6d9a2
|
||||
zh: 3eb6fcb9f348dec4
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
|
||||
en: b8278678170f1507
|
||||
zh: 113a51ba5cf27de1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input:
|
||||
en: ca6a5c372bb3cad1
|
||||
zh: 1cd5028269b58c65
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing:
|
||||
en: 0423a607ee0589b5
|
||||
zh: 61df892a01c5aa43
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered:
|
||||
en: d8e1ea66e293cd93
|
||||
zh: 0aaa9e693f441673
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/consequences:
|
||||
en: 89148df30a9a1772
|
||||
zh: d484cc7c9e0cc3f1
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: One fold motion for collapsing on the next message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-chat-single-fold-motion.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
When "When to Collapse Work Details" is "On next message", the previous exchange's process rows close when the next human input arrives. The scroll layer had two fold paths and chose one by `ownInput` ([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts)):
|
||||
|
||||
- **Input fold path:** when the fold starts, it measures the closing rows, computes the final landing and the required bottom room, and moves the rows and the scrollport on one frame clock to a single landing.
|
||||
- **Generic path:** scrollTop stays put, the closing height is reserved as bottom room in one block, and following scrolls once more after the fold ends. This holds only when the fold happens below the reader. When the folding region is above the reader, the content the reader is looking at moves up as a whole and the viewport shows only the room.
|
||||
|
||||
`ownInput` holds for three signals only: a local echo in the transcript (`submissionId`), a steer echo (`steeringId`), or a user message as the last list row (`lastIsUser`). In the commit where the Host records the opening input, the Turn's process control is ordered after that input, so `lastIsUser` does not hold. Every human input without a transcript echo on this page therefore took the generic path:
|
||||
|
||||
| Source | Echo on this page | Reader at the bottom | Reader in the middle of the body |
|
||||
|---|---|---|---|
|
||||
| Idle send from the main composer | Yes, in the transcript | Correct | Correct |
|
||||
| Main-Session queued message delivered after the previous Turn | Yes, but in the queue, not consulted | Whole viewport blank | Whole viewport blank |
|
||||
| Send from another tab | No | Whole viewport blank | Whole viewport blank |
|
||||
| Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank |
|
||||
| Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — |
|
||||
|
||||
Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); the table above covers the human input that still folded through the generic path. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts).
|
||||
|
||||
Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id.
|
||||
|
||||
## Decision
|
||||
|
||||
"On next message" keeps a single fold motion: [flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) hands every batch of closing rows to the `InputFoldDriver` that the viewport supplies, and [use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) chooses one of two landings when the batch begins.
|
||||
|
||||
### Batches
|
||||
|
||||
- Rows that start closing in one commit, together with rows that start revealing in it, form one batch. The driver's four steps are:
|
||||
- `begin`: measure the boxes and choose the landing;
|
||||
- `prepare`: grow the room before box writes so the browser does not clamp an intermediate frame;
|
||||
- `frame`: write box heights and scrollTop together;
|
||||
- `finish`: install the final room.
|
||||
|
||||
Content added during the batch updates the remaining movement by starting a new segment.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
|
||||
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
|
||||
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
|
||||
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
|
||||
|
||||
### Landings
|
||||
|
||||
Let `start` be the scrollTop when the batch begins, `above` the height of closing rows above the viewport top minus revealing rows there, `base = max(0, start − above)`, and `bottom` the floor after the fold.
|
||||
|
||||
| Landing | When | Target scrollTop | After the batch |
|
||||
|---|---|---|---|
|
||||
| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership |
|
||||
| **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position |
|
||||
|
||||
- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor, and keeps only the room that target needs.
|
||||
- Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it.
|
||||
- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following.
|
||||
- A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still.
|
||||
- For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it.
|
||||
- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed.
|
||||
|
||||
### Own input
|
||||
|
||||
| Source | Determination | Landing |
|
||||
|---|---|---|
|
||||
| Idle send from the main composer | Transcript echo | Tail |
|
||||
| Idle send from a subagent child's composer | The child also registers a local echo; `subagents.prompt` uses the echo's `requestId` | Tail |
|
||||
| Queued while running, delivered after the previous Turn | Not own input | Tail when following, otherwise reader content still |
|
||||
| Send from another tab | Not own input | Same as above |
|
||||
| Parent Agent messages a child | The notice opens no exchange and folds nothing | — |
|
||||
|
||||
"On completion" is outside the motion scope; its folds apply immediately and are unaffected.
|
||||
|
||||
## Testing
|
||||
|
||||
[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the generic path and fix only own-input detection.** The two paths keep diverging geometry rules; for input that is not own input, a reader in the middle is either pulled to the bottom or still sees a blank.
|
||||
|
||||
**Compensate the generic path by reader position but implement it separately from the input path.** The compensation and room geometry would exist twice and could diverge again.
|
||||
|
||||
**Detect own input from the exchange-opening human node and keep children without echoes.** Input from another tab would count as own input and pull a reader in the middle to the bottom; child sends still would not show the sender's message immediately.
|
||||
|
||||
**Let content slide back to fill the deficit a short body leaves.** Content would move up and then down within one animation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Benefit: the source of whole-viewport blanks (the generic path) no longer exists; the landing depends only on whether the input is own input and whether the reader was following, not on which page sent the message or when it was delivered. Child sends show the sender's message immediately.
|
||||
- Cost: with a short body, content never sliding back still leaves the deficit until new content fills it.
|
||||
- When focus is inside a closing row, focus protection keeps that row open. If it is a height-capped process group, its body shrinks to the cap immediately. That shrink happens before the batch begins, so the reader-content-still landing cannot measure it and the reader's content moves once.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: 「下条消息时收起」只用一条折叠动画
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-chat-single-fold-motion.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 `ownInput`([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts))二选一:
|
||||
|
||||
- **输入折叠路径**:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。
|
||||
- **通用路径**:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。
|
||||
|
||||
`ownInput` 只在三种信号下成立:对话流里的本地回显(`submissionId`)、steer 回显(`steeringId`)、列表最后一行是用户消息(`lastIsUser`)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,`lastIsUser` 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径:
|
||||
|
||||
| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 |
|
||||
|---|---|---|---|
|
||||
| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 |
|
||||
| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 |
|
||||
| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 |
|
||||
| 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 |
|
||||
| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — |
|
||||
|
||||
通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md));上表是仍经由通用路径折叠的人类输入。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。
|
||||
|
||||
子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。
|
||||
|
||||
## Decision
|
||||
|
||||
「下条消息时收起」只保留一条折叠动画:[flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) 把每一批收起交给 viewport 提供的 `InputFoldDriver`,[use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 在批次开始时选择两种落点之一。
|
||||
|
||||
### 批次
|
||||
|
||||
- 同一次提交中开始收起的行,连同同批开始展开的行,组成一个批次。驱动器的四步依次为:
|
||||
- `begin`:测量这些盒子,选定落点;
|
||||
- `prepare`:在写盒子之前扩大补白,避免中间帧被浏览器截断;
|
||||
- `frame`:同步写盒子高度和 scrollTop;
|
||||
- `finish`:安装最终补白。
|
||||
|
||||
批次中途新增的内容按分段规则更新剩余运动。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
|
||||
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
|
||||
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
|
||||
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
|
||||
|
||||
### 落点
|
||||
|
||||
记 `start` 为批次开始时的 scrollTop,`above` 为视口顶部以上收起行的高度减去展开行的高度,`base = max(0, start − above)`,`bottom` 为折叠后的底部位置。
|
||||
|
||||
| 落点 | 何时 | 目标 scrollTop | 结束后 |
|
||||
|---|---|---|---|
|
||||
| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 |
|
||||
| **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 |
|
||||
|
||||
- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。到底部落点在结束时读取已提交的实际排版:落在目标与真实底部中较大的位置,只保留该目标所需的空间。
|
||||
- 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。
|
||||
- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。
|
||||
- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。
|
||||
- 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。
|
||||
- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。
|
||||
|
||||
### 自己输入的判定
|
||||
|
||||
| 来源 | 判定 | 落点 |
|
||||
|---|---|---|
|
||||
| 主会话输入框空闲发送 | 对话流回显 | 到底部 |
|
||||
| 子会话输入框空闲发送 | 子会话同样注册本地回显;`subagents.prompt` 使用回显的 `requestId` | 到底部 |
|
||||
| 运行中排队、上一轮结束后自动投递 | 不是自己输入 | 跟随底部时到底部,否则内容不动 |
|
||||
| 另一个标签页发送 | 不是自己输入 | 同上 |
|
||||
| 父 agent 给子会话发消息 | 通知不开启交互段,不折叠 | — |
|
||||
|
||||
「完成时收起」不在动画作用域内,折叠立即生效,不受影响。
|
||||
|
||||
## Testing
|
||||
|
||||
[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留通用路径,只修自己输入的识别。** 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。
|
||||
|
||||
**通用路径改为按读者位置补偿,但与输入路径分开实现。** 补偿与补白的几何代码重复一份,两边容易再次不一致。
|
||||
|
||||
**用开启交互段的人类输入节点识别自己输入,子会话不补回显。** 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。
|
||||
|
||||
**允许内容回滑,以填满短正文留下的差额。** 同一次动画里内容先上后下。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。
|
||||
- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。
|
||||
- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-context-breakdown-system-prompt-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md
|
||||
/agent-note-context-breakdown-retains-only-system-prompts:
|
||||
en: 9b155f87202e037e
|
||||
zh: 8d4190c00412b892
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/problem:
|
||||
en: 7cbbdfbe7da9ae7a
|
||||
zh: 50368b272d7b5ebc
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/decision:
|
||||
en: 52d5c42112c5c6dc
|
||||
zh: 744dbbcc52193300
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/verification:
|
||||
en: 68a7375ca7fe99e8
|
||||
zh: e3c7d108740e8952
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/alternatives-considered:
|
||||
en: 2bd0e83c5298da2b
|
||||
zh: 336259b6985eb7b5
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/consequences:
|
||||
en: 839a919ff16bc53c
|
||||
zh: bf0d4f3a783ead1d
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Context breakdown retains only system prompts
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-context-breakdown-system-prompt-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` kept one `{ seq, heuristicTokens, system }` entry per retained surface node so that any replacement could subtract the exact price of its range and re-select the last nonempty system prompt. Its checkpoint therefore grew with the number of messages in the model context. In a local corpus of 836 Sessions, 151,122 entries made the unit's rows 7.13 MB of the projection cache and up to 103 KB for one Session, and every cached Session listing validated all of them. Long tool-heavy runs below the compaction threshold kept about 2,000 entries each.
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) retains the surviving `system/message` nodes in surface order with their heuristic prices, one running surface total, and at most one armed shadow-price claim. State version 6 discards version-5 rows; other projection keys keep their rows.
|
||||
|
||||
- An append adds its price; a `system/message` append also joins the end of the system list.
|
||||
- `compaction/summary` and `compaction/prune` arm a claim from `shadowedRange`, `shadowedTokenCount`, and the retained system prompts named by `shadowedSeqs`. The next event consumes or expires it. A replacement over the claimed range subtracts the claim price and drops those prompts; a replacement naming another range, or putting a `system/message` in place of a metered range, throws.
|
||||
- An unmetered replacement of one retained system prompt subtracts that prompt's own price. A `system/message` replacement keeps its position; any other replacement removes it.
|
||||
- Any other unmetered replacement folds at zero delta, the rule `contextPressure` already applies.
|
||||
|
||||
`systemTokens` is the last retained prompt with a positive price, and `messageTokens` is the surface total minus it; both are floored at zero. `shadowedSeqs` is the authoritative set of shadowed nodes, so membership decides which prompts leave without locating non-system endpoints in surface order.
|
||||
|
||||
## Verification
|
||||
|
||||
Folding all 836 stored logs with the version-5 and version-6 definitions produced identical breakdowns after every event. The logs held 2,206 metered replacements, 106 system-prompt rewrites, and no other replacement. Retained state fell from 7.13 MB to 110 KB, with at most five surviving system prompts in one Session.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-node entries with a faster validator.** A hand-written node check cut listing validation from 75 ms to 4.5 ms but kept 7.13 MB of rows, an O(surface) array copy per surface event, and checkpoint writes proportional to the message count.
|
||||
|
||||
**Columnar per-node entries.** Parallel arrays shrink rows about 5.7 times but keep O(surface) state for information the shadow-price events already carry.
|
||||
|
||||
**Exact pricing of unmetered non-system replacements.** That requires per-node prices. Current producers meter every non-system replacement, and released formats from v0 carry the shadow-price fields, so the remaining case is a log that breaks producer adjacency; it drifts like `contextPressure`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Checkpoint size and per-event cost follow the number of surviving system prompts rather than messages, and cached listings no longer validate per-message entries. The figures stay exact for metered replacements and system-prompt rewrites, and their sum equals the `measure()` heuristic total under those producers. An unmetered non-system replacement leaves the figures off by that replacement's price difference. A future range replacement that inserts a system prompt is rejected until the fold can order it among the retained prompts. This supersedes the positional-entry choice for this unit in the [in-history system-prompt replacement](../feature/2026-09-02-in-history-system-prompt-replacement.md) note; `measure()` keeps positional nodes for route pricing.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 上下文构成只保留系统提示词
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-context-breakdown-system-prompt-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` 曾为每个保留的 surface 节点存一条 `{ seq, heuristicTokens, system }`,以便任意替换都能减去其范围的精确价格,并重新选出最后一个非空系统提示词。因此它的检查点随模型上下文中的消息数增长。在一个包含 836 个 Session 的本地语料中,151,122 条条目让该单元的行占用 projection cache 的 7.13 MB,单个 Session 最多 103 KB,而每次缓存会话列表都要校验全部条目。低于压缩阈值、工具调用密集的长时间运行各自保留约 2,000 条条目。
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) 按 surface 顺序保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim。状态版本 6 丢弃版本 5 的行;其他 projection key 保留各自的行。
|
||||
|
||||
- 追加加上其价格;`system/message` 追加还会加入系统列表末尾。
|
||||
- `compaction/summary` 和 `compaction/prune` 依据 `shadowedRange`、`shadowedTokenCount` 以及 `shadowedSeqs` 中列出的已保留系统提示词武装 claim。下一个事件消费或使其失效。覆盖所 claim 范围的替换减去 claim 价格并移除这些提示词;指向其他范围的替换,或以 `system/message` 取代已计量范围的替换,会抛出错误。
|
||||
- 对单个已保留系统提示词的未计量替换减去该提示词自身的价格。`system/message` 替换保持其位置;其他替换将其移除。
|
||||
- 其他未计量替换以零增量折叠,这与 `contextPressure` 已采用的规则相同。
|
||||
|
||||
`systemTokens` 是最后一个价格为正的保留提示词,`messageTokens` 是 surface 总量减去它;两者都不低于零。`shadowedSeqs` 是被遮蔽节点的权威集合,因此由成员关系决定哪些提示词离开,无需在 surface 顺序中定位非系统端点。
|
||||
|
||||
## Verification
|
||||
|
||||
用版本 5 和版本 6 的定义折叠全部 836 份已存日志,每个事件之后的构成数字都完全相同。这些日志包含 2,206 次已计量替换、106 次系统提示词改写,没有其他替换。保留状态从 7.13 MB 降到 110 KB,单个 Session 最多有五个存活的系统提示词。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留逐节点条目,换用更快的校验器。** 手写节点检查把列表校验从 75 ms 降到 4.5 ms,但仍保留 7.13 MB 的行、每个 surface 事件一次 O(surface) 数组复制,以及与消息数成正比的检查点写入。
|
||||
|
||||
**列式逐节点条目。** 并行数组让行缩小约 5.7 倍,但仍为影子价事件已经携带的信息保留 O(surface) 状态。
|
||||
|
||||
**精确定价未计量的非系统替换。** 这需要逐节点价格。当前生产方会计量每一次非系统替换,自 v0 起的已发布格式都携带影子价字段,因此剩余情况只是破坏生产方相邻性的日志;它像 `contextPressure` 一样产生偏差。
|
||||
|
||||
## Consequences
|
||||
|
||||
检查点大小和每个事件的成本取决于存活系统提示词的数量而不是消息数,缓存列表也不再校验逐消息条目。对已计量替换和系统提示词改写,数字保持精确,其和在这些生产方下等于 `measure()` 的启发式总量。未计量的非系统替换会让数字偏离该替换的价格差。未来插入系统提示词的范围替换会被拒绝,直到折叠能确定它在已保留提示词中的顺序。这取代了 [历史内系统提示词替换](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 说明中针对该单元的位置条目选择;`measure()` 仍为路由定价保留位置节点。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-cua-continuous-hover.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-13-cua-continuous-hover.md
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership:
|
||||
en: b86d869be9abc278
|
||||
zh: f79f3f1b3c548791
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/problem:
|
||||
en: cdc04139c8d69415
|
||||
zh: a3fe3e78ada08d95
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/decision:
|
||||
en: a25e8c472c084852
|
||||
zh: 12250574113e110a
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/alternatives-considered:
|
||||
en: 18d917ee2e909b93
|
||||
zh: 5b5ad0530f138cda
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/consequences:
|
||||
en: 714789d0a472a308
|
||||
zh: e324c2eb537a5bf9
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Continuous CUA hover without cross-call ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-cua-continuous-hover.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Hover interactions span movement, delayed content, observation and input inside a revealed region. A request cleanup that restores the pointer, or a click that first presses outside that region, can dismiss the content even when nobody else sends input.
|
||||
|
||||
## Decision
|
||||
|
||||
App and Window expose `hover` for an observed AX element or an explicit coordinate target. It never presses a mouse button; disabled controls can use system-pointer movement. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) selects AX alternate UI before falling back to system movement with foreground and mouse-hit checks. It never activates or raises the app implicitly. Other input or window changes can race that check.
|
||||
|
||||
The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete individual operations. It grants no application ownership across calls. Earlier arrival, a retained app object and Session lifetime do not establish task priority. Waiting for the model does not reserve the application. Explicit task coordination determines any required execution order.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Cross-call application reservation** would block other tasks throughout observation and model inference without evidence that the first task deserves priority. Continuous interaction instead relies on CUA preserving the last hover state when no further input occurs.
|
||||
|
||||
**Click or drag as hover** introduces button input that can activate a control, select text or dismiss a popup. Hover requires a separate unpressed movement operation.
|
||||
|
||||
**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The system-pointer fallback avoids reporting this unreliable event route as continuous hover.
|
||||
|
||||
## Consequences
|
||||
|
||||
Other input and application-driven changes can interrupt the interaction; callers observe fresh state before continuing. Dispatch does not verify that a hover delay has elapsed or a popup has appeared. The Electron fixture verifies delayed reveal, AX and screenshot preservation, entry into the popup, exactly one trusted click and dismissal on exit. The recorded Session covers hover dispatch and observed popup content; it does not substitute for native event verification.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: CUA 连续悬停不分配跨调用所有权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-cua-continuous-hover.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
悬停交互包含移动、等待内容出现、观察和在新区域内输入。如果请求清理恢复鼠标位置,或者点击先在该区域外按下鼠标,即使没有其他输入,也可能关闭浮层。
|
||||
|
||||
## 决策
|
||||
|
||||
App 和 Window 提供 `hover`,接受已观察的 AX 元素或显式坐标目标。悬停不按下鼠标按钮,禁用控件可使用系统鼠标移动路径。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,再回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。
|
||||
|
||||
[共享 helper](2026-10-09-cua-shared-native-helper.zh.md)串行执行完整的单次操作,不分配跨调用的应用所有权。先发起请求、持有 app 对象和 Session 寿命均不代表任务优先级。等待模型期间不占用应用。需要确定执行顺序时,由明确的任务协调决定。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**跨调用占用应用**会在观察和模型推理期间阻塞其他任务,但先开始的任务并无优先依据。连续交互依赖 CUA 在没有新输入时保留上一次悬停状态。
|
||||
|
||||
**用点击或拖动代替悬停**会引入按键输入,可能触发控件、选中文字或关闭浮层。悬停需要独立的不按键移动操作。
|
||||
|
||||
**向 PID 定向发送虚拟移动**可能在已有交互的 Chromium 窗口中显示悬停内容,但新窗口可能忽略它,后续鼠标位置检查也可能关闭内容。实测 AppKit 跟踪区域同样需要真实鼠标移动。系统鼠标回退避免把这条不可靠的事件路径当作连续悬停。
|
||||
|
||||
## 影响
|
||||
|
||||
其他输入和应用自身变化可能中断交互,调用方需要重新观察再继续。分派成功不代表悬停延迟已经结束或浮层已经出现。Electron fixture 验证延迟出现、AX 和截图保留悬停、进入浮层、恰好一次可信点击以及移出后关闭。Session 回放覆盖悬停分派和浮层内容观察,不代替原生事件验证。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-cua-hover-routes-and-coordinates.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-13-cua-hover-routes-and-coordinates.md
|
||||
/agent-note-background-hover-and-screenshot-coordinates:
|
||||
en: 2abcb6476c2995eb
|
||||
zh: 007a05272fc7f366
|
||||
/agent-note-background-hover-and-screenshot-coordinates/problem:
|
||||
en: f96bf1ebc0719fb0
|
||||
zh: e9a1144f0e76572d
|
||||
/agent-note-background-hover-and-screenshot-coordinates/decision:
|
||||
en: 0cdd295d5f1872ed
|
||||
zh: fe03ec9f7df94a4a
|
||||
/agent-note-background-hover-and-screenshot-coordinates/alternatives-considered:
|
||||
en: 5fc4beaf79761c50
|
||||
zh: 4948333b0020be28
|
||||
/agent-note-background-hover-and-screenshot-coordinates/consequences:
|
||||
en: 07a67707e1df59f8
|
||||
zh: 459c4f2629cd3190
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Background hover and screenshot coordinates
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-cua-hover-routes-and-coordinates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Hover-only controls can sometimes be revealed without moving the user's pointer. Treating every hover as a system movement unnecessarily disturbs that pointer. Screenshot pixels also differ from native window points after Retina capture, attachment normalization or model-request resizing; making the model convert them duplicates provider geometry rules.
|
||||
|
||||
## Decision
|
||||
|
||||
Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route falls back to the real system pointer. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md).
|
||||
|
||||
Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 1000 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution.
|
||||
|
||||
`LlmAdapter.imageRequestTarget` exposes the same dimensions and byte target used by image serialization. DeepSeek and pi-ai share their existing projection functions with this method. Other adapters preserve normalized images unless they declare resizing. Screenshot bytes returned to programs match the request image; its durable source is admitted and stored once. The native PNG metadata is checked against decoded source dimensions before any input mapping is published.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
PID-directed movement alone does not establish AppKit hover tracking. The [continuous-hover observations](2026-10-13-cua-continuous-hover.md) retain that limitation. Browser-engine or Electron page input can hover without the system pointer, but needs a separately established connection; a generic native app handle does not provide one.
|
||||
|
||||
Making all coordinates relative would discard the familiar absolute-pixel interface. Keeping both modes explicit avoids guessing a coordinate's units from its value. Using capture dimensions alone would miss later image resizing; the provider owns its projection formula.
|
||||
|
||||
## Consequences
|
||||
|
||||
The native fixture covers alternate-UI reveal, delayed observation, screenshots, descendant entry, one click and exit while sampling the system pointer. Separate owned Chromium/CDP and Electron-input probes verify CSS hover, trusted input and pointer stability; they do not establish automatic access to arbitrary existing applications. Applications without a usable background route still require system-pointer hover.
|
||||
|
||||
Unit and provider tests cover Retina and downscaled images, both coordinate modes, endpoint validation, scoped-window preservation, resize invalidation, decoded-image metadata and one-time image storage. Recorded native-tool and PTC sessions retain the model-facing coordinate contract and hover-route evidence.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 后台悬停与截图坐标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-cua-hover-routes-and-coordinates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
部分悬停控件可以在不移动用户鼠标的情况下展开。每次悬停都使用系统鼠标会增加不必要的干扰。Retina 截图、附件归一化和模型请求缩放也会使截图像素不同于窗口 point;让模型换算会重复提供方已有的几何规则。
|
||||
|
||||
## 决策
|
||||
|
||||
原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。
|
||||
|
||||
面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~1000 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。
|
||||
|
||||
`LlmAdapter.imageRequestTarget` 提供与图片序列化相同的尺寸和字节目标。DeepSeek 和 pi-ai 在该方法中复用已有投影函数。其他适配器除非声明缩放,否则保留归一化图片。返回程序的截图字节与模型请求图片一致,持久原图只接入并保存一次。发布输入映射前,原生 PNG 元数据必须与解码后的原图尺寸一致。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
仅向 PID 发送移动事件不能保证触发 AppKit 悬停跟踪,[连续悬停观察](2026-10-13-cua-continuous-hover.zh.md)保留了这一限制。浏览器引擎和 Electron 页面输入可以不移动系统鼠标,但需要单独建立控制连接,普通原生应用句柄不提供这种连接。
|
||||
|
||||
统一使用相对坐标会放弃已有的绝对像素接口。显式选择模式可避免按数值猜测单位。只使用截图采集尺寸会遗漏后续图片缩放,因此投影公式由模型提供方负责。
|
||||
|
||||
## 影响
|
||||
|
||||
原生 fixture 覆盖替代界面展开、延迟观察、截图、进入子控件、一次点击和移出,并采样系统指针。独立的 Chromium/CDP 和 Electron 输入实验验证 CSS 悬停、可信输入和指针不变,不代表已能自动接入任意现有应用。没有可用后台路径的应用仍需系统鼠标悬停。
|
||||
|
||||
单元及提供方测试覆盖 Retina 和缩小后的图片、两种坐标模式、端点校验、单窗口观察保留其他映射、尺寸变化失效、解码尺寸校验及一次图片存储。原生工具和 PTC 的 Session 回放保留模型可见的坐标约定与悬停路径信息。
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-webworker-preview-profile-parity.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand:
|
||||
en: f43aec03d4eeb831
|
||||
zh: fb40c3fd679876f3
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/problem:
|
||||
en: 0dd8324644e69400
|
||||
zh: 19d12f096078d72c
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment:
|
||||
en: caf1ca77192dcb20
|
||||
zh: d663e2cbb05fe4fc
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker:
|
||||
en: 37d8e809eaa21d25
|
||||
zh: 8c7c31bd6f45c823
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/inspector-degradation-on-platforms-without-worker-threads:
|
||||
en: 608b24ca97ab5bca
|
||||
zh: 94bb28377c856b2e
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/separate-archives-for-lazily-loaded-chunks:
|
||||
en: 428699f74f19e998
|
||||
zh: 2e8fcdbc9616f70b
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/watching-and-client-plugin-delivery:
|
||||
en: f3e6ba098433a70a
|
||||
zh: 345b93f451d2768c
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/alternatives-considered:
|
||||
en: 99ee505e82fa9c7c
|
||||
zh: 448bc0e160424cbf
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/consequences:
|
||||
en: 53d72faa09e78410
|
||||
zh: a5224e4662a4846b
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/testing:
|
||||
en: 532918df1acd147d
|
||||
zh: e476c090d183d0a4
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Agent Note: Boot the Preview Worker from the shipped profile and fetch lazy chunks on demand
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-14-webworker-preview-profile-parity.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The browser Preview ([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.md), [webworker-packer](../../../../packages/experimental/webworker-packer/README.md)) can receive its boot configuration at pack time: the packer runs `dsh --profile web --dump-config` against a temporary home and writes the output to the image's `config/cordis.yml`; the Worker then inserts the whole configuration back as the profile's own patch (one `insert`). Measured on such an image, this has three consequences:
|
||||
|
||||
| Symptom | Cause |
|
||||
|---|---|
|
||||
| The Plugins page shows no optional bundle | The image only collects rows that appear in the dump, so none of the 11 default-off `OPTIONAL_BUNDLES` is included; the Worker's `installAnchor` points at the profile's own package.json, which declares no installation dependencies |
|
||||
| Even when added, some bundles do not take effect after they are enabled | Bundle layers apply before the profile patch, when the base rows do not exist yet: the session-titles disable patch is skipped, agent-team produces 4 `entry not found` warnings, and rows a bundle inserts move to the front |
|
||||
| The image is 12.79 MiB | 4 lazily loaded chunks (documentpreview's pdf, excel, and frontmatter chunks and sidebar-terminal's terminal chunk, about 4.4 MiB together) download together with the code needed at startup |
|
||||
|
||||
Measured: with no bundle enabled, the two compositions differ in only one of 188 rows, the `compression: none` setting of `session-persistence-jsonl`. The differences come from how the configuration is layered, not from the data itself.
|
||||
|
||||
## Decision
|
||||
|
||||
### Boot the same profile as the served Web deployment
|
||||
|
||||
The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it:
|
||||
|
||||
- **packer**
|
||||
- Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses.
|
||||
- The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`.
|
||||
- The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots.
|
||||
- The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image.
|
||||
- **Worker**
|
||||
- The image's app-boot `loadProfile` initializes `home/profiles/<profile>` from the template, the Worker writes an empty root `cordis.yml`, and `readProfilePatches` composes the layers.
|
||||
- profileContext is filled the way `runProfile` fills it: name, dir, patchPath, installAnchor, startedBundles, cwd, home, and an empty overlay list, so the Worker calls `readProfilePatches` once, as the launcher does without `--patch`.
|
||||
- The Worker provides a launchEnvironment snapshot and mounts `PluginPackages` without a resolution.
|
||||
- **Retained platform differences**: jsonl `compression: none` through the image's home patch (ConfigEditor rejects edits to that row's config, as it rejects any row a home patch overrides), the module loader (the Worker's own CommonJS loader, provided through the Node internal-module interfaces in the next section), log output, command-line arguments, appReady timing, no pnpm, and no process-level proxy or signal handling.
|
||||
|
||||
The Worker uses existing app-boot exports; app-boot has no Preview override or injection point.
|
||||
|
||||
### Node internal-module interfaces in the Worker
|
||||
|
||||
Plugin metadata reads (`readPluginMeta`), configuration schema dumps, and HMR do not go through ctx: they take Node internal modules directly through `ModuleLoader.fromInternal()` or `node-addon-require-builtin.requireBuiltin(id)`. A Worker that hides those modules, for example by reporting `process.versions.node` as `0.0.0` so that `fromInternal()` returns undefined, breaks these callers once `PluginPackages` is mounted: every bundle on the Plugins page reports "Plugin metadata requires the Node module resolver", and HMR, which finds no `loadCache`, returns `restart-required` for every switch.
|
||||
|
||||
- **Interfaces only**: the Worker replaces `node-addon-require-builtin` with a static module whose `requireBuiltin(id)` answers the 5 modules callers read, under Node v24.18.0 ids, with Node's member names and signatures; every other id throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. `process.versions.node` reports `24.18.0`, and `execArgv` carries no `--expose-internals`.
|
||||
- **`internal/modules/esm/loader`**: the cascaded loader's `resolveSync(parentURL, request)` and `import` delegate to the Worker's existing loader, and failed resolution carries Node's `ERR_MODULE_NOT_FOUND` or `ERR_PACKAGE_PATH_NOT_EXPORTED`; `loadCache` is an empty `LoadCache`; `getOrCreateModuleJob` exists and refuses when called. The Worker loader instance owns the cascaded loader.
|
||||
- **Members HMR replaces**: `Module` from `internal/modules/cjs/loader`, `package_json_reader`, the `modules` binding from `internal/bootstrap/realm`, and `ResolveCache` from `module_map` keep Node's member names so HMR can replace and restore them; they refuse when called. The Worker's own `require` does not route through them.
|
||||
- **One cascaded loader**: the Worker does not override `ctx.loader.internal`; the Cordis Loader and static callers reach one cascaded loader through `fromInternal()`.
|
||||
- **Node behavior for measured callers**: `node:path.win32.isAbsolute` (icon path validation) and `node:util.inherits` (`http-errors`), both following Node's source.
|
||||
|
||||
### Inspector degradation on platforms without worker_threads
|
||||
|
||||
The Worker's structural stubs throw errors carrying `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. The Inspector Host degrades only when its Worker fails to start with that code: it warns once, the bootstrap route answers 503, and it registers no DevTools route, injects nothing, and provides no `inspector` service; every other startup failure fails the fiber. `serve-static` and `open` load after the Worker starts, so a degraded start never loads them. Session Inspector in the same bundle therefore runs.
|
||||
|
||||
### Separate archives for lazily loaded chunks
|
||||
|
||||
- **Classification**: the client build preset emits package-local chunk requests only as `require.async("./client.<name>.js")`, and `verify-client-packages` forbids synchronous chunk requires. The packer's `lazyChunksOf` scans every page asset in the swept image (each package's `lib/client.js` and `lib/client.*.js`) for these literals, so a chunk that another chunk requests is found without following requests from the `./client` entry. A target that a package's `./client` export names under any condition (such as `client.browser.js`) loads eagerly and stays in the base image; every other target leaves it. A request for a chunk the package does not publish fails the pack.
|
||||
- **Archives**: each chunk gets one deterministic tar.gz named `lazy/<sha256>.tar.gz` after the SHA-256 of the compressed archive. The base manifest's `lazy` list records each archive as `{ archive, files: [{ path, size }] }`, with no file mode. There is no size threshold.
|
||||
- **Registration**: the Worker registers each listed file with `MemoryVfs.deferArchive`, which creates its parent directories and gives it the VFS default file mode; an image that lists lazy archives must be fetched from a URL, against which the archive names resolve. `exists`, `stat`, and `readdir` answer synchronously from the registration.
|
||||
- **Fetch**: the asynchronous faces of the Worker's `node:fs` and `node:fs/promises` that read a registered file wait for its archive: `fs.promises.readFile`, `fs.promises.open` (so every FileHandle opens after arrival), `fs.promises.cp`, and `fs.createReadStream`. The first of them fetches the archive, inflates it, checks that it carries exactly the registered paths at the registered sizes, and materializes the files; concurrent readers share one fetch. The digest in the archive name is not verified. A failed fetch fails only the reads waiting on it; the files stay registered and the next asynchronous read fetches again.
|
||||
- **Before arrival**: synchronous reads, and writes, appends, truncation, chmod, renames, and links in their synchronous and promise forms, are refused with `ERR_DSH_VFS_DEFERRED`, naming the archive; a directory rename that would move a registered file is refused before the destination changes, so the destination stays as it was. Unlink and recursive removal drop the registration, and seeding the path replaces it; an archive that arrives afterwards leaves those paths as they are.
|
||||
- **client-modules**: `chunkResponse` reads a chunk with `fs.promises.readFile` inside `lazyBody`, which shares one read among concurrent requests and drops a rejected read once it settles, so the next request reads again. Under Node the asynchronous read returns the same bytes as a synchronous one.
|
||||
|
||||
### Watching and Client plugin delivery
|
||||
|
||||
- Worker `fs.watch` and `fs.watchFile` listeners run in the async context where the watcher was created, as Node delivers watch events from the watch handle, not in the context of the code whose write produced the event. HMR's configuration watcher therefore runs outside the writer's HMR transaction, and a configuration write made inside a transaction does not make the follow-up reload fail as a nested transaction.
|
||||
- The browser half of `@deepseek-ai/dsh-client-hmr` reads `/plugins/events` through the page's `__DSH_TRANSPORT__.fetch` when a shell provides one (the Worker preview tunnel), otherwise through `EventSource`. The Client plugins of a bundle enabled in the Preview therefore mount without a page reload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the pack-time composition and wrap it in a synthetic base bundle inside the Worker.** The change is smaller, but the synthetic bundle appears in the plugin list, the self-composed pack-time configuration remains, and the Preview still differs from the served Web deployment.
|
||||
|
||||
**Fetch chunks with synchronous XHR in the Worker.** client-modules stays unchanged, but the whole Host tree blocks while the download runs.
|
||||
|
||||
**Prefetch in the tunnel by recognizing chunk request URLs.** The tunnel would need a copy of client-modules' `/plugins/<id>/<file>` routing rules.
|
||||
|
||||
**Combine archives per package or above a size threshold.** Each `require.async` needs exactly one file, so one archive per chunk fits best; a threshold would be a tunable with no supporting evidence.
|
||||
|
||||
**Port Node v24's CommonJS loader and resolver into the Worker and replace the Worker's whole `require` pipeline.** Resolution would match Node exactly, but it requires Node's condition set (changing image contents), cjs-module-lexer for named exports, and about 300 KB more in worker.js, while callers need only the internal-module interfaces.
|
||||
|
||||
**Inject a resolver into `readPluginMeta`.** It changes an app-boot public function to accommodate a platform difference, and other `fromInternal()` callers such as the configuration schema dump still get no loader.
|
||||
|
||||
**Emulate `--expose-internals`.** Without that flag, the vendored Loader, HMR, and app-boot all fall back to `node-addon-require-builtin`, so replacing that package covers every caller.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Preview boots the composition the served Web deployment boots. With no bundle enabled, the Worker composition differs from the served Web composition only by the jsonl platform override, and enabling any bundle skips no patch. The Plugins page and Settings → Built-in plugins list every `OPTIONAL_BUNDLES` entry with its title and icon and no metadata error. Switching each of the 11 optional bundles on and then off returns `applied` both times; after enabling, every enabled row it changes is active, and after disabling, the plugin list returns to its prior rows. Enabling session-titles disables `@deepseek-ai/dsh-session-title-first-prompt-llm` (row `session-title-llm`) and enables `@deepseek-ai/dsh-session-title-all-prompts-llm`, as in the served Web deployment; enabling inspector-profile runs Session Inspector and starts the NodeJS Inspector degraded. Degradation requires `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, so a served `dsh web` runs the full Inspector.
|
||||
|
||||
Size (MiB, measured): the base image is 9.20 (12.79 with pack-time composition and eager chunks, −28%) including 11 optional bundles, plus 5 lazy tar archives totaling 4.45 (pdf 2.88, excel 1.39, terminal 0.13, frontmatter 0.05, Inspector bottom panel 0.00). The base image contains no lazily loaded chunk, and the page fetches an archive only when it requests the chunk.
|
||||
|
||||
The trade-off costs:
|
||||
|
||||
- HMR replaces no modules: the Worker's ES module cache stays empty, so a module file changed in the VFS keeps running its loaded code while HMR reports `applied`. The Preview image changes no module files (package operations fail with the `execa` refusal).
|
||||
- The Node internal-module members that exist only by name refuse when called; a future caller that invokes them fails explicitly in the Preview.
|
||||
- Reporting `process.versions.node` as `24.18.0` can change third-party code that branches on the version; a scan of the image found only undici selecting wasm SIMD by version on ppc64, which does not apply to the Worker.
|
||||
- CommonJS `require('stream')` returns the module namespace rather than the `Stream` constructor, so code that passes it to `util.inherits` fails at load.
|
||||
- Capabilities in optional bundles that cannot run in a browser (sqlite, terminals, local speech recognition) fail when used: enabling them reports no error, and using them does. A degraded NodeJS Inspector keeps only its bootstrap route, which answers 503.
|
||||
- The image layout carries no `config/cordis.yml`, `WorkerHostOptions` has no `configPath`, and an image manifest without `profile` fails validation, so an image packed with a composed configuration must be repacked.
|
||||
- Deferred files require readers to use the waiting asynchronous faces; a new synchronous reader fails explicitly in the Preview instead of degrading. The Worker checks only the registered paths and sizes of an arrived archive, not the digest that names it.
|
||||
|
||||
## Testing
|
||||
|
||||
- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) pins the roster (template bundles, every optional bundle, and the packages their patch rows name) and an image that records the profile and installation and carries only the installation manifest.
|
||||
- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) pins one archive per `require.async` chunk, identical archive names and bytes across packs, refusal of an unpublished chunk, and `./client` export targets kept in the base image.
|
||||
- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) pins registration answers, `ERR_DSH_VFS_DEFERRED` refusals, the unchanged rename destination, removal and seeding, one shared fetch, retry after a failed fetch, and the waiting `node:fs` faces.
|
||||
- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) pins the provided internal modules, refusal of other ids with the platform code, the cascaded loader, the members HMR replaces, and the reported Node version; [fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) pins watcher listeners running in the watcher's creation context.
|
||||
- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) pins Inspector degradation; [transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) pins the page-transport event stream; [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) pins the chunk read retry.
|
||||
- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) boots the packed Worker deployment, requests a lazy chunk through the tunnel, switches every optional bundle on and off, checks the session-titles and inspector-profile rows, and mounts an enabled bundle's Client plugin without a reload. The image sizes are measured, not pinned by a test.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Agent Note: Preview Worker 按正式 profile 启动,懒加载 chunk 按需拉取
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-14-webworker-preview-profile-parity.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
浏览器 Preview([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.zh.md)、[webworker-packer](../../../../packages/experimental/webworker-packer/README.zh.md))的启动配置可以在打包时生成:packer 用临时 home 运行 `dsh --profile web --dump-config`,把输出写进镜像的 `config/cordis.yml`;Worker 再把整份配置当作 profile 自己的补丁(一条 `insert`)插回去。在这样的镜像上实测,会带来三个后果:
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 插件页看不到任何 optional bundle | 镜像只收 dump 里出现的行,11 个默认关闭的 `OPTIONAL_BUNDLES` 都不在里面;Worker 的 `installAnchor` 指向 profile 自己的 package.json,没有安装依赖 |
|
||||
| 即使补上,启用后有的 bundle 也不生效 | bundle 层排在 profile 补丁前面,基础行这时还不存在:session-titles 的禁用补丁被跳过,agent-team 产生 4 条 `entry not found`,bundle 新增的行排到了最前面 |
|
||||
| 镜像 12.79 MiB | 4 个懒加载 chunk(documentpreview 的 pdf、excel、frontmatter,sidebar-terminal 的 terminal,共约 4.4 MiB)和启动必需的代码一起下载 |
|
||||
|
||||
实测:不开 bundle 时,两边组合的 188 行里只有 `session-persistence-jsonl` 的 `compression: none` 一处不同。差异来自配置的分层方式,不在数据本身。
|
||||
|
||||
## 决策
|
||||
|
||||
### 与正式 Web 相同的 profile 启动
|
||||
|
||||
正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路:
|
||||
|
||||
- **packer**
|
||||
- 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。
|
||||
- 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。
|
||||
- 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。
|
||||
- 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。
|
||||
- **Worker**
|
||||
- 用镜像里 app-boot 的 `loadProfile` 从模板初始化 `home/profiles/<profile>`,写空的根 `cordis.yml`,用 `readProfilePatches` 组合。
|
||||
- profileContext 按 `runProfile` 填写:name、dir、patchPath、installAnchor、startedBundles、cwd、home,overlay 列表为空,因此 Worker 与不带 `--patch` 的启动器一样只调用一次 `readProfilePatches`。
|
||||
- 提供 launchEnvironment 快照,挂上不带 resolution 的 `PluginPackages`。
|
||||
- **保留的平台差异**:jsonl `compression: none`(经镜像的 home patch 提供;ConfigEditor 会拒绝修改这一行的配置,与拒绝任何被 home patch 覆盖的行一致)、模块加载器(Worker 自己的 CommonJS 加载器,经 Node internal 模块接口提供,见下节)、日志输出、启动参数、appReady 时机、没有 pnpm、没有进程级代理和信号处理。
|
||||
|
||||
Worker 只用 app-boot 现有导出;app-boot 没有 Preview 专用的 override 或注入点。
|
||||
|
||||
### Worker 提供 Node internal 模块接口
|
||||
|
||||
读插件元信息(`readPluginMeta`)、配置 schema 导出和 HMR 不经过 ctx,而是用 `ModuleLoader.fromInternal()` 或 `node-addon-require-builtin.requireBuiltin(id)` 直接取 Node 的 internal 模块。如果 Worker 藏起这些模块,例如把 `process.versions.node` 报成 `0.0.0` 让 `fromInternal()` 返回空,挂上 `PluginPackages` 后这些调用方就会出错:插件页每个 bundle 都报“Plugin metadata requires the Node module resolver”,HMR 看不到 `loadCache`,每次开关都返回 `restart-required`。
|
||||
|
||||
- **只照搬接口**:Worker 用静态模块替换 `node-addon-require-builtin`,`requireBuiltin(id)` 按 Node v24.18.0 的 id 返回调用方实际读取的 5 个模块,成员名和签名与 Node 一致;其余 id 抛 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`。`process.versions.node` 报 `24.18.0`,`execArgv` 不带 `--expose-internals`。
|
||||
- **`internal/modules/esm/loader`**:cascaded loader 的 `resolveSync(parentURL, request)` 和 `import` 交给 Worker 现有的加载器,解析失败带 Node 的 `ERR_MODULE_NOT_FOUND`、`ERR_PACKAGE_PATH_NOT_EXPORTED`;`loadCache` 是空的 `LoadCache`;`getOrCreateModuleJob` 存在但调用即拒绝。cascaded loader 由 Worker 加载器实例持有。
|
||||
- **HMR 替换的成员**:`internal/modules/cjs/loader` 的 `Module`、`package_json_reader`、`internal/bootstrap/realm` 的 `modules` binding、`module_map` 的 `ResolveCache` 保留 Node 的成员名,供 HMR 替换与还原;被调用时拒绝。Worker 自己的 `require` 不经过它们。
|
||||
- **同一个 cascaded loader**:Worker 不覆写 `ctx.loader.internal`;Cordis Loader 与静态调用方经 `fromInternal()` 拿到同一个 cascaded loader。
|
||||
- **按实测调用方补齐的 Node 行为**:`node:path.win32.isAbsolute`(图标路径校验)、`node:util.inherits`(`http-errors`),均按 Node 源码实现。
|
||||
|
||||
### Inspector 在没有 worker_threads 的平台上降级
|
||||
|
||||
Worker 的结构化 stub 抛出带 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 的错误。Inspector Host 只在 Worker 启动以这个错误码失败时降级:警告一次,bootstrap 路由返回 503,不注册 DevTools 路由、不注入、不提供 `inspector` 服务;其他启动错误让 fiber 失败。`serve-static` 和 `open` 在 Worker 启动成功后才加载,降级时不加载。同一 bundle 里的 Session Inspector 因此正常运行。
|
||||
|
||||
### 懒加载 chunk 独立归档
|
||||
|
||||
- **分类**:client 构建预设只以 `require.async("./client.<name>.js")` 的形式产出包内 chunk 请求,`verify-client-packages` 禁止同步 require chunk。packer 的 `lazyChunksOf` 扫描清扫后镜像里的每个页面资源(各包的 `lib/client.js` 和 `lib/client.*.js`)中的这些字面量,因此 chunk 引用的 chunk 不必从 `./client` 入口沿请求追踪也能找到。在任一条件下被某个包的 `./client` 导出指向的目标(如 `client.browser.js`)会被立即加载,留在基础镜像;其余目标移出基础镜像。请求包未发布的 chunk 会让打包失败。
|
||||
- **归档**:每个 chunk 一个确定性 tar.gz,按压缩后归档的 SHA-256 命名为 `lazy/<sha256>.tar.gz`。基础 manifest 的 `lazy` 列表把每个 archive 记录为 `{ archive, files: [{ path, size }] }`,不记录文件 mode。不设大小阈值。
|
||||
- **登记**:Worker 用 `MemoryVfs.deferArchive` 登记列出的每个文件,同时创建其父目录,并赋予 VFS 默认文件 mode;列出懒加载 archive 的镜像必须从 URL 拉取,archive 名相对这个 URL 解析。`exists`、`stat`、`readdir` 依据登记同步作答。
|
||||
- **拉取**:Worker 的 `node:fs` 和 `node:fs/promises` 中读取已登记文件的异步接口会等待其 archive:`fs.promises.readFile`、`fs.promises.open`(因此每个 FileHandle 都在到达后才打开)、`fs.promises.cp` 和 `fs.createReadStream`。其中第一个调用拉取 archive、解压,校验其中恰好是登记的路径和登记的大小,再物化文件;并发读取方共用一次拉取。archive 名里的摘要不做校验。拉取失败只让等待它的读取失败;文件保持登记,下一次异步读取再次拉取。
|
||||
- **到达之前**:同步读取,以及同步和 promise 形式的写入、追加、截断、chmod、重命名和硬链接,都以 `ERR_DSH_VFS_DEFERRED` 拒绝,并给出 archive 名;会移动已登记文件的目录重命名在目标改变之前就被拒绝,目标保持原样。unlink 和递归删除会撤销登记,对该路径 seed 会替换登记;之后到达的 archive 不改动这些路径。
|
||||
- **client-modules**:`chunkResponse` 在 `lazyBody` 里用 `fs.promises.readFile` 读取 chunk;`lazyBody` 让并发请求共用一次读取,被拒绝的读取一旦落定就丢弃,下一次请求重新读取。在 Node 下,异步读取与同步读取得到相同字节。
|
||||
|
||||
### 文件监听与 Client 插件下发
|
||||
|
||||
- Worker 的 `fs.watch` 和 `fs.watchFile` 监听器在创建 watcher 时的异步上下文里运行,与 Node 从 watch 句柄派发事件一致,而不是在写入方的上下文里运行。因此 HMR 的配置 watcher 在写入方的 HMR 事务之外运行,事务内的配置写入不会让随后的重载因嵌套事务而失败。
|
||||
- `@deepseek-ai/dsh-client-hmr` 的浏览器部分在 shell 提供 `__DSH_TRANSPORT__.fetch` 时(Worker preview 隧道)经它读取 `/plugins/events`,否则经 `EventSource` 读取。因此在 Preview 里启用 bundle 后,其 Client 插件无需刷新页面即可挂载。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留打包期组合,只在 Worker 里包成一个合成 base bundle。** 改动小,但合成 bundle 会出现在插件列表里,打包期“自己编”的配置也还在,与正式 Web 不同构。
|
||||
|
||||
**Worker 用同步 XHR 拉 chunk。** 不改 client-modules,但下载期间整棵 Host 树都被阻塞。
|
||||
|
||||
**隧道按 URL 识别 chunk 请求后预取。** 需要在隧道里复制 client-modules 的 `/plugins/<id>/<file>` 路由规则。
|
||||
|
||||
**按包或按大小阈值合并 archive。** 每次 `require.async` 只要一个文件,按 chunk 拆分最贴合;阈值是没有依据的可调参数。
|
||||
|
||||
**把 Node v24 的 CJS 加载器和解析器源码搬进 Worker,替换 Worker 的整条 require 流程。** 解析路径可以与 Node 逐字一致,但要改用 Node 的条件集(镜像内容随之变化)、引入 cjs-module-lexer 计算具名导出,worker.js 约增加 300 KB;而调用方只需要 internal 模块的接口。
|
||||
|
||||
**给 `readPluginMeta` 注入解析器。** 要改 app-boot 的公共函数来适配一个平台差异,配置 schema 导出等其他 `fromInternal()` 调用方仍然拿不到加载器。
|
||||
|
||||
**模拟 `--expose-internals`。** vendor Loader、HMR 和 app-boot 在没有这个参数时都退到 `node-addon-require-builtin`,替换这个包即可覆盖全部调用方。
|
||||
|
||||
## 后果
|
||||
|
||||
Preview 启动的组合与正式 Web 相同。不开 bundle 时,Worker 组合与正式 Web 只差 jsonl 的平台覆盖;开任一 bundle 都没有被跳过的补丁。插件页和“设置 → 内置插件”列出全部 `OPTIONAL_BUNDLES`,每个都显示标题和图标,没有元信息错误。11 个 optional bundle 逐个开启再关闭,两次都返回 `applied`;开启后它改动的每个启用行都是 active,关闭后插件列表恢复原样。启用 session-titles 会禁用 `@deepseek-ai/dsh-session-title-first-prompt-llm`(行 `session-title-llm`)并启用 `@deepseek-ai/dsh-session-title-all-prompts-llm`,与正式 Web 一致;启用 inspector-profile 后 Session Inspector 运行,NodeJS Inspector 降级启动。降级以 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 为前提,所以正常 `dsh web` 运行完整的 Inspector。
|
||||
|
||||
体积(MiB,实测):基础镜像 9.20(打包期组合且 chunk 随启动下载时为 12.79,−28%),含 11 个 optional bundle;另有 5 个 lazy tar 共 4.45(pdf 2.88、excel 1.39、terminal 0.13、frontmatter 0.05、inspector 底部面板 0.00)。基础镜像不含懒加载 chunk,页面请求 chunk 时才拉取对应 archive。
|
||||
|
||||
这一取舍的代价:
|
||||
|
||||
- HMR 不替换模块:Worker 的 ES module 缓存始终为空,VFS 里被修改的模块文件仍运行已加载的代码,而 HMR 照常报告 `applied`。Preview 镜像不会修改模块文件(包操作会因 `execa` 拒绝而失败)。
|
||||
- Node internal 模块里只提供成员名的部分被调用时拒绝;将来有调用方真正调用它们,会在 Preview 里明确失败。
|
||||
- `process.versions.node` 报 `24.18.0` 可能改变按版本分支的第三方代码;扫描镜像只发现 undici 在 ppc64 上按版本选择 wasm SIMD,与 Worker 无关。
|
||||
- CommonJS 的 `require('stream')` 返回模块命名空间而不是 `Stream` 构造函数,把它传给 `util.inherits` 的代码会在加载时失败。
|
||||
- optional bundle 里浏览器跑不了的能力(sqlite、终端、本地语音)按“跑到才炸”处理:启用不报错,用到时报错。NodeJS Inspector 降级后只剩返回 503 的 bootstrap 路由。
|
||||
- 镜像布局里没有 `config/cordis.yml`,`WorkerHostOptions` 没有 `configPath`,缺少 `profile` 的镜像 manifest 无法通过校验,所以带组合配置打出的镜像必须重新打包。
|
||||
- 延迟文件要求读取方走会等待的异步接口;新增的同步读取方会在 Preview 里明确失败,而不是退化。Worker 只校验到达的 archive 中登记的路径和大小,不校验命名它的摘要。
|
||||
|
||||
## 测试
|
||||
|
||||
- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) 锁定收包清单(模板 bundle、全部 optional bundle、它们补丁行指向的包),以及记录 profile 和 installation、只带安装清单的镜像。
|
||||
- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) 锁定每个 `require.async` chunk 一个 archive、多次打包得到相同的 archive 名和字节、拒绝未发布的 chunk、`./client` 导出目标留在基础镜像。
|
||||
- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) 锁定登记后的作答、`ERR_DSH_VFS_DEFERRED` 拒绝、重命名目标保持不变、删除与 seed、共用一次拉取、拉取失败后重试,以及会等待的 `node:fs` 接口。
|
||||
- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) 锁定提供的 internal 模块、其他 id 以平台错误码拒绝、cascaded loader、HMR 替换的成员和报告的 Node 版本;[fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) 锁定监听器在 watcher 创建时的上下文里运行。
|
||||
- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) 锁定 Inspector 降级;[transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) 锁定经页面 transport 读取事件流;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 锁定 chunk 读取重试。
|
||||
- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 启动打包好的 Worker 部署,经隧道请求一个懒加载 chunk,逐个开关全部 optional bundle,检查 session-titles 和 inspector-profile 的行,并在不刷新页面的情况下挂载已启用 bundle 的 Client 插件。镜像体积是实测值,没有测试锁定。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 0187ac573d06a360
|
||||
zh: 13e08f4f22bacfff
|
||||
/agent-note-persistent-pty-sessions/decision/agent-ownership-and-identity:
|
||||
en: 9bf79081f102eb51
|
||||
zh: 34855888f0614b47
|
||||
en: c2a38b8c0403e1e4
|
||||
zh: 6063cf5e41a6582e
|
||||
/agent-note-persistent-pty-sessions/decision/security-and-process-boundary:
|
||||
en: 882bbfd3f0dc6e09
|
||||
zh: a547d6ff5e6ef7a1
|
||||
|
||||
@@ -36,6 +36,8 @@ There are no plugin-load auto-start sessions. `terminal_open` creates a session
|
||||
|
||||
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects or returns a session whose rollback close fails; that cleanup failure remains tracked for later owner or service disposal instead of replacing the caller reason. A lifecycle-triggered rollback close failure rejects both the spawn and the disposing lifecycle, while `TerminalBackendCleanupError` lets a backend preserve its own failed startup cleanup for the disposing lifecycle without replacing a caller cancellation. When caller cancellation settles before disposal, the cleanup failure remains tracked owner activity until later owner or service disposal consumes and reports it, so sandbox-mode policy cannot mistake failed cleanup for quiescence. Backend or tool-plugin reload does not orphan sessions: ownership lives in `TerminalSessionService` until the agent ends, following the same service-owned-record pattern as [`ctx.jobs`](../../../../packages/jobs/jobs/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership.
|
||||
|
||||
The [cleanup dependency proposal](../../proposed/architecture/2026-10-09-cordis-cleanup-dependency-lifetimes.md) distinguishes early admission or optional-notification closure from releasing required completion observers. PTY ownership and quiescent teardown remain required.
|
||||
|
||||
### Security and process boundary
|
||||
|
||||
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-terminal-bash` therefore applies two protections before spawning:
|
||||
|
||||
@@ -36,6 +36,8 @@ harness 可以运行前台与后台命令、编辑文件和委派工作,但无
|
||||
|
||||
agent scope dispose(资源释放)时先撤销注册,再等待全部所属 PTY 完全停稳。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端 reject,或返回的会话在回滚 close 时失败,调用方取消仍会原样保留其 `AbortSignal.reason`;该清理失败不会替换调用方原因,而会继续受追踪,留待后续 owner 或服务 dispose 处理。由 lifecycle dispose 触发的回滚 close 失败会使 spawn 与该 lifecycle dispose 都 reject,而 `TerminalBackendCleanupError` 让后端在不替换调用方取消的前提下,为该 lifecycle dispose 保留自身的启动清理失败。若调用方取消在 dispose 开始前已经结算,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为完全停稳。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `TerminalSessionService` 中,直到 agent 结束,与 [`ctx.jobs`](../../../../packages/jobs/jobs/README.zh.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 job id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
|
||||
|
||||
[清理依赖提案](../../proposed/architecture/2026-10-09-cordis-cleanup-dependency-lifetimes.zh.md)区分提前关闭准入或可选通知与释放必需的完成观察器。PTY 所有权与完全停稳的清理要求仍然成立。
|
||||
|
||||
### 安全与进程边界
|
||||
|
||||
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-terminal-bash` 在 spawn 前应用两层保护:
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: bab62798c2dc07b4
|
||||
zh: fe42c5abf9127d37
|
||||
/agent-note-durable-agent-teams-over-continuable-children/decision:
|
||||
en: 95e7d723d6b00930
|
||||
zh: d18fdf9b2bf69bdb
|
||||
en: 35650a30529c01f8
|
||||
zh: 4f9ee01b568d2fed
|
||||
/agent-note-durable-agent-teams-over-continuable-children/profile-delegation:
|
||||
en: 4835fb8c0c0d8a0d
|
||||
zh: 5f9438a0d58104c4
|
||||
|
||||
@@ -16,7 +16,7 @@ Agent Teams needs an explicit opt-in composition while its public contracts rema
|
||||
|
||||
Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`.
|
||||
|
||||
The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion.
|
||||
The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The shared policy announces Team availability and describes coordination responsibilities. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion.
|
||||
|
||||
The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的
|
||||
|
||||
每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。
|
||||
|
||||
实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。
|
||||
实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。共享策略声明 Team 可用,并说明协调责任。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。
|
||||
|
||||
Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。
|
||||
|
||||
|
||||
+4
-4
@@ -18,14 +18,14 @@
|
||||
en: 554d01427ebebee1
|
||||
zh: 664c9bf0a9337a04
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting:
|
||||
en: 00a91b4fb592e1e4
|
||||
zh: dac2e4f0a3ee9ecf
|
||||
en: 800d579e2fcf2cce
|
||||
zh: 1e92354d9161c50a
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/compaction:
|
||||
en: c21a3e7a773860e5
|
||||
zh: 566669e245ccdbaf
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered:
|
||||
en: f3dec242b2550529
|
||||
zh: 0397fa07cc1806bb
|
||||
en: 02b49911bc4601d0
|
||||
zh: bfa19fd7c4337812
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences:
|
||||
en: beda064bcd194a2a
|
||||
zh: 8a3bb81d5edcd60a
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ An empty head with no active later system node represents no prompt. Dormant emp
|
||||
|
||||
Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`.
|
||||
|
||||
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
|
||||
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The unit retains only the surviving system prompts and a running surface total, and prices replacements from the adjacent shadow-price event ([system-prompt state](../architecture/2026-10-13-context-breakdown-system-prompt-state.md)). `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
|
||||
|
||||
Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence.
|
||||
|
||||
@@ -73,7 +73,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat
|
||||
|
||||
**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected.
|
||||
|
||||
**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification.
|
||||
**Keep only scalar totals.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. Retained system prompts with the shadow-price event's `shadowedSeqs` answer both questions; [the system-prompt state note](../architecture/2026-10-13-context-breakdown-system-prompt-state.md) records that choice over positional entries.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ Status: implemented
|
||||
|
||||
Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。
|
||||
|
||||
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
|
||||
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。该单元只保留存活的系统提示词和一个 surface 运行总量,并依据紧邻的影子价事件为替换定价([系统提示词状态](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
|
||||
|
||||
Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。
|
||||
|
||||
@@ -73,7 +73,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释
|
||||
|
||||
**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。
|
||||
|
||||
**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。
|
||||
**只保留标量总量。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。保留系统提示词并结合影子价事件的 `shadowedSeqs` 可以回答这两个问题;[系统提示词状态说明](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md)记录了为何以这种方式取代位置条目。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-02-plugin-market-discover.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md
|
||||
/agent-note-plugin-market-discover-page-and-index-reader:
|
||||
en: e226087ae345768b
|
||||
zh: b043fa6c793fd684
|
||||
/agent-note-plugin-market-discover-page-and-index-reader/problem:
|
||||
en: 329c6ff947559b11
|
||||
zh: 5c4c1053236d319b
|
||||
/agent-note-plugin-market-discover-page-and-index-reader/decision:
|
||||
en: cc4d17a51dd479b7
|
||||
zh: 1fecf669b117f51c
|
||||
/agent-note-plugin-market-discover-page-and-index-reader/alternatives-considered:
|
||||
en: 62e7691dc8efd5c0
|
||||
zh: b13769634979b950
|
||||
/agent-note-plugin-market-discover-page-and-index-reader/consequences:
|
||||
en: 672397a936cae42f
|
||||
zh: 1f535708b914a915
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: Plugin market Discover page and index reader
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-02-plugin-market-discover.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [plugin market index format](2026-10-02-plugin-market-index-format.md) lets a market publish installable bundles in one document, but DSH needs a reader that fetches indexes safely, a place where users choose index sources and browse entries, and a path from an entry to the existing installation flow. No official market exists, so DSH cannot ship a default source; the sources are user data.
|
||||
|
||||
The plugin manager owns profile package operations and must not remount while an installation runs. Market configuration that a user edits from the Web therefore cannot be an ordinary configuration field of `plugin-manager`.
|
||||
|
||||
## Decision
|
||||
|
||||
The feature ships as the experimental optional [custom plugin markets bundle](../../../../packages/experimental/plugin-market-bundle/README.md), off by default, which mounts both rows below; with it off, neither the service, the Discover tab, nor the `plugin_market` tool exists, and the Plugins page shows no tabs. A separate Host service, [`pluginMarket`](../../../../packages/boot/plugin-market/README.md), reads indexes. It requires `pluginManager` for read-only calls and never writes the profile. Its `indexes` configuration field is volatile: Settings writes only volatile fields, and the Loader commits a volatile-only change into the running reference without remounting any plugin. Other fields (`fetchTimeoutMs`, `maxBytes`, `cacheTtlMs`) are ordinary configuration.
|
||||
|
||||
Index URLs use `http:` or `https:`, without credentials or fragment. `file:` is excluded because relative icon references would resolve to URLs a browser cannot load. `search` and `entry` accept only a configured URL, so the Host never fetches an address a client supplies. The Host fetches through the process proxy, refuses redirects, bounds the body, and validates with the shared format package; an invalid index affects only its own URL.
|
||||
|
||||
Indexes can reach the default 128 MiB `maxBytes`, so clients never receive or filter a whole index. `search` filters, matches text, orders, and pages on the Host and returns at most 100 entries; `entry` returns one plugin by name for the tool. Text matching is fuzzy and covers the package name, author, keywords, and every language of the title, description, and category and label titles, because a person may search in any language a market writes. A Chinese, Japanese, or Korean run is indexed as overlapping character pairs and its last character, so queries match without a word dictionary. [MiniSearch](https://github.com/lucaong/minisearch) builds the text index on the first text query of each document and yields between chunks. Each page carries its document's `fetchedAt`, and a later page sent with it is answered from that document while it is cached, so paging stays consistent across cache expiry; a client whose document was replaced starts again from the first page.
|
||||
|
||||
Each returned entry carries the profile's installation status, its compatibility computed by `evaluatePluginCompatibility` with the profile's version exemptions, and `install`, the exact arguments from `marketInstallTarget`. That is the only place install arguments are built; the Web page and the model tool consume it.
|
||||
|
||||
The Web [Discover tab](../../../../packages/client/ui-plugin-market/README.md) is its own client plugin. [`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.md) declares a `plugins.tab` slot and shows **Installed** and each contributed tab under the page header, because browsing a market and managing installed bundles are separate tasks of equal rank. The owner props provide `install(spec, registry?)` for a spec the person confirmed in the tab, which opens the existing install dialog at its check screen without the spec form, and `openPackage(name)`, which opens an installed bundle's page. A registry an entry names applies to that install only and is never remembered as the dialog's choice, so one market cannot redirect later manual installs. Discover confirms each install with the entry's origin and a trust notice, and keeps source management, which adds a code origin, in a dialog with its own trust notice. Each entry has a page with its full description, source facts, and every listed version; **Install** on a card chooses the default version, and the page can install any other version installation accepts. Indexes are browsed one at a time; categories, labels, and sort keys stay opaque and display in localized title order; entries missing the selected sort value come last in both directions.
|
||||
|
||||
A read-only `plugin_market` tool lists indexes and pages entries, returning the `target` and `registry` that `plugin_manager`'s `install_bundle` accepts. It requires no approval; installation keeps `plugin_manager`'s approval. The tool cannot change index sources, because adding a source introduces a new code origin.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Market fields on `plugin-manager`.** A non-volatile field would remount `plugin-manager` when the Web edits it and interrupt package operations it owns. Even with a volatile field, a separate service guarantees that market configuration never reaches `plugin-manager`, at the cost of one more package and service.
|
||||
|
||||
**Configuring indexes only in `cordis.yml`.** Most users do not edit `cordis.yml`, so Discover would stay empty for them.
|
||||
|
||||
**Fetching indexes in the browser.** CORS, the Host's proxy configuration, and validation where untrusted data enters favor Host fetches.
|
||||
|
||||
**Filtering in the browser.** It needs the whole annotated index in every client, which grows with the index and recomputes compatibility for every entry on each read.
|
||||
|
||||
**A query protocol on the index host.** It would keep large indexes off the Host but requires a server for every market, which a static index file avoids. It remains the next step if indexes outgrow the Host.
|
||||
|
||||
**Merging all configured indexes into one list.** Opaque ids from different markets may collide with different meanings, and duplicate package names need a conflict rule. Per-index browsing avoids both.
|
||||
|
||||
**Rendering Discover inside `ui-plugin-manager`.** The page would depend on the market Remote and grow further; a slot keeps the page usable without the market and lets the market plugin own its copy and state.
|
||||
|
||||
**Discover as a section under the Installed groups, installing through the spec form.** Finding it requires scrolling past every installed bundle, and the form asks a person to review a spec they never typed. Tabs give browsing its own entry point, and a confirmation with the entry's origin followed by the check screen replaces the form.
|
||||
|
||||
**An approval for `plugin_market`.** Listing changes nothing and reads only sources the user configured; requiring approval would add a prompt without protecting an operation. The installation step keeps its approval.
|
||||
|
||||
**An upgrade action.** Upgrading changes the documented "uninstall, then install" behavior and is deferred; Discover shows `update-available` as a status.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users browse and install third-party bundles without knowing a spec, and the existing install dialog, registry fallback, build-script approval, and compatibility refusal apply unchanged.
|
||||
- The Settings form also shows `indexes`, because Settings lists every volatile field.
|
||||
- An `http:` index, including the install specs it lists, can be altered in transit; a market reached over an untrusted network needs `https:`.
|
||||
- URL icons are requested by the browser and disclose the user's IP address to image hosts; Discover loads them without a referrer.
|
||||
- Market text reaches users and, through `plugin_market`, models; the tool description marks it as third-party content.
|
||||
- A real-composition test proves that `setIndexes` persists through the profile without remounting the service, and unit tests cover fetch limits, cache sharing, removal, and search matching, ordering, and paging.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: 插件市场发现页与索引读取器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-02-plugin-market-discover.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[插件市场索引格式](2026-10-02-plugin-market-index-format.zh.md)让市场能在一份文档中发布可安装的组合包(bundle),但 DSH 还需要:一个安全获取索引的读取器,一个让用户选择索引源并浏览条目的位置,以及一条从条目进入现有安装流程的路径。目前没有官方市场,因此 DSH 无法随附默认源;索引源属于用户数据。
|
||||
|
||||
插件管理器拥有 profile 的包操作,安装运行期间不得重新挂载。因此,用户从 Web 编辑的市场配置不能是 `plugin-manager` 的普通配置字段。
|
||||
|
||||
## 决策
|
||||
|
||||
该功能以实验性的可选[自定义插件市场组合包](../../../../packages/experimental/plugin-market-bundle/README.zh.md)发布,默认关闭,并挂载下面两行;关闭时,服务、「发现」标签页和 `plugin_market` 工具都不存在,插件页也不显示标签页。由独立的 Host 服务 [`pluginMarket`](../../../../packages/boot/plugin-market/README.zh.md) 读取索引。它依赖 `pluginManager` 进行只读调用,从不写入 profile。它的 `indexes` 配置字段是 volatile 字段:Settings 只写入 volatile 字段,Loader 把只涉及 volatile 字段的变更提交到运行中的引用,不重新挂载任何插件。其他字段(`fetchTimeoutMs`、`maxBytes`、`cacheTtlMs`)是普通配置。
|
||||
|
||||
索引 URL 使用 `http:` 或 `https:`,不含凭据或片段。排除 `file:`,因为相对图标引用会解析为浏览器无法加载的 URL。`search` 和 `entry` 只接受已配置的 URL,因此 Host 从不获取客户端提供的地址。Host 通过进程代理获取,拒绝重定向,限制响应体大小,并用共享的格式包校验;无效索引只影响它自己的 URL。
|
||||
|
||||
索引可能达到默认 128 MiB 的 `maxBytes`,因此客户端从不接收或过滤整个索引。`search` 在 Host 上完成过滤、文本匹配、排序和分页,最多返回 100 个条目;`entry` 按名称为工具返回一个插件。文本匹配是模糊的,覆盖包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言,因为用户可能使用市场所写的任意语言搜索。中文、日文或韩文片段被索引为相邻的两字组合及其最后一个字符,因此查询无需分词词典即可匹配。[MiniSearch](https://github.com/lucaong/minisearch) 在每份文档第一次带文本的查询时构建文本索引,并在分块之间让出事件循环。每页都携带其文档的 `fetchedAt`,之后带着它发送的页请求只要该文档仍在缓存中就用它回答,因此分页在缓存过期后仍保持一致;文档被替换的客户端从第一页重新开始。
|
||||
|
||||
每个返回的条目携带 profile 中的安装状态、由 `evaluatePluginCompatibility` 结合 profile 版本豁免计算出的兼容性,以及 `install`,即 `marketInstallTarget` 给出的精确参数。这是唯一构建安装参数的位置;Web 页面和模型工具都使用它。
|
||||
|
||||
Web 的[发现标签页](../../../../packages/client/ui-plugin-market/README.zh.md)是独立的 Client 插件。[`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.zh.md) 声明 `plugins.tab` slot,并在页头下方显示**已安装**和每个贡献的标签页,因为浏览市场与管理已安装组合包是两项同等重要的独立任务。owner props 提供 `install(spec, registry?)` 和 `openPackage(name)`:前者用于用户已在标签页中确认的 spec,在检查界面打开现有的安装对话框,不显示 spec 表单;后者打开已安装组合包的页面。条目指定的注册表只用于这一次安装,从不作为对话框的选择被记住,因此一个市场无法改变之后手动安装所用的注册表。发现标签页在每次安装前显示条目来源和信任提示以供确认,并把会引入代码来源的索引源管理放在带有自身信任提示的对话框中。每个条目都有一个页面,显示其完整描述、来源信息和列出的每个版本;卡片上的**安装**选择默认版本,页面可以安装安装检查接受的任何其他版本。索引一次浏览一个;分类、标签和排序键保持不透明,按本地化标题顺序显示;缺少所选排序值的条目在两个方向上都排在最后。
|
||||
|
||||
只读的 `plugin_market` 工具列出索引并分页列出条目,返回 `plugin_manager` 的 `install_bundle` 接受的 `target` 和 `registry`。它不需要批准;安装仍保留 `plugin_manager` 的批准。该工具不能修改索引源,因为添加索引源会引入新的代码来源。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把市场字段放在 `plugin-manager` 上。** 非 volatile 字段在 Web 编辑它时会重新挂载 `plugin-manager`,中断它拥有的包操作。即使使用 volatile 字段,独立服务也能保证市场配置永远不会到达 `plugin-manager`,代价是多一个包和服务。
|
||||
|
||||
**只在 `cordis.yml` 中配置索引。** 大多数用户不编辑 `cordis.yml`,发现区对他们会一直为空。
|
||||
|
||||
**在浏览器中获取索引。** CORS、Host 的代理配置,以及在不可信数据进入处进行校验,都支持由 Host 获取。
|
||||
|
||||
**在浏览器中过滤。** 这需要每个客户端都拿到整个带注解的索引,其大小随索引增长,并且每次读取都要为每个条目重新计算兼容性。
|
||||
|
||||
**在索引主机上提供查询协议。** 它能让大索引不进入 Host,但每个市场都需要一台服务器,而静态索引文件不需要。如果索引超出 Host 的承受能力,这是下一步。
|
||||
|
||||
**把所有已配置索引合并成一个列表。** 不同市场的不透明 id 可能相同但含义不同,重复的包名也需要冲突规则。按索引分别浏览可以避免这两个问题。
|
||||
|
||||
**在 `ui-plugin-manager` 内渲染发现区。** 该页面会依赖市场 Remote,代码量也会继续增加;使用 slot 时,没有市场该页面仍可使用,市场插件也能拥有自己的文案和状态。
|
||||
|
||||
**把发现作为「已安装」分组下方的区域,并通过 spec 表单安装。** 找到它需要滚动经过每个已安装组合包,而且该表单要求用户审核一个自己从未输入的 spec。标签页为浏览提供独立入口,先显示条目来源以供确认、再进入检查界面的流程取代了该表单。
|
||||
|
||||
**为 `plugin_market` 要求批准。** 列出操作不做任何改动,只读取用户配置的源;要求批准只会增加一次提示,而不保护任何操作。安装步骤保留其批准。
|
||||
|
||||
**升级操作。** 升级会改变已记录的「先卸载再安装」行为,因此延后;发现区把 `update-available` 显示为状态。
|
||||
|
||||
## 后果
|
||||
|
||||
- 用户无需知道 spec 即可浏览并安装第三方组合包,现有的安装对话框、registry 回退、构建脚本批准和兼容性拒绝照常适用。
|
||||
- 设置表单也显示 `indexes`,因为设置会列出每个 volatile 字段。
|
||||
- `http:` 索引(包括其中列出的安装 spec)可能在传输途中被篡改;经由不可信网络访问的市场需要使用 `https:`。
|
||||
- URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址;发现区加载图标时不发送 referrer。
|
||||
- 市场文本会到达用户,并通过 `plugin_market` 到达模型;工具描述将其标记为第三方内容。
|
||||
- 一个真实组合测试证明 `setIndexes` 会经由 profile 持久化而不重新挂载服务,单元测试覆盖获取限制、缓存共享、移除,以及搜索的匹配、排序和分页。
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md
|
||||
/agent-note-plugin-market-index-format:
|
||||
en: ee3515adb22d4af6
|
||||
zh: 5dfd687255f64e95
|
||||
/agent-note-plugin-market-index-format/problem:
|
||||
en: ce5e4a362f558af6
|
||||
zh: 29d93e0655b99217
|
||||
/agent-note-plugin-market-index-format/decision:
|
||||
en: 906ac925fc53dd87
|
||||
zh: affcc405e186b0d7
|
||||
/agent-note-plugin-market-index-format/decision/example:
|
||||
en: 1b1c960c966cf912
|
||||
zh: 97be87cb14893c26
|
||||
/agent-note-plugin-market-index-format/decision/root-fields:
|
||||
en: 4fac5a9b8261342d
|
||||
zh: f9168a8a4209feab
|
||||
/agent-note-plugin-market-index-format/decision/plugin-fields:
|
||||
en: b79abd05a957948b
|
||||
zh: b6f0c203ea8e4838
|
||||
/agent-note-plugin-market-index-format/decision/version-fields:
|
||||
en: d6c0fa99e1cbb068
|
||||
zh: 3d2a7b85299c679a
|
||||
/agent-note-plugin-market-index-format/decision/install-arguments:
|
||||
en: c748cdc4d8ce349a
|
||||
zh: 5740fa0d6106e637
|
||||
/agent-note-plugin-market-index-format/decision/dsh-compatibility:
|
||||
en: 00fe507b9b5dfea7
|
||||
zh: 55de6aaeb3865e84
|
||||
/agent-note-plugin-market-index-format/decision/generator-obligations:
|
||||
en: 923849223cb1e70e
|
||||
zh: f3daf398bc06c81a
|
||||
/agent-note-plugin-market-index-format/decision/reader-rules:
|
||||
en: 92903cbe87d9c0ca
|
||||
zh: 54af6cb33e9da252
|
||||
/agent-note-plugin-market-index-format/alternatives-considered:
|
||||
en: 908407ed974101d2
|
||||
zh: b4b7391c513bda60
|
||||
/agent-note-plugin-market-index-format/consequences:
|
||||
en: 4aee5ff31901049a
|
||||
zh: 8187b6216bb8b06b
|
||||
/agent-note-plugin-market-index-format/risks:
|
||||
en: 41170d483b17df80
|
||||
zh: 1bfd8f44e0f09036
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
# Agent Note: Plugin market index format
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-02-plugin-market-index-format.zh.md)
|
||||
|
||||
@@ -12,9 +12,9 @@ The smallest possible index is a list of npm names or Git repository URLs, one p
|
||||
|
||||
An index must therefore carry everything a list page displays: localized title and description, icon, author, available versions, compatibility, and publish time. Markets also want to publish their own data, such as categories, tags, downloads, and ratings, without DSH knowing what those values mean.
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
Separate the **index** from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. DSH's reader, its Discover page, and a reference generator are separate changes that consume this format. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md).
|
||||
The **index** is separate from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. [`dsh-plugin-market-index`](../../../../packages/util/plugin-market-index/README.md) is the executable rule set; the [Discover Agent Note](2026-10-02-plugin-market-discover.md) records how DSH reads and presents indexes. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md).
|
||||
|
||||
The market reuses npm's package names, versions, ranges, distribution tags, and person declarations. Readers and generators use maintained npm parsers and validators. Additional fields or changed meanings require a plugin-market requirement that npm does not meet.
|
||||
|
||||
@@ -45,6 +45,7 @@ The market reuses npm's package names, versions, ranges, distribution tags, and
|
||||
"icon": "icons/auto-review.svg",
|
||||
"author": "Example Labs",
|
||||
"homepage": "https://example.com/dsh-auto-review",
|
||||
"repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" },
|
||||
"metadata": {
|
||||
"category": "productivity",
|
||||
"labels": ["official"],
|
||||
@@ -114,9 +115,10 @@ Category, label, and sort-key ids are non-empty strings. Readers look ids up as
|
||||
| `name` | yes | Package name accepted by npm's maintained package-name validator for new packages; unique within the index. |
|
||||
| `title` | no | `LocalizedText`; a reader displays `name` when absent. |
|
||||
| `description` | no | `LocalizedText`. |
|
||||
| `icon` | no | Either `data:<type>;base64,<payload>`, where `<type>` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `<payload>` is canonically padded base64, and the decoded payload is at most 256 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
|
||||
| `icon` | no | Either `data:<type>;base64,<payload>`, where `<type>` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `<payload>` is canonically padded base64, and the decoded payload is at most 32 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
|
||||
| `author` | no | String with at least one non-whitespace character. |
|
||||
| `homepage` | no | Absolute `https:` or `http:` URL of a page about the plugin. |
|
||||
| `repository` | no | `{ "url": string, "directory"?: string }`. `url` is the absolute `https:` or `http:` URL of the source repository's web page, without credentials, query, or fragment; `directory` is the package directory inside it, with the `path` rule of a Git source. It is for display and never selects an install source. |
|
||||
| `metadata` | no | Object holding the values below. |
|
||||
| `metadata.category` | no | One declared category id. |
|
||||
| `metadata.labels` | no | Array of distinct declared label ids. |
|
||||
@@ -166,7 +168,7 @@ A reader cannot verify these rules; a generator that violates them publishes a m
|
||||
- `engines.dsh` copies the manifest's declared range unchanged. A version with a missing, blank, or invalid range is omitted with a diagnostic. A plugin with no eligible version is omitted.
|
||||
- `dist-tags` snapshots npm's [distribution tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), retaining only targets present among the eligible listed versions. For Git sources, the publisher or market supplies the same exact tag-to-version mapping.
|
||||
- Each listed version declares `dsh.bundle`. Installation independently refuses a package without a bundle patch.
|
||||
- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one.
|
||||
- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one. `repository` comes from that version's manifest `repository`: a shorthand or Git URL becomes the repository's web URL, `directory` is copied when it meets the `directory` rule and otherwise dropped while `url` stays, and the field is absent when the manifest has none or names no web-addressable repository.
|
||||
- `author` comes from the same highest listed version's npm author declaration. Copy an object's `name`; parse shorthand strings with a maintained npm person normalizer. Omit an absent or whitespace-only name and contact fields. Normalize a fresh projection, leaving the source manifest untouched.
|
||||
|
||||
### Reader rules
|
||||
@@ -197,7 +199,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se
|
||||
|
||||
**Display fields per version.** A list shows one row per plugin, so older display text, icons, authors, and homepages would cost index size without a reader that shows them.
|
||||
|
||||
**Inline icons only.** A list without extra requests needs inline icons, but 256 KiB of raw bytes is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index.
|
||||
**Inline icons only.** A list without extra requests needs inline icons, but the 256 KiB installed-plugin icon limit is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index. The 32 KiB inline limit is about 43 KiB as base64, so 1,000 plugins with inline icons add at most about 43 MB; larger icons use URL references.
|
||||
|
||||
**Inferring host compatibility from peer dependencies.** The market defines a new standard requiring an author-declared `engines.dsh`. Existing packages must add that declaration before listing; no fallback, workspace mapping, or range intersection is needed.
|
||||
|
||||
@@ -215,7 +217,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se
|
||||
|
||||
**Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader.
|
||||
|
||||
## Acceptance criteria
|
||||
## Consequences
|
||||
|
||||
- A validator accepts the example above and rejects one violation of each rule in Root fields, Plugin fields, Version fields, and the `source` forms, with a diagnostic naming the JSON path.
|
||||
- Install arguments built from every example version are accepted by DSH's install-spec parser and by pnpm.
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
# Agent Note: 插件市场索引格式
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-02-plugin-market-index-format.md) | 中文
|
||||
|
||||
@@ -12,9 +12,9 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
|
||||
因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 DSH 不需要理解这些值的含义。
|
||||
|
||||
## 提案
|
||||
## 决策
|
||||
|
||||
将**索引**与**索引生成器**分开。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。DSH 的读取器、Discover 页面和参考生成器是使用此格式的独立变更。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
|
||||
**索引**与**索引生成器**相互分离。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。[`dsh-plugin-market-index`](../../../../packages/util/plugin-market-index/README.zh.md) 是可执行的规则集;[发现 Agent Note](2026-10-02-plugin-market-discover.zh.md) 记录 DSH 如何读取和展示索引。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
|
||||
|
||||
市场复用 npm 的包名、版本、范围、发布标签和 person 声明。读取器与生成器使用维护中的 npm 解析器和校验器。新增字段或改变含义需要 npm 无法满足的插件市场需求。
|
||||
|
||||
@@ -45,6 +45,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
"icon": "icons/auto-review.svg",
|
||||
"author": "Example Labs",
|
||||
"homepage": "https://example.com/dsh-auto-review",
|
||||
"repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" },
|
||||
"metadata": {
|
||||
"category": "productivity",
|
||||
"labels": ["official"],
|
||||
@@ -114,9 +115,10 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
| `name` | 是 | npm 维护的包名校验器接受的新包名称;在索引内唯一。 |
|
||||
| `title` | 否 | `LocalizedText`;缺省时读取器显示 `name`。 |
|
||||
| `description` | 否 | `LocalizedText`。 |
|
||||
| `icon` | 否 | 以下两种形式之一:`data:<type>;base64,<payload>`,其中 `<type>` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`<payload>` 是按规范填充的 base64,解码后的载荷不超过 256 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
|
||||
| `icon` | 否 | 以下两种形式之一:`data:<type>;base64,<payload>`,其中 `<type>` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`<payload>` 是按规范填充的 base64,解码后的载荷不超过 32 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
|
||||
| `author` | 否 | 至少包含一个非空白字符的字符串。 |
|
||||
| `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 |
|
||||
| `repository` | 否 | `{ "url": string, "directory"?: string }`。`url` 是源代码仓库网页的绝对 `https:` 或 `http:` URL,不含凭据、查询或片段;`directory` 是包在仓库中的目录,规则与 Git 来源的 `path` 相同。该字段仅用于展示,从不决定安装来源。 |
|
||||
| `metadata` | 否 | 包含下列值的对象。 |
|
||||
| `metadata.category` | 否 | 一个已声明的分类 id。 |
|
||||
| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 |
|
||||
@@ -166,7 +168,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
- `engines.dsh` 原样复制 manifest 声明的范围。缺失、空白或无效范围的版本会被省略并给出诊断。没有合格版本的插件会被省略。
|
||||
- `dist-tags` 是 npm [发布标签](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/)的快照,只保留指向合格且已列出版本的目标。对于 Git 来源,发布者或市场提供同样的精确标签到版本映射。
|
||||
- 列出的每个版本都声明了 `dsh.bundle`。安装时也会独立拒绝没有组合包 patch 的包。
|
||||
- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。
|
||||
- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。`repository` 来自该版本 manifest 的 `repository`:简写或 Git URL 转换为仓库的网页 URL,`directory` 符合 `directory` 规则时原样复制,否则丢弃并保留 `url`;manifest 没有该字段,或其指向的仓库没有网页地址时省略。
|
||||
- `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。
|
||||
|
||||
### 读取器规则
|
||||
@@ -197,7 +199,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
|
||||
**每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。
|
||||
|
||||
**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。
|
||||
**只允许内联图标。** 不发额外请求的列表需要内联图标,但已安装插件的 256 KiB 图标上限编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。32 KiB 的内联上限编码为 base64 后约为 43 KiB,因此 1,000 个带内联图标的插件最多增加约 43 MB;更大的图标使用 URL 引用。
|
||||
|
||||
**从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。
|
||||
|
||||
@@ -215,7 +217,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
|
||||
**拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。
|
||||
|
||||
## 验收标准
|
||||
## 后果
|
||||
|
||||
- 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。
|
||||
- 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-built-in-plugin-feature-previews.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.md
|
||||
/agent-note-built-in-plugin-feature-previews:
|
||||
en: f6abb7d00b13d362
|
||||
zh: b89d5da18677f31a
|
||||
/agent-note-built-in-plugin-feature-previews/problem:
|
||||
en: e38fa1e5b3a62b5a
|
||||
zh: c612ea229705d695
|
||||
/agent-note-built-in-plugin-feature-previews/decision:
|
||||
en: aa12e3f72f7c2498
|
||||
zh: 7dda9f061009aaa0
|
||||
/agent-note-built-in-plugin-feature-previews/alternatives-considered:
|
||||
en: 4a4416610894d7c6
|
||||
zh: 4256e81e7ab70db5
|
||||
/agent-note-built-in-plugin-feature-previews/consequences:
|
||||
en: a3dbd70080416314
|
||||
zh: 4d802da45085dfb0
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Built-in plugin feature previews
|
||||
|
||||
Status: implemented
|
||||
|
||||
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,27 @@
|
||||
# Agent Note: 内置插件功能配图
|
||||
|
||||
Status: implemented
|
||||
|
||||
[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。该设计将定义自己的格式和信任规则,届时内置功能配图可能迁移过去。
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-host-release-change-refresh-prompt.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases:
|
||||
en: 559df9281e9994e0
|
||||
zh: c51f2b1f9973127f
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/problem:
|
||||
en: d98f5657dd30d9da
|
||||
zh: 19d1210e57eb663b
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision:
|
||||
en: d39be9788fe2a0c9
|
||||
zh: 1a68a3ae4d73930d
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/behavior:
|
||||
en: f85ebe23d677ae93
|
||||
zh: a829e0692e711660
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/release-identity:
|
||||
en: e7cae8876e688f92
|
||||
zh: 1d326330c34d128d
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/wire:
|
||||
en: f15c4106f228f5f6
|
||||
zh: ab80cf4ce62ae84c
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/page-side:
|
||||
en: c0610c8f5a0882f3
|
||||
zh: d309cbc53183ec6e
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/ui:
|
||||
en: 41b5a3346a7edffa
|
||||
zh: a05ed84017556760
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/alternatives-considered:
|
||||
en: abb9d7ab9ef1df15
|
||||
zh: 1704732daf5f2886
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/consequences:
|
||||
en: 815b41dc4f9ffcee
|
||||
zh: b57388264cc459c7
|
||||
@@ -0,0 +1,143 @@
|
||||
# Agent Note: Prompt to reload when the Host release changes instead of hot-replacing across releases
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-14-host-release-change-refresh-prompt.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After `dsh web` is upgraded, or after a developer restarts `pnpm run dev:web` at another commit, an open browser page reconnects to the new Host automatically while its static shell stays at the old release. When the protocol is no longer compatible, the page keeps failing to reconnect or shows errors, and the user is not told that a reload is required.
|
||||
|
||||
Technical causes:
|
||||
|
||||
- After a reconnect, [client-hmr](../../../../packages/client/hmr/src/client/index.ts) receives the new Host's complete graph, and [ClientEntries](../../../../packages/client/modules/src/client/entries.ts) hot-replaces plugins by each row's `rev` (the bundle's mtime, ctime, and size). The Vite shell, static libraries, and the bootstrap are never replaced during the page's lifetime.
|
||||
- The API connection validates its ready frame against an exact key set ([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)), so an old page cannot recover from a protocol change by itself.
|
||||
- The page has no way to learn that the Host now runs another release.
|
||||
- Desktop is unaffected: after its Host is replaced, the main process navigates the window again ([main.ts](../../../../apps/desktop/src/main.ts)).
|
||||
|
||||
The [client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.md) owns the loading chain and hot replacement. It states that static libraries and the shell require a reloaded page; this note owns how the page learns that the Host's release changed and what it does about it.
|
||||
|
||||
## Decision
|
||||
|
||||
A release is the Host's version identity: a version plus an optional source commit. The name deliberately differs from "build", which names the build process and its artifacts.
|
||||
|
||||
The change touches three owners: client-modules (producing, publishing, and comparing the release identity), the shared client build preset (fixing the commit into the Host artifact), and ui-settings-general (the confirmation dialog). web-app and client-hmr are unchanged.
|
||||
|
||||
### Behavior
|
||||
|
||||
A page applies Host graphs and rebuilt frames only while the connected Host serves the release the page booted from. Every module the page runs therefore belongs to its boot release: either the boot artifacts unchanged, or the boot artifacts plus hot replacements published by a Host of that same release.
|
||||
|
||||
| Situation | Behavior |
|
||||
|---|---|
|
||||
| The Host serves the boot release; its graph equals the applied graph | Nothing happens. |
|
||||
| The Host serves the boot release; some module revisions differ | Hot replacement of exactly the changed modules, as before; a shown dialog closes. |
|
||||
| The Host serves another release (version or commit differs) | Nothing from it is applied, rebuilt frames are ignored, and a dialog shows the Host's and the page's transition. |
|
||||
| The user chooses **Reload to load the new version** | `location.reload()` boots the page from the Host's release. |
|
||||
| The user chooses **Not now**, the close control, a mask click, or Escape | The dialog closes; the page stays at its boot release and applies nothing from that Host release; later graphs of that release no longer ask. |
|
||||
| The Host returns to the boot release | The dialog closes and the usual revision comparison replaces exactly the changed modules; unchanged modules stay. |
|
||||
| The Host moves to a release other than the dismissed one | The dialog appears again. |
|
||||
|
||||
The page remembers only the last dismissed Host release, in page memory; nothing is written to localStorage or IndexedDB. After a reload, the new boot release is the comparison basis.
|
||||
|
||||
### Release identity
|
||||
|
||||
- Fields: `{ version, commit? }`; two releases are the same only when both fields are equal. Every complete build, released builds included, records the repository commit, so pages compare both fields; a release changes both together.
|
||||
- `version` is client-modules' own package.json version. All packages release under one version, so it equals the CLI version.
|
||||
- `commit` is fixed at build time. The shared client build preset [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) injects the same public `process.env.DSH_CLIENT_*` values into the Node half of every client package that it injects into the browser half ([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) replaces the named variables and leaves `process.env` itself in place). The client-modules Host code reads `process.env.DSH_CLIENT_COMMIT_HASH`, which is a constant in the built artifact.
|
||||
- A Host started from TypeScript source has no build step: `pnpm run dev:web` places the build values in the Host process environment, so the Host reads the same commit; a source launch without dev-web publishes the version alone.
|
||||
- Field values are wire data that the dialog renders verbatim, without localization.
|
||||
|
||||
### Wire
|
||||
|
||||
```
|
||||
WebBootRelease { version: string; commit?: string }
|
||||
WebBootGraph {
|
||||
rev; entries; batches // unchanged
|
||||
/** Host release identity; absent from a Host that publishes none. */
|
||||
release?: WebBootRelease
|
||||
}
|
||||
BootManifest { ...; release?: WebBootRelease }
|
||||
```
|
||||
|
||||
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) computes the release once at construction and `compose()` writes it into every graph; `release` stays outside `graph.rev`. There is no new config field and the composition is unchanged.
|
||||
- `__DSH_BOOT__` and every SSE `graph` frame carry the field, so one graph frame carries both the Host release and its complete module graph; the client-hmr Host half is unchanged. A rebuilt frame comes from the same Host process as the graph frame before it on that SSE connection, so it inherits that frame's release.
|
||||
- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. `readRelease` reads only the `release` member of a received graph, so a page compares releases before it validates anything else; a release it cannot read counts as absent. Older pages that do not know the field ignore it.
|
||||
|
||||
### Page side
|
||||
|
||||
The release comparison lives in [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts); `ClientEntries` consults it from `sync` and `reload`.
|
||||
|
||||
```
|
||||
interface ClientReleaseChange {
|
||||
/** Release this page booted from; every module the page runs belongs to it. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release the Host served before its latest release change. */
|
||||
readonly previous: WebBootRelease | undefined
|
||||
/** Release of the connected Host. */
|
||||
readonly host: WebBootRelease | undefined
|
||||
}
|
||||
|
||||
class ReleaseChangeGate {
|
||||
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */
|
||||
get matches(): boolean
|
||||
/** Record a received graph's release and update the announcement; true when the graph may be applied. */
|
||||
observe(release: WebBootRelease | undefined): boolean
|
||||
/** Stop announcing the connected Host's release. */
|
||||
dismiss(): void
|
||||
}
|
||||
|
||||
class ClientEntries {
|
||||
/** The gate's change. */
|
||||
readonly releaseChange: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
|
||||
dismissRelease(): void
|
||||
}
|
||||
```
|
||||
|
||||
Order of operations:
|
||||
|
||||
1. `ClientEntries` creates the gate from the boot manifest's `release`.
|
||||
2. `sync(graph)` reads the graph's release with `readRelease` and passes it to `observe` before parsing the rest, so a graph of another release is announced even when this page cannot read its format. A graph of the boot release is parsed and reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities through `console.warn` and publishes the change unless that release was the last one dismissed.
|
||||
3. `reload(id, rev)` (a rebuilt frame) returns at once while the Host serves another release.
|
||||
4. `retry()` keeps reconciling the last applied graph, which always belongs to the boot release.
|
||||
5. The UI's inject face reloads the page through `location.reload()`; `ClientEntries` does not navigate.
|
||||
|
||||
### UI
|
||||
|
||||
- Owner: [ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts), which already owns the connection pill (`ConnectionIndicator`) and the Desktop update prompt; its `inject` adds `modules`.
|
||||
- It registers the `shell.overlay` entry `host-release-change` through `ctx.slots.inject('shell.overlay', ...)` and renders it with `Modal`. The inject face provides `hooks: { releaseChange: ctx.modules.entries.releaseChange }`, `dismissRelease`, and `reloadPage`.
|
||||
- Copy lives in the zh/en locale dictionaries: a title, one sentence stating that live updates are paused until a reload, and two gray-labelled lines:
|
||||
- **Service**: `previous` (gray) → `host` (solid), joined by a 1px solid green arrow because the Host already switched.
|
||||
- **Page**: `page` (solid) → `host` (gray), joined by a 1px dashed gray arrow because only a reload completes the switch. Both connectors are a fixed 56px and do not stretch across the dialog.
|
||||
- Each line shows versions when its two ends differ in version and commits otherwise; a release or field the page cannot read, including one the Host did not publish, shows **Unknown**. Screen readers read the two lines as text; the arrows are decorative.
|
||||
- The buttons are **Not now**, which carries `data-modal-autofocus`, and **Reload to load the new version** (primary). The dialog opens without a user gesture, so initial focus keeps the page and an Enter typed into the composer cannot reload it and discard a draft. The close control, a mask click, and Escape act as **Not now** through the shared `Modal` close behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Call `location.reload()` automatically**: it can discard unsaved input, and the user should see and confirm the reload.
|
||||
- **Carry the identity in the API ready frame (`ConnectionHostInfo`)**: when the protocol is incompatible, the ready frame itself may fail to parse, so detection would never run; pausing hot replacement would still belong to `ClientEntries`.
|
||||
- **Let web-app compose the identity (CLI version + web-app version + commit) and pass it to modules through the `webApp` service and the composition**: it changes web-app's service interface, tests, docs, and the generated API catalog, and makes modules wait for web-runtime activation; the two versions release together, so reading them separately adds nothing.
|
||||
- **Add a client-modules `commit` config field set from `!!js process.env.DSH_CLIENT_COMMIT_HASH` in the composition**: it reads the environment at run time, and released `dsh web` launches do not set the variable; the commit must be fixed at build time.
|
||||
- **Read the CLI version by depending on app-boot from client-modules**: it adds a dependency edge, a dependency-policy classification, a tsconfig reference, and a lockfile change, while the package's own version is the same number.
|
||||
- **Keep the decision logic inside `ClientEntries`**: reconciliation and release comparison are independent concerns; the separate gate keeps `ClientEntries` to a few added lines.
|
||||
- **Let Not now apply the other release's plugin updates**: the page would run plugins of one release on the shell and static libraries of another, its state could no longer be named by one release, and returning to the boot release would need a second prompt whose two choices end in the same state.
|
||||
- **Name the field `build`**: it is easily confused with the build process and build artifacts.
|
||||
- **Reopen a permanently closed SSE stream in client-hmr**: in end-to-end restarts the 404 window never appeared, because the browser's own retry covered connection refusals; the gap is a documented limitation.
|
||||
- **Store the decision in localStorage**: the decision concerns one page's code, and a reload replaces that code, so it should not outlive the page.
|
||||
- **Remember every dismissed release**: a page that moves between several Host releases is rare, and asking again after another release appeared costs one click.
|
||||
- **Keep the identity an opaque string and let the page split version and commit**: the display rule needs the two parts separately, and parsing a concatenated string breaks whenever its format changes.
|
||||
- **Create a separate UI package**: it needs three more registrations, while ui-settings-general already owns connection state and update prompts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An upgraded Host or a dev Host at another commit never hot-replaces plugin code into a page of another release; the page keeps working at its own release until the user reloads.
|
||||
- While the Host serves another release, plugin updates, including enabling or disabling a plugin on the Host, reach the page only after a reload.
|
||||
- When a Host returns to the page's release, only modules whose revisions differ are replaced. Revisions come from artifact metadata, so a rebuild of identical code still replaces the rebuilt modules once, without a prompt.
|
||||
- A composition without the dialog registered still pauses updates from another release and logs the warning; the user sees no prompt.
|
||||
- The API connection and SSE are independent channels, so an old page can show a protocol error before the dialog appears.
|
||||
- If a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that SSE stream for good, and the page cannot detect a release change until it reloads.
|
||||
- A source launch without dev-web has no commit, so development detection falls back to version changes.
|
||||
- Each tab prompts and remembers independently.
|
||||
- Unit tests pin the gate through `ClientEntries` (same-release replacement, another release applying nothing, the return to the boot release replacing only changed modules, dismissal, absent releases), the manifest parser, the Host-published release, the shared preset's Node-half defines, and the dialog's rendering and callbacks.
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Agent Note: Host release 变化时提示刷新页面,而不是跨 release 热替换
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-14-host-release-change-refresh-prompt.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh web` 升级后,或开发者在另一个 commit 上重启 `pnpm run dev:web` 后,已打开的浏览器页面会自动重连到新 Host,但页面的静态 shell 仍停在旧 release。协议一旦不兼容,页面会一直重连失败或报错,用户也不知道需要刷新。
|
||||
|
||||
技术原因:
|
||||
|
||||
- 重连后,[client-hmr](../../../../packages/client/hmr/src/client/index.ts) 会收到新 Host 的完整 graph,[ClientEntries](../../../../packages/client/modules/src/client/entries.ts) 按每行的 `rev`(bundle 的 mtime、ctime 和大小)热替换插件。Vite shell、静态库和 bootstrap 在页面生命周期内从不替换。
|
||||
- API 连接按精确键集合校验 ready 帧([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)),协议一变,旧页面无法自行恢复。
|
||||
- 页面没有任何途径得知 Host 已换成另一个 release。
|
||||
- Desktop 不受影响:Host 被替换后,main 进程会重新导航窗口([main.ts](../../../../apps/desktop/src/main.ts))。
|
||||
|
||||
[client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.zh.md) 负责加载链与热替换,它写明静态库和 shell 需要刷新页面;本 Note 负责页面如何得知 Host 的 release 变了,以及之后怎么处理。
|
||||
|
||||
## Decision
|
||||
|
||||
release 指 Host 的版本身份:版本号加可选的源码 commit。名字刻意区别于 build,后者指构建过程和构建产物。
|
||||
|
||||
改动落在三处:client-modules(产生、下发和比较 release 身份)、共享 client 构建预设(把 commit 固化进 Host 产物)、ui-settings-general(确认框)。web-app 和 client-hmr 不改。
|
||||
|
||||
### 行为
|
||||
|
||||
只有当前连接的 Host 提供的是页面启动时的 release,页面才应用 Host 的 graph 和 rebuilt 帧。所以页面运行的每个模块都属于启动 release:要么是原封不动的启动产物,要么是启动产物加上同一 release 的 Host 发布的热替换。
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| Host 提供启动 release,graph 与已应用的相同 | 什么都不做。 |
|
||||
| Host 提供启动 release,部分模块 revision 不同 | 照常只热替换变化了的模块;已弹出的对话框关闭。 |
|
||||
| Host 提供另一个 release(版本号或 commit 不同) | 不应用它的任何内容,忽略 rebuilt 帧,对话框显示 Host 和页面的版本变化。 |
|
||||
| 用户选择**刷新以加载新版本** | `location.reload()` 以 Host 的 release 启动页面。 |
|
||||
| 用户选择**暂不刷新**、关闭按钮、点击遮罩或 Escape | 对话框关闭;页面保持启动 release,不应用该 Host release 的任何内容;该 release 的后续 graph 不再提示。 |
|
||||
| Host 回到启动 release | 对话框关闭,照常按 revision 对比,只替换变化了的模块,没变的保持不动。 |
|
||||
| Host 换到被撤回的那个以外的 release | 再次弹出对话框。 |
|
||||
|
||||
页面只在内存里记住最近一次撤回的 Host release,不写 localStorage 或 IndexedDB。刷新后以新的启动 release 为比较基准。
|
||||
|
||||
### Release 身份
|
||||
|
||||
- 字段:`{ version, commit? }`,两个字段都相等才算同一 release。每次完整构建(包括正式发布)都会记录仓库 commit,所以页面比较的是两个字段;发布新版本时两者一起变。
|
||||
- `version` 取 client-modules 自己 package.json 的版本。所有包同一版本号发布,所以它等于 CLI 版本。
|
||||
- `commit` 在构建时固化。共享 client 构建预设 [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) 给每个 client 包的 Node 半侧注入与浏览器半侧相同的公开 `process.env.DSH_CLIENT_*` 取值([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) 只替换具体变量,保留 `process.env` 本身)。client-modules 的 Host 代码读 `process.env.DSH_CLIENT_COMMIT_HASH`,在构建产物里是常量。
|
||||
- 从 TypeScript 源码启动的 Host 没有构建这一步:`pnpm run dev:web` 把构建取值放进 Host 进程环境,因此读到的是同一个 commit;不经 dev-web 的源码启动只发布版本号。
|
||||
- 字段值属于 wire 数据,对话框原样显示,不做本地化。
|
||||
|
||||
### Wire
|
||||
|
||||
```
|
||||
WebBootRelease { version: string; commit?: string }
|
||||
WebBootGraph {
|
||||
rev; entries; batches // unchanged
|
||||
/** Host release identity; absent from a Host that publishes none. */
|
||||
release?: WebBootRelease
|
||||
}
|
||||
BootManifest { ...; release?: WebBootRelease }
|
||||
```
|
||||
|
||||
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) 在构造时计算一次 release,由 `compose()` 写入每个 graph;`release` 不计入 `graph.rev`。不新增配置项,组合配置不变。
|
||||
- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。
|
||||
- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。`readRelease` 只读取收到的 graph 的 `release` 成员,所以页面先比较 release,再校验其余内容;读不出的 release 视为缺省。不认识该字段的旧页面会忽略它。
|
||||
|
||||
### 页面侧
|
||||
|
||||
release 比较在 [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts);`ClientEntries` 在 `sync` 和 `reload` 中调用它。
|
||||
|
||||
```
|
||||
interface ClientReleaseChange {
|
||||
/** Release this page booted from; every module the page runs belongs to it. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release the Host served before its latest release change. */
|
||||
readonly previous: WebBootRelease | undefined
|
||||
/** Release of the connected Host. */
|
||||
readonly host: WebBootRelease | undefined
|
||||
}
|
||||
|
||||
class ReleaseChangeGate {
|
||||
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */
|
||||
get matches(): boolean
|
||||
/** Record a received graph's release and update the announcement; true when the graph may be applied. */
|
||||
observe(release: WebBootRelease | undefined): boolean
|
||||
/** Stop announcing the connected Host's release. */
|
||||
dismiss(): void
|
||||
}
|
||||
|
||||
class ClientEntries {
|
||||
/** The gate's change. */
|
||||
readonly releaseChange: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
|
||||
dismissRelease(): void
|
||||
}
|
||||
```
|
||||
|
||||
调用顺序:
|
||||
|
||||
1. `ClientEntries` 以启动 manifest 的 `release` 创建 gate。
|
||||
2. `sync(graph)` 先用 `readRelease` 读出 graph 的 release 交给 `observe`,再解析其余部分,所以即使页面读不懂另一个 release 的格式也会弹框。启动 release 的 graph 经过解析后照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。
|
||||
3. Host 提供其他 release 期间,`reload(id, rev)`(rebuilt 帧)立即返回。
|
||||
4. `retry()` 继续调和上次应用的 graph,它始终属于启动 release。
|
||||
5. UI 的 inject 面通过 `location.reload()` 刷新页面;`ClientEntries` 不负责导航。
|
||||
|
||||
### UI
|
||||
|
||||
- 归属:[ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts),它已经拥有连接状态药丸(`ConnectionIndicator`)和 Desktop 更新提示;其 `inject` 增加 `modules`。
|
||||
- 它通过 `ctx.slots.inject('shell.overlay', ...)` 注册 `shell.overlay` 条目 `host-release-change`,用 `Modal` 渲染。inject 面提供 `hooks: { releaseChange: ctx.modules.entries.releaseChange }`、`dismissRelease` 和 `reloadPage`。
|
||||
- 文案在中英 locale 字典里:一个标题、一句说明热更新已暂停直到刷新,以及两行灰色标签的版本:
|
||||
- **服务**:`previous`(灰)→ `host`(实色),1px 绿色实线箭头,表示 Host 已经切换。
|
||||
- **页面**:`page`(实色)→ `host`(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。
|
||||
- 每行两端版本号不同时显示版本号,否则显示 commit;页面读不出的 release 或字段(包括 Host 没发布的)显示**未解析**。读屏把两行当文字读,箭头只是装饰。
|
||||
- 按钮为**暂不刷新**(带 `data-modal-autofocus`)和**刷新以加载新版本**(主按钮)。对话框不是用户操作打开的,初始焦点落在保留页面一侧,用户在输入框里按下的回车不会刷新页面、丢掉草稿。关闭按钮、点击遮罩和 Escape 通过共用的 `Modal` 关闭行为等同**暂不刷新**。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **自动调用 `location.reload()`**:可能丢失尚未保存的输入,而且应当让用户看到并确认刷新。
|
||||
- **把身份放进 API ready 帧(`ConnectionHostInfo`)**:协议不兼容时 ready 帧本身就可能解析失败,检测根本不会发生;暂停热替换仍然属于 `ClientEntries`。
|
||||
- **由 web-app 拼身份(CLI 版本 + web-app 版本 + commit),通过 `webApp` 服务和组合配置交给 modules**:要改 web-app 的服务接口、测试、文档和生成的 API 清单,还让 modules 等待 web-runtime 激活;两个版本号一起发布,分开读取没有区别。
|
||||
- **给 client-modules 加 `commit` 配置项,在组合配置里写 `!!js process.env.DSH_CLIENT_COMMIT_HASH`**:运行时读环境变量,而正式发布的 `dsh web` 不设置这个变量;commit 必须在构建时固化。
|
||||
- **client-modules 依赖 app-boot 读取 CLI 版本**:要新增依赖边、依赖策略分类、tsconfig 引用和 lockfile 改动,而本包自己的版本就是同一个数。
|
||||
- **把判断逻辑留在 `ClientEntries` 里**:调和与 release 比较是两件独立的事;拆出 gate 后 `ClientEntries` 只多几行。
|
||||
- **选择暂不刷新后应用另一个 release 的插件更新**:页面会在一个 release 的 shell 和静态库上运行另一个 release 的插件,其状态无法再用一个 release 描述;回到启动 release 时还得再问一次,而两个选择的结果完全相同。
|
||||
- **字段命名为 `build`**:容易和构建过程、构建产物混淆。
|
||||
- **在 client-hmr 里重开被永久关闭的 SSE**:端到端重启测试中从未出现 404 窗口,浏览器自带的重试已覆盖连接被拒;这个缺口记为已知限制。
|
||||
- **把选择写进 localStorage**:这个选择只关乎一个页面的代码,而刷新会替换这些代码,所以不应比页面活得更久。
|
||||
- **记住每一个撤回过的 release**:页面在多个 Host release 之间来回切换的情况很少,出现其他 release 后再问一次只多一次点击。
|
||||
- **身份保持一个不透明字符串,由页面拆出版本号和 commit**:显示规则需要分开这两部分,从拼接字符串里拆解,格式一变就会出错。
|
||||
- **新建一个独立 UI 包**:要多做三处注册,而 ui-settings-general 已经拥有连接状态和更新提示。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 升级后的 Host 或换了 commit 的开发 Host,永远不会把插件代码热替换进另一个 release 的页面;页面按自己的 release 继续工作,直到用户刷新。
|
||||
- Host 提供其他 release 期间,插件更新(包括在 Host 上启用或停用插件)要等刷新后才会到达页面。
|
||||
- Host 回到页面的 release 时,只替换 revision 不同的模块。revision 取自产物元数据,所以即使重新构建出的代码完全相同,也会把重建过的模块替换一次,不弹框。
|
||||
- 组合里没有注册确认框时,仍会暂停来自其他 release 的更新并记录警告,用户看不到提示。
|
||||
- API 连接和 SSE 是两条独立通道,旧页面可能在确认框出现之前就已显示协议错误。
|
||||
- 重启中的 Host 若在注册路由前对 `/plugins/events` 返回非 200 状态,浏览器会永久关闭这条 SSE,页面在刷新前检测不到 release 变化。
|
||||
- 不经 dev-web 的源码启动没有 commit,开发期只能检测到版本号变化。
|
||||
- 每个标签页各自提示、各自记录。
|
||||
- 单元测试通过 `ClientEntries` 固定 gate 的行为(同一 release 的替换、其他 release 不应用任何内容、回到启动 release 只替换变化了的模块、撤回、缺失 release),并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-published-dependency-faces-and-bounded-peer-relays/decision/package-selection:
|
||||
en: 5ea13b09ae774437
|
||||
zh: ef9dbccada39107a
|
||||
en: 931b678bcdb42dbe
|
||||
zh: 95dbebdb03c50d1f
|
||||
/agent-note-published-dependency-faces-and-bounded-peer-relays/decision/dependency-sections:
|
||||
en: d34ebac6c1c7642b
|
||||
zh: 2cf3a60df14b072a
|
||||
|
||||
@@ -14,7 +14,7 @@ The package that chooses a Client build input is the shipped profile, while a Ho
|
||||
|
||||
### Package selection
|
||||
|
||||
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy.
|
||||
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every package that declares `dsh.client`, experimental packages included. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy. [Experimental packages follow the same dependency declaration rules](2026-10-14-experimental-dependency-policy.md) records why the selection includes experimental packages.
|
||||
|
||||
[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller` and `@deepseek-ai/dsh-api-workspace-controller`. Adding Session Controller back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Client 构建输入由发布 profile 选择,而 Host value import 由导入它
|
||||
|
||||
### 包选择
|
||||
|
||||
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。
|
||||
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个包,实验包也包括在内。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。[实验包遵守同一套依赖声明规则](2026-10-14-experimental-dependency-policy.zh.md)记录了选包为何包括实验包。
|
||||
|
||||
[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller` 和 `@deepseek-ai/dsh-api-workspace-controller`。把 Session Controller 加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-experimental-dependency-policy.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/process/2026-10-14-experimental-dependency-policy.md
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules:
|
||||
en: ec44b9c40ce4bb25
|
||||
zh: 2e5731c96fa82ba8
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/problem:
|
||||
en: ee78aec260872b62
|
||||
zh: 005e4c3c2b54a9ef
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/decision:
|
||||
en: 081508647fade940
|
||||
zh: 496cffe0fba8544f
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/alternatives-considered:
|
||||
en: 28c320fb3547502d
|
||||
zh: 9563fd1709ed8b54
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/consequences:
|
||||
en: cc50e617e4893a30
|
||||
zh: c2d2b9efd2955127
|
||||
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/testing:
|
||||
en: 9542331ca97ac00a
|
||||
zh: 34857e7b85128c7b
|
||||
@@ -0,0 +1,40 @@
|
||||
# Agent Note: Experimental packages follow the same dependency declaration rules
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-14-experimental-dependency-policy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) scope of `packages/!(experimental)/*/package.json` exempts the whole experimental group from the rule that browser and type relationships belong only in devDependencies ([client dependency rules](../../../../packages/client/AGENTS.md#dependency-declaration)), and [published dependency faces](2026-08-26-published-dependency-faces.md) stated its coverage as "non-experimental packages". Under that scope, 4 experimental client packages declared client, api, session, and similar packages as peerDependencies. The webworker packer follows workspace peers into the image, so browser static libraries became sweep roots, and packing agent-team-profile produced 82 unresolvable requests. The [experimental package rules](../../../../packages/experimental/AGENTS.md) state that experimental status does not relax engineering requirements, which that exemption contradicts.
|
||||
|
||||
Checking the 6 experimental packages that declare `dsh.client` under the same rules found 46 violations across the full run, all in experimental packages. In addition, a verifier that traces runtime imports only from the Host entry misclassifies inspector's `ws`, which is used only in its worker_threads entry, as devDependencies-only.
|
||||
|
||||
## Decision
|
||||
|
||||
- The check reads `packages/*/*/package.json`, so experimental packages follow exactly the release rules with no group-level exemption, and the dependency-section check carries no other experimental exception.
|
||||
- The verifier also scans each package's own Host-side worker and child-process entries, identified from the build entries the package's `tsdown.config.ts` declares. `readHostBuildEntries` imports that config, evaluates its default export once under the `host` build face and once under the `client` build face (`DSH_BUILD_FACE`), combines the results, and keeps entries whose platform is not browser and whose source is outside `src/client/`. The verifier does not guess from source patterns. The configurations can only be imported asynchronously, so the verifier's state reading is async.
|
||||
- The affected experimental manifests and the lockfile follow the policy; every entry moved out of `peerDependencies` or `dependencies` was confirmed not to be a runtime requirement. cot-translation applies `instanceof` to translator's `TranslationError`, which requires the same class instance, so `peerRequiredHostExports` classifies that export.
|
||||
- Host-only experimental packages, like release packages, are checked only when listed in `hostPackages`. Whether to extend the check to every Host-only package is a separate decision.
|
||||
- This decision partially supersedes the package-selection coverage of [published dependency faces](2026-08-26-published-dependency-faces.md), whose package-selection paragraph links back here; that note's other decisions remain in force.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fix only these manifests without changing the check scope.** The same declarations would soon return; a rule without an enforcement point is not a rule.
|
||||
|
||||
**Skip browser static libraries as sweep roots in the packer.** This only works around the symptom; the incorrect manifests still affect npm installation and other consumers.
|
||||
|
||||
## Consequences
|
||||
|
||||
`pnpm run verify-package-dependencies` covers experimental packages that declare `dsh.client`, so the manifests it accepts let the webworker packer pack agent-team-profile and voice-input-bundle, and inspector's published manifest keeps its worker runtime dependencies.
|
||||
|
||||
The trade-off costs:
|
||||
|
||||
- Published experimental manifests list browser and type relationships only as dev dependencies, so users who install these packages individually from npm receive fewer peer hints.
|
||||
- The verifier cannot see imports injected by a tsdown `banner`, such as the inspector worker's `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`. Such a dependency stays only because it is declared; if it is removed by mistake, the verifier does not detect it.
|
||||
- `readHostBuildEntries` imports the `tsdown.config.ts` of every covered package with a Host face and evaluates its default export under both build faces, so the gate depends on every such config module importing cleanly, and the modules' import side effects run in the gate process; for example, the inspector's config imports its package-local [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts).
|
||||
- Measured: loading the 127 additional tsdown configurations takes about 2 s, and the full-run duration stays essentially the same (4:27 without the scan, 4:21 with it).
|
||||
|
||||
## Testing
|
||||
|
||||
[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) pins three cases: an experimental client package that declares a browser package as a peer is rejected, an experimental package listed in `hostPackages` is checked as a configured Host package, and a runtime dependency used only in a worker entry named by `tsdown.config.ts` stays in dependencies. The same spec pins the peer-required classification of translator's `TranslationError`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Agent Note: 实验包遵守同一套依赖声明规则
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-14-experimental-dependency-policy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
如果 [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) 的检查范围是 `packages/!(experimental)/*/package.json`,整个 experimental 组就不受“浏览器和类型关系只放 devDependencies”([client 依赖规则](../../../../packages/client/AGENTS.md#dependency-declaration))的约束,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)也曾把覆盖范围写成“非实验包”。在这一范围下,有 4 个实验 client 包把 client、api、session 等包写成 peerDependencies。webworker packer 会沿 workspace peer 把这些包收进镜像,浏览器静态库因此成了扫描起点,agent-team-profile 打包时出现 82 条无法解析的请求。[实验包规则](../../../../packages/experimental/AGENTS.md)写明实验身份不放宽工程要求,这一豁免与之矛盾。
|
||||
|
||||
按同一套规则检查 6 个带 `dsh.client` 的实验包,全量共 46 条违规,全部出在实验包。另外,只从 Host 入口追踪运行时导入的 verifier 会把 inspector 的 `ws` 误判为只该放 devDependencies,因为它只在 worker_threads 入口里使用。
|
||||
|
||||
## 决策
|
||||
|
||||
- 检查读取 `packages/*/*/package.json`,experimental 包与正式包遵守完全相同的规则,不设组级豁免,依赖区段检查也没有其他针对实验包的例外。
|
||||
- verifier 同时扫描包自己的 Host 侧 worker 和子进程入口,识别依据是包 `tsdown.config.ts` 声明的构建入口。`readHostBuildEntries` import 这份配置,在 `host` 和 `client` 两个构建 face(`DSH_BUILD_FACE`)下各求值一次默认导出并取并集,保留 platform 不是 browser、源码不在 `src/client/` 下的 entry。不靠猜源码写法。配置只能异步 import,所以 verifier 的状态读取是 async。
|
||||
- 受影响的实验包清单和 lockfile 符合策略;从 `peerDependencies` 或 `dependencies` 移走的每一项都确认过不是运行时必需。cot-translation 对 translator 的 `TranslationError` 做 `instanceof` 判断,需要同一个类实例,因此 `peerRequiredHostExports` 收录了这个导出。
|
||||
- 纯 Host 的实验包与正式包一样,只有列入 `hostPackages` 才受检查。是否扩大到全部纯 Host 包,另行决定。
|
||||
- 本决定部分取代[发布依赖面](2026-08-26-published-dependency-faces.zh.md)的选包覆盖范围,该 Note 的选包段落链接回本 Note;它的其余决定保持有效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只修这几个清单,不改检查范围。** 很快会再出现;规则没有执行点等于没有规则。
|
||||
|
||||
**在 packer 里跳过浏览器静态库这类扫描起点。** 只是绕开症状,错误的清单还会影响 npm 安装和其他消费方。
|
||||
|
||||
## 后果
|
||||
|
||||
`pnpm run verify-package-dependencies` 覆盖带 `dsh.client` 的实验包,它接受的清单让 webworker packer 能打包 agent-team-profile 和 voice-input-bundle,inspector 的发布清单也保留它的 worker 运行时依赖。
|
||||
|
||||
这一取舍的代价:
|
||||
|
||||
- 发布的实验包清单只把浏览器和类型关系列为 dev 依赖,通过 npm 单独安装这些包的用户得到的 peer 提示更少。
|
||||
- verifier 看不到 tsdown `banner` 注入的导入,例如 inspector worker 的 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。这类依赖只因为已经声明而被保留;如果被误删,verifier 发现不了。
|
||||
- `readHostBuildEntries` 会 import 每个受覆盖、带 Host face 的包的 `tsdown.config.ts`,并在两个构建 face 下求值其默认导出,所以门禁依赖每个这样的配置模块都能干净地 import,这些模块的 import 副作用也会在门禁进程里执行;例如 inspector 的配置会 import 包内的 [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts)。
|
||||
- 实测:新增的 127 个 tsdown 配置加载约 2 s,全量耗时基本不变(不扫描时 4:27,扫描时 4:21)。
|
||||
|
||||
## 测试
|
||||
|
||||
[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) 锁定三种情形:实验 client 包把浏览器包写成 peer 时会被拒绝;列入 `hostPackages` 的实验包按 configured Host 包检查;只在 `tsdown.config.ts` 指定的 worker 入口里使用的运行时依赖保留在 dependencies。同一份 spec 还锁定 translator 的 `TranslationError` 属于 peer-required 分类。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-14-no-top-level-await-in-packages.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/process/2026-10-14-no-top-level-await-in-packages.md
|
||||
/agent-note-package-sources-must-not-use-top-level-await:
|
||||
en: d7409565b91b608c
|
||||
zh: 855807c10ac7e44a
|
||||
/agent-note-package-sources-must-not-use-top-level-await/problem:
|
||||
en: 1735c60cec478b06
|
||||
zh: 85ed83b2b804b39e
|
||||
/agent-note-package-sources-must-not-use-top-level-await/decision:
|
||||
en: 7459c979b58be927
|
||||
zh: 569c68e1476f0129
|
||||
/agent-note-package-sources-must-not-use-top-level-await/alternatives-considered:
|
||||
en: 5f19be0393f39abc
|
||||
zh: ed0414b54eb17678
|
||||
/agent-note-package-sources-must-not-use-top-level-await/consequences:
|
||||
en: d609ff0b1ce0c99a
|
||||
zh: d60aa801ea72c2dd
|
||||
/agent-note-package-sources-must-not-use-top-level-await/testing:
|
||||
en: 55adbea10005be4b
|
||||
zh: 8c95636d2a0fdad4
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Package sources must not use top-level await
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-14-no-top-level-await-in-packages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The child-process entry `src/worker.ts` of [speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.md) ran `await startRecognitionServer(...)` at module scope. Top-level await makes module evaluation asynchronous: an importer's evaluation waits on it, and an entry failure becomes a module-loading failure instead of an error the entry handles explicitly. The same file is also the package's `./worker` export, and the browser Preview's module loader wraps module bodies as CommonJS, so it refuses to pack top-level await, which kept voice-input-bundle out of the image. No repository gate prevented this pattern; a repository-wide scan found two more cases outside bins: ssh's `src/helper-entry.ts` (the target of the `./helper` export) and inspector's `src/worker/entry.ts` (a worker_threads entry).
|
||||
|
||||
## Decision
|
||||
|
||||
- Entry modules use an explicit `async function main()`:
|
||||
- sensevoice: a failure before readiness writes only the error message to stderr and exits with code 1 once the message is flushed (`process.exit(1)` in the write callback, because a loaded native recognizer would otherwise keep the process alive); stdout carries only one `{"port":N}` line, which the Host parses as readiness.
|
||||
- ssh helper: `main()` holds the helper's try/catch/finally and signal handling; every failure is reported on stderr with the `dsh-ssh-sandbox:` prefix and exit code 127.
|
||||
- inspector worker: the synchronous startup stays at module scope, the asynchronous part runs in `main()`, and a failure sends the failure frame before stopping.
|
||||
- Gate [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts):
|
||||
- **Scope**: `.ts` and `.tsx` files under `packages/*/*/src/**`.
|
||||
- **Rule**: based on the TypeScript AST. An `await` expression, `for await`, or `await using` whose nearest function body is the module is a violation, including occurrences in top-level if statements, blocks, try statements, loops, switch statements, conditional expressions, export initializers, and computed member names. Occurrences in functions, methods, constructors, accessors, parameter defaults, and class static blocks are not violations.
|
||||
- **Exclusions**: only bins are excluded, judged from declarations and syntax alone: the source file starts with `#!`, or it is the `src/<path>.ts|.tsx` that corresponds to a `lib/<path>.js` named by package.json `bin`. A process executes a bin directly, and no other module imports it. The current exclusions are the `src/bin.ts` files of webworker-packer and llm-mock-server, and a spec pins the list.
|
||||
- **Corpus guard**: the gate fails when the corpus is empty, contains no `.tsx`, or completely omits a `src/` directory that contains TS.
|
||||
- **Registration**: a shared hygiene gate in `run-gates`, running in the static lane and the hygiene aggregate. The rule text lives in the [package-surface defensive patterns](../../../../docs/defensive-patterns/package-surface.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Rely only on the webworker packer's refusal.** The packer checks only modules that enter the image, so packages outside the image could still introduce top-level await.
|
||||
|
||||
**Implement it as a lint rule.** oxlint has no existing rule that expresses "module scope" precisely. A syntax-level script gate is more controllable, and tests can pin every included and excluded form.
|
||||
|
||||
**Forbid it in bins too.** A process executes a bin directly and nothing imports it, so its top-level await does not affect loading any module.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every importable package source evaluates synchronously, including packages that never enter the Preview image, and each entry handles its own startup failure; voice-input-bundle enters the Preview image.
|
||||
|
||||
The trade-off costs:
|
||||
|
||||
- Third-party packages and `apps/` are outside the scope; the page bootstrap script `apps/web/src/preview.ts` uses top-level await.
|
||||
- sensevoice startup failure diagnostics carry only the error message, without a stack trace.
|
||||
|
||||
## Testing
|
||||
|
||||
- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) pins every included and excluded form, the corpus guards, and the current exclusion list; [run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) pins the gate's registration. With the asynchronous startup of the three entries above at module scope, the gate reports 4 cases (1 in sensevoice, 1 in ssh, 2 in inspector) and fails.
|
||||
- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) launches the sensevoice entry and pins that a failure before readiness exits with code 1, leaves stdout empty, and reports the reason on stderr without a stack trace.
|
||||
- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) launches the ssh helper entry, and [integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) starts the inspector Worker through `startInspector`; no spec drives the inspector worker entry's startup-failure frame.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 包源码禁止顶层 await
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-14-no-top-level-await-in-packages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.zh.md) 的子进程入口 `src/worker.ts` 曾在模块顶层 `await startRecognitionServer(...)`。顶层 await 让模块求值变成异步:导入方的求值被它挂住,入口的失败也变成模块加载失败,而不是入口自己显式处理的错误。这个文件同时是包的 `./worker` 导出,浏览器 Preview 的模块加载器按 CommonJS 包装模块体,遇到顶层 await 会拒绝打包,voice-input-bundle 因此无法进入镜像。仓库里当时没有任何门禁阻止这种写法;全仓扫描时,除 bin 外还有另外两处:ssh 的 `src/helper-entry.ts`(`./helper` 导出的目标)和 inspector 的 `src/worker/entry.ts`(worker_threads 入口)。
|
||||
|
||||
## 决策
|
||||
|
||||
- 入口模块用显式 `async function main()`:
|
||||
- sensevoice:就绪前失败时只把错误消息写到 stderr,消息写完后以退出码 1 退出(在写入回调里调用 `process.exit(1)`,否则已加载的原生识别器会让进程一直存活);stdout 只有一行 `{"port":N}`,Host 把它解析为就绪信号。
|
||||
- ssh helper:`main()` 承载 helper 的 try/catch/finally 和信号处理;每个失败都以 `dsh-ssh-sandbox:` 前缀写到 stderr,退出码为 127。
|
||||
- inspector worker:同步启动部分留在模块顶层,异步部分在 `main()` 里运行,失败时先发 failure 帧再 stop。
|
||||
- 门禁 [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts):
|
||||
- **范围**:`packages/*/*/src/**` 下的 `.ts` 和 `.tsx`。
|
||||
- **判定**:基于 TypeScript AST。最近的函数体是模块本身的 `await` 表达式、`for await`、`await using` 都算违规,包括顶层 if、块、try、循环、switch、条件表达式、export 初始化和计算成员名里的。函数、方法、构造器、访问器、参数默认值、class static block 里的不算。
|
||||
- **排除**:只排除 bin,依据只取声明和语法:源文件以 `#!` 开头,或者是 package.json `bin` 指向的 `lib/<path>.js` 对应的 `src/<path>.ts|.tsx`。bin 由进程直接执行,不会被其他模块导入。当前排除 webworker-packer 和 llm-mock-server 的两个 `src/bin.ts`,清单由 spec 锁定。
|
||||
- **语料防护**:语料为空、没有 `.tsx`、或某个含 TS 的 `src/` 目录完全没进语料时,门禁报错。
|
||||
- **注册**:`run-gates` 的共享卫生门禁,跑在 static lane 和 hygiene 聚合里。规则正文写在 [package-surface 防御模式](../../../../docs/defensive-patterns/package-surface.zh.md)。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只靠 webworker packer 的拒绝来把关。** packer 只检查进入镜像的模块,镜像外的包仍可能引入顶层 await。
|
||||
|
||||
**用 lint 规则实现。** oxlint 没有现成规则能精确表达“模块顶层”。语法级脚本门禁更可控,也能用测试锁定每一种纳入和排除的形式。
|
||||
|
||||
**连 bin 一起禁止。** bin 由进程直接执行,不被导入,顶层 await 不影响任何模块的加载。
|
||||
|
||||
## 后果
|
||||
|
||||
每个可被导入的包源码都同步求值,包括从不进入 Preview 镜像的包;每个入口自己处理启动失败;voice-input-bundle 能进入 Preview 镜像。
|
||||
|
||||
这一取舍的代价:
|
||||
|
||||
- 第三方包和 `apps/` 不在范围内;页面引导脚本 `apps/web/src/preview.ts` 使用顶层 await。
|
||||
- sensevoice 启动失败的诊断只有错误消息,不带堆栈。
|
||||
|
||||
## 测试
|
||||
|
||||
- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) 锁定每一种纳入和排除的形式、语料防护和当前的排除清单;[run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 锁定门禁的注册。若上述三个入口的异步启动放在模块顶层,门禁报出 4 处(sensevoice 1 处、ssh 1 处、inspector 2 处)并失败。
|
||||
- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) 启动 sensevoice 入口,锁定就绪前失败时退出码为 1、stdout 为空、stderr 给出原因且不带堆栈。
|
||||
- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) 启动 ssh helper 入口,[integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) 经 `startInspector` 启动 inspector Worker;没有 spec 触发 inspector worker 入口的启动失败帧。
|
||||
+4
-4
@@ -21,11 +21,11 @@
|
||||
en: f07bb6fbbdd01fe4
|
||||
zh: 9313a0ef2a22af12
|
||||
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
|
||||
en: 6a303115f85ed4e8
|
||||
zh: e894f3e9d630bf57
|
||||
en: bfa4b063696492a5
|
||||
zh: 3cee9b6087dbee91
|
||||
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
|
||||
en: 1e21f5f9ca959f7e
|
||||
zh: 401e303071b8d426
|
||||
en: 7600c1671b5dc73c
|
||||
zh: b38e3ae76a5a32d1
|
||||
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
|
||||
en: 8f9d25b72d3e7660
|
||||
zh: e5c6ae0194905876
|
||||
@@ -43,15 +43,18 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
|
||||
| Item | Content |
|
||||
|---|---|
|
||||
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
|
||||
| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval |
|
||||
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
|
||||
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
|
||||
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
|
||||
| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
|
||||
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
|
||||
|
||||
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
|
||||
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; ratios are not machine-scaled. Unmatched with/without ratios use a common 1.75× headroom because independently selected minima vary with V8 tiering and workload shape; unchanged-worker CI ratios reached 1.65× their reference expectations. Absolute time budgets, doubling ratios, and allocation budgets retain their existing limits. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
|
||||
|
||||
The reference constants are calibrated for minima of five single evaluations. Minimum-of-five sample means use the existing absolute headroom without recalibration. Averaging can move the ratio of independently selected minima in either direction; a threefold matcher-work negative control verifies that the JSON ratio limit still rejects extra scanning.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
|
||||
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
|
||||
- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure.
|
||||
- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower.
|
||||
|
||||
@@ -43,15 +43,18 @@ Status: implemented
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
|
||||
| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 |
|
||||
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
|
||||
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
|
||||
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
|
||||
| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
|
||||
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
|
||||
|
||||
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
|
||||
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;比值不随机器缩放。无凭据文本的有/无匹配器耗时比统一使用 1.75 倍余量,因为独立选取的最小值会随 V8 编译层级和文本形状变化;未改变的 worker 在 CI 中达到过参考预期的 1.65 倍。绝对耗时、翻倍比和分配预算保持原有上限。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
|
||||
|
||||
参考常量按五次单独求值的最小值校准;五组样本均值的最小值使用既有绝对耗时余量,不重新校准。求平均值可能使两侧独立最小值的比值向任一方向变化;将匹配器工作量增至三倍的负对照验证 JSON 比值上限仍能拒绝额外扫描。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
|
||||
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
|
||||
- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。
|
||||
- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。
|
||||
|
||||
+2
-2
@@ -42,8 +42,8 @@
|
||||
en: bf81660f7d4638fa
|
||||
zh: 4d59310d2e6476fd
|
||||
/agent-note-logical-session-and-storage-rebuild/references:
|
||||
en: 32c7669fd961ad1a
|
||||
zh: 09dca97558a3e7b0
|
||||
en: 3a8115fc089df4dc
|
||||
zh: 155a6514bb296018
|
||||
/agent-note-logical-session-and-storage-rebuild/alternatives-considered:
|
||||
en: 4bc014b676d93c13
|
||||
zh: 1e0a04856da60bb0
|
||||
|
||||
@@ -141,6 +141,7 @@ The proposal preserves the shipped [handle-based persistence decision](../../imp
|
||||
|
||||
## References
|
||||
|
||||
- [Cleanup dependency lifetimes](2026-10-09-cordis-cleanup-dependency-lifetimes.md) adds the graceful-teardown requirement for the stage 4 enqueue ownership change: retain writers and required listeners until producers finish publishing final events.
|
||||
- [Sessions subsystem](../../../../docs/subsystems/session.md) defines the shipped session API and event semantics.
|
||||
- [Session persistence subsystem](../../../../docs/subsystems/persistence.md) defines the shipped durable format and lifecycle.
|
||||
- [`@deepseek-ai/dsh-session`](../../../../packages/core/session/README.md) owns the session package contract.
|
||||
|
||||
@@ -141,6 +141,7 @@ composition ──► SessionService ──► LogicalSession implementation
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [清理依赖生命周期](2026-10-09-cordis-cleanup-dependency-lifetimes.zh.md)为阶段 4 的入队所有权变更补充有序清理要求:保留写入器与必需监听器,直到生产者完成最终事件发布。
|
||||
- [会话子系统](../../../../docs/subsystems/session.zh.md)定义已交付的会话 API 与事件语义。
|
||||
- [会话持久化子系统](../../../../docs/subsystems/persistence.zh.md)定义已交付的持久格式与生命周期。
|
||||
- [`@deepseek-ai/dsh-session`](../../../../packages/core/session/README.zh.md)拥有会话包约定。
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Bilingual-pair consistency record for 2026-10-09-cordis-cleanup-dependency-lifetimes.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/proposed/architecture/2026-10-09-cordis-cleanup-dependency-lifetimes.md
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish:
|
||||
en: 5697ed719fc785dc
|
||||
zh: 6752482fc87287bd
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/problem:
|
||||
en: 8097f1f18de77ada
|
||||
zh: 6ac996a2e319b376
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/proposal:
|
||||
en: cd7c3cd7a85e3653
|
||||
zh: fe76a2eddddec695
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings:
|
||||
en: 630ab3bad5d44193
|
||||
zh: 71d3ba982c6f9a42
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings/session-persistence-drops-events-emitted-during-cleanup:
|
||||
en: 347916370af3b6b2
|
||||
zh: 39b1da819efc1ef9
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings/storage-closes-before-schedule-commits-accepted-delivery:
|
||||
en: 8a9578bbc700c0bf
|
||||
zh: 8ee46e5ef1cf3972
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings/authorization-loses-required-observation-or-an-accepted-credential-write:
|
||||
en: 33312dc2b9813ef9
|
||||
zh: ba2fb5fac770e698
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings/ssh-disables-cleanup-requests-before-dependent-process-cleanup-finishes:
|
||||
en: 85fb2b564bc40230
|
||||
zh: 97c3e794124f8044
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/p0-findings/windows-sandbox-releases-temporary-process-resources-during-consumer-cleanup:
|
||||
en: faa50d48dca589c5
|
||||
zh: a9e651ccff96ae31
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/scope-and-existing-decisions:
|
||||
en: 8cf4a670022dcb47
|
||||
zh: 0611538847281f9a
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/alternatives-considered:
|
||||
en: 383a83fd09d3fe0d
|
||||
zh: cf55505415b6f197
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/acceptance-criteria:
|
||||
en: 57057426039f75f4
|
||||
zh: 16dbf1d3ca83783a
|
||||
/agent-note-keep-cleanup-dependencies-usable-until-consumers-finish/risks:
|
||||
en: 4520eb132dfe9032
|
||||
zh: 55b01317ecea3d31
|
||||
@@ -0,0 +1,93 @@
|
||||
# Agent Note: Keep cleanup dependencies usable until consumers finish
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-10-09-cordis-cleanup-dependency-lifetimes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A disposer can await its consumers while another disposer has already removed the listener or closed the resource those consumers need. Awaiting a provider's final disposal promise therefore does not establish resource survival. This affects shutdown and plugin replacement, including final Session events, accepted reminder delivery, credential commits, and remote process termination.
|
||||
|
||||
The [Cordis audit skill](../../../skills/cordis-styling-nitpicking/SKILL.md#p0-cleanup-dependency-released-before-consumer-completion) classifies this lifecycle violation as P0. [Fiber unload (`Fiber._unload`)](../../../../vendor/cordis/src/fiber.ts) starts sibling disposers concurrently; [service withdrawal (`ReflectService.provide`)](../../../../vendor/cordis/src/reflect.ts) waits for dependent fibers only inside its own disposer. Service injection establishes dependency tracking but cannot prevent sibling resource closure. [Event subscriptions (`EventsService.register`)](../../../../vendor/cordis/src/events.ts) are independently owned effects, including subscriptions made through a caller-traced service context.
|
||||
|
||||
## Proposal
|
||||
|
||||
Make each required resource's owner retain it through the consumers' complete asynchronous cleanup. Stop admitting new work, settle accepted work and final publication, then drain and release the resource. Compose the actual disposers into an awaited ownership sequence; a returned wrapper does not capture an independently registered original effect. Preserve cancellation and error reporting while ensuring one failure cannot bypass remaining cleanup.
|
||||
|
||||
The audit below records unresolved production paths inspected on 2026-10-09. It proposes implementation work; this note and the skill do not fix runtime behavior. Findings target the cleanup-dependency rule only, not the skill's separate singleton and unreversed-effect rules.
|
||||
|
||||
## P0 findings
|
||||
|
||||
### Session persistence drops events emitted during cleanup
|
||||
|
||||
**Owners and evidence.** The [JSONL plugin (`apply`)](../../../../packages/session/session-persistence-jsonl/src/index.ts) composes service withdrawal before tracker disposal. The [tracker (`JsonlBackendTracker.install`)](../../../../packages/session/session-persistence-jsonl/src/storage.ts) removes live-event listeners before closing handles. This protects consumers unloaded by provider withdrawal, but concurrent root disposal can unregister consumer fibers before withdrawal discovers them; the plugin explicitly excludes that ordering guarantee. The Agent loop [cancels, awaits idle and scope disposal, then closes its handle (`AgentLoop.prepare`)](../../../../packages/core/agent-loop/src/index.ts). The `AgentLoop.prepare` teardown comment explicitly requires backend live-event listeners to remain mounted through driver shutdown to receive closing events. Its [turn unwind (`ReactLoopAgent.turn`)](../../../../packages/core/agent-loop/src/agent.ts) can publish recovered tool results and closing events after awaits.
|
||||
|
||||
**Failure and correction.** During root disposal, pause the turn's cancellation unwind. JSONL can remove its routing listeners and finish closing the currently buffered prefix before the turn emits its final events. Those events reach the live Session but never enter the writer; a later idempotent handle close cannot recover them. Retain routing, writers, and write ownership until every producer settles, then perform the final drain. This is source-traced; a full Session reproduction remains required.
|
||||
|
||||
The [handle-based persistence decision](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md) explicitly permits losing the unemitted remainder during root disposal. This proposal intentionally tightens that policy for events emitted while graceful cleanup finishes. Crash repair remains necessary for abrupt termination, but cannot substitute for persisting actual final results during awaited shutdown. The [logical-session storage proposal](2026-09-06-logical-session-storage-rebuild.md) relocates enqueue ownership in stage 4; it must also establish this teardown ordering.
|
||||
|
||||
### Storage closes before Schedule commits accepted delivery
|
||||
|
||||
**Owners and evidence.** The storage-domain plugin independently registers [facility closure and service provision (`apply / DomainFacility.closeAll`)](../../../../packages/storage/storage-domain/src/index.ts). [Domain close (`DomainImpl.runClose`)](../../../../packages/storage/storage-domain/src/domain.ts) marks the domain disposing, and [later enqueues reject (`DomainImpl.enqueue`)](../../../../packages/storage/storage-domain/src/domain.ts). Schedule [awaits its runtime and management work before closing its domain (`ScheduleService`)](../../../../packages/schedule/schedule/src/index.ts). Accepted delivery [flushes the Session before retiring or advancing the task (`ScheduleRuntime.drive`)](../../../../packages/schedule/schedule/src/runtime.ts).
|
||||
|
||||
**Failure and correction.** Hold that Session flush, unload storage-domain, then release the flush. The provider's sibling closer can mark the domain closed while Schedule is still draining; the final task commit then fails. A reminder already persisted in the inbox remains active in task storage and can be delivered again after restart. Withdraw the service and await consumers before closing leftover domains; retain backend units through the domain drain. The [JSON backend](../../../../packages/storage/storage-json/src/index.ts) and [SQLite backend](../../../../packages/storage/storage-sqlite/src/index.ts) have the same sibling-close ownership concern and belong in that implementation review, not separate unproven consumer findings.
|
||||
|
||||
A focused in-memory experiment using the actual Cordis and storage-domain implementation paused an injected consumer before its final write, began provider disposal, then released it. The result was `domain 'audit' is closed`. This reproduces resource closure; the complete Schedule restart consequence is source-traced. The accepted crash window between Session persistence and task commit remains; graceful shutdown must not manufacture that window by closing storage early.
|
||||
|
||||
### Authorization loses required observation or an accepted credential write
|
||||
|
||||
**Owners and evidence.** Authorization's [commit observer (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts) is a caller-owned `ctx.on` effect. Its [commit operation (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts) awaits credential storage, while [success requires the observed update (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts). The account service [leaves committing attempts un-aborted and awaits their completion (`PlatformAccount`)](../../../../packages/credentials/deepseek-account-platform/src/index.ts).
|
||||
|
||||
**Successful write, false failure.** Pause a credential write, dispose the account consumer, then complete the write and emit its update. The observer can auto-unsubscribe alongside the account's drain; Authorization then rejects with `NOT_COMMITTED` despite successful storage. Keep observation owned by the operation until its result settles, or acknowledge successful `session.commit` directly while giving legacy direct-write observation an explicit lifetime. A focused experiment using actual Cordis and Authorization with a deferred in-memory credential provider reproduced `NOT_COMMITTED` after the provider write succeeded; the complete browser sign-in path is source-traced.
|
||||
|
||||
**Accepted write rejected during provider disposal.** Separately, credentials-local [sets `closed` before waiting for queued operations (`LocalCredentialProvider[Service.init]`)](../../../../packages/credentials/credentials-local/src/index.ts), and [queued record writes check that flag again (`LocalCredentialProvider.modifyRecord`)](../../../../packages/credentials/credentials-local/src/index.ts). With an earlier document operation held, an already-committing account attempt can have its accepted write rejected when the queue advances after provider disposal begins. Distinguish new admission from accepted operations, and retain write capability until dependent commit drainage finishes. This variant is source-traced, not independently reproduced. Fixing the observer alone cannot fix it.
|
||||
|
||||
### SSH disables cleanup requests before dependent process cleanup finishes
|
||||
|
||||
**Owners and evidence.** The SSH service's [disposal (`SshConnection.disposeOnce`)](../../../../packages/ssh/ssh/src/index.ts) sets `closed` and aborts its lifetime before awaiting helper shutdown. Its [request guard (`SshConnection.assertOpen`)](../../../../packages/ssh/ssh/src/index.ts) rejects subsequent use. The injected subprocess provider [terminates and awaits live processes and terminals during cleanup (`SshSubprocessRuntime`)](../../../../packages/ssh/subprocess-ssh/src/index.ts), which requires SSH requests.
|
||||
|
||||
**Failure and correction.** SSH provider disposal can close request admission while subprocess cleanup still needs termination and exit confirmation, despite `inject = ['ssh']`. The dependent reports that SSH process cleanup could not be confirmed. Keep the cleanup request capability available until dependents settle, then close the helper, sockets, and master. The helper's own close also attempts remote cleanup: the evidence establishes a lost cleanup capability and possible disposal errors, not guaranteed orphaned remote processes. A focused reproduction used real Cordis, SSH and subprocess providers with a mocked child-process/filesystem transport. It started a process and unloaded SSH after the exit-wait RPC was admitted. The handle rejected with `SSH connection is closed`; the helper received `close` without `process.terminate`. This verifies the composed-provider defect, not actual remote process quiescence.
|
||||
|
||||
### Windows sandbox releases temporary process resources during consumer cleanup
|
||||
|
||||
**Owners and evidence.** The local sandbox's [sibling cleanup effect (`LocalSandboxProvider`)](../../../../packages/sandbox/sandbox-local/src/index.ts) calls [grant revocation and temporary-directory removal (`LocalSandboxProvider.revokeAclGrants`)](../../../../packages/sandbox/sandbox-local/src/index.ts). The [temporary capability (`LocalSandboxProvider.materializeAclGrant`)](../../../../packages/sandbox/sandbox-local/src/index.ts) belongs to the provider and is embedded in the confined runner arguments. The injected Node PTC runtime [aborts and awaits accepted runs (`NodePtcRuntime`)](../../../../packages/ptc-runtime/ptc-runtime-node/src/index.ts); [completion awaits process exit and output drainage (`NodePtcRuntime.execute`)](../../../../packages/ptc-runtime/ptc-runtime-node/src/index.ts).
|
||||
|
||||
**Failure and correction.** Unloading the sandbox can revoke a live process's temporary-directory permission and remove its directory while the PTC consumer is still awaiting process termination. Final temporary-file access can fail, and removal can encounter a still-running process. Retain temporary capabilities through the admitted process lifetime, then revoke and remove them after actual process quiescence. Standing workspace grants intentionally remain; this finding concerns revocable temporary resources. Holder-owned shell processes also require process lifetime ownership, so merely ordering executor unload is insufficient.
|
||||
|
||||
A controlled reproduction used actual Cordis, the local sandbox provider, and real temporary directories with mocked Windows ACL operations. While an injected consumer's disposer was paused and provider disposal remained pending, the directory was already absent and its temporary grant revoked. This establishes provider release timing; the concrete PTC dependency is source-traced. Native Windows restricted-token access and live PTC termination were not exercised.
|
||||
|
||||
## Scope and existing decisions
|
||||
|
||||
The [persistent PTY decision](../../implemented/feature/2026-07-16-persistent-pty-sessions.md) remains active. Its early registration closure does not authorize removing observers required for quiescent teardown. Admission and optional notifications may stop first; completion dependencies must remain usable. This clarifies the scope of that teardown policy without replacing PTY ownership, rollback, or process-tree cleanup decisions.
|
||||
|
||||
Four independent directory scans covered core/session/jobs; execution, SSH, terminals, sandbox, subagents and workflow; host/client/API/boot/SDK/ACP/MCP/browser/computer packages and apps; and storage/schedule/credentials plus remaining provider groups. Candidate discovery used cleanup registrations, followed by consumer and resource-owner tracing. Surviving findings were rechecked against current master after refreshing the checkout. This is not an exhaustive semantic proof for every package.
|
||||
|
||||
The current AgentLoop composite retains projection registrations through ownership disposal, so the earlier projection candidate is excluded. Jobs cancellation already awaits settlement. Working-directory and title work revalidate cancellation after awaits. Projection-cache failures are explicitly fail-soft for rebuildable data; the inspected production telemetry consumer is on-demand. The Session history promotion candidate concerns admission after a drain snapshot, without an established prematurely released cleanup dependency, and is excluded from this focused rule.
|
||||
|
||||
The active-note supersession audit retains the handle-based persistence decision and logical-session storage proposal, cross-linked above: only graceful-shutdown tail retention overlaps, while their handle APIs, format, storage separation, and other rationale remain applicable. No implemented note is fully superseded by this proposal. The [Host schedule storage decision](../../implemented/architecture/2026-09-16-host-schedule-storage.md), [authorization flow decision](../../implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md), and [account-login decision](../../implemented/architecture/2026-09-14-deepseek-account-login.md) remain active: independent task authority, fresh-commit evidence, and settlement of admitted commits are required. A preexisting configured record cannot stand in for evidence of this attempt's successful commit. The [Windows ACL sandbox decision](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) also remains active: temporary capabilities stay revocable, with final revocation ordered after the process lifetime. This proposal supplies the missing cross-owner teardown requirement.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add injection or reverse disposer registration order.** Dependency withdrawal already waits for consumers, and reverse invocation still starts sibling async cleanup concurrently. Neither keeps resources usable across consumer awaits.
|
||||
|
||||
**Serialize every Cordis disposer globally.** This would change unrelated lifetimes, risk dependency deadlocks, and obscure which operations require each resource. Explicit composition expresses the necessary order locally.
|
||||
|
||||
**Catch closed-resource errors or rely on crash repair.** Suppressing errors cannot recover an unrouted result or a failed task commit, and can report a successful credential write as failed. Crash recovery remains useful after abrupt loss, not as a replacement for graceful completion.
|
||||
|
||||
**Wait for a broad storage or Session redesign.** The logical-session proposal may remove event-routing dependence, but the same lifetime rule also affects credentials and SSH. Each owner can establish the required order without waiting for all architectural stages.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Each finding receives a controlled-promise regression that pauses the real consumer before its last dependency use, starts provider or enclosing-owner disposal, releases the pause, and awaits both operation and disposal. Verify required resources and listeners survive the pause and are released afterward.
|
||||
- Session teardown persists the exact emitted final suffix, including tool results and closing events; compare live and restored history rather than checking only that `close()` ran. Cover provider replacement and root disposal, including cleanup failure paths.
|
||||
- Schedule provider unload during the flush-to-task-commit interval retains successful final task state; restarting does not redeliver that completed occurrence. Keep the separately documented abrupt-crash duplication policy.
|
||||
- Account consumer disposal during successful commit returns an outcome consistent with stored credentials. Credentials provider disposal drains an already-admitted queued commit and rejects new admission without cancelling that commit.
|
||||
- SSH provider unload with live processes and PTYs completes dependent termination and confirmation before request resources close. Verify remote quiescence through the real helper; test failure propagation without claiming cleanup success from top-level process exit alone.
|
||||
- Windows sandbox unload or replacement preserves the temporary grant and directory until a confined process finishes a delayed termination and final temporary write. Verify restricted-token access on native Windows, then assert grant revocation and directory removal.
|
||||
- Update affected provider documentation and current owning decisions when fixes ship; preserve durable formats and add the required composition/snapshot coverage for any changed user-visible result.
|
||||
|
||||
## Risks
|
||||
|
||||
Incorrectly composed dependencies can deadlock shutdown or retain a resource indefinitely. Tests must release controlled pauses even after failures, and implementations must preserve existing bounded external-operation policies. Holding providers longer can increase shutdown latency; hiding cleanup failure or silently dropping required final writes is not an acceptable shortcut.
|
||||
|
||||
The focused experiments isolate storage, Authorization, composed SSH-provider teardown, and sandbox resource-release timing. They do not exercise complete product compositions or native Windows access. Session, Schedule consequences, queued credentials, actual remote process exit, and native sandbox behavior still need the integration evidence above before a fix can be considered complete. Listener ownership follows the actual caller context, so moving cleanup to a provider object without transferring its effect ownership may leave the race intact.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# Agent Note: 在消费者清理完成前保持清理依赖可用
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-10-09-cordis-cleanup-dependency-lifetimes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一个 disposer(清理函数)可能仍在等待消费者,另一个 disposer 却已经移除消费者需要的监听器或关闭资源。因此,等待 provider(提供者)的最终释放 promise,不能保证资源在此期间一直可用。这会影响关闭与插件替换,包括最终 Session 事件、已接受的提醒投递、凭据提交和远程进程终止。
|
||||
|
||||
[Cordis 审查 skill](../../../skills/cordis-styling-nitpicking/SKILL.md#p0-cleanup-dependency-released-before-consumer-completion) 将这种生命周期违规归为 P0。[Fiber 卸载 (`Fiber._unload`)](../../../../vendor/cordis/src/fiber.ts) 并发启动同级 disposer;[服务撤销 (`ReflectService.provide`)](../../../../vendor/cordis/src/reflect.ts) 仅在自己的 disposer 内等待依赖 fiber。服务注入建立依赖追踪,却不能阻止同级资源关闭。[事件订阅 (`EventsService.register`)](../../../../vendor/cordis/src/events.ts) 是独立拥有的 effect,包括通过追踪调用方的服务上下文建立的订阅。
|
||||
|
||||
## 提案
|
||||
|
||||
让每项必需资源的所有者,在消费者完成全部异步清理之前保留该资源。停止接收新工作,完成已接受的工作和最终发布,然后排空并释放资源。将实际 disposer 组合成明确等待的所有权顺序;返回包装函数不能收纳独立注册的原始 effect。保留取消和错误报告,同时保证一次失败不会绕过余下清理。
|
||||
|
||||
以下审查记录了 2026-10-09 检查到的未解决生产路径。它提出后续实现工作;本 Note 和 skill 不修复运行时行为。发现仅针对清理依赖规则,不覆盖 skill 中独立的单例与未撤销副作用规则。
|
||||
|
||||
## P0 发现
|
||||
|
||||
### Session 持久化丢失清理期间发出的事件
|
||||
|
||||
**所有者与证据。** [JSONL 插件 (`apply`)](../../../../packages/session/session-persistence-jsonl/src/index.ts)通过组合清理,先撤回服务,再释放追踪器。[追踪器 (`JsonlBackendTracker.install`)](../../../../packages/session/session-persistence-jsonl/src/storage.ts)先移除实时事件监听器,再关闭句柄。这能保护由 provider 撤回触发卸载的消费方,但根资源并发释放可能在服务撤回发现消费方 fiber 之前,就已将其注销;插件明确不保证这种情形的释放顺序。Agent loop [取消运行,等待 idle 与 scope 释放,然后关闭句柄 (`AgentLoop.prepare`)](../../../../packages/core/agent-loop/src/index.ts)。`AgentLoop.prepare` 的清理注释明确要求后端实时事件监听器在驱动器关闭期间保持挂载,以接收结束事件。其[轮次收尾 (`ReactLoopAgent.turn`)](../../../../packages/core/agent-loop/src/agent.ts)可能在 await 后发布补齐的工具结果与结束事件。
|
||||
|
||||
**失败与修正。** 根资源释放时,暂停轮次的取消收尾。JSONL 可以先移除路由监听器,并关闭当前已缓冲的前缀,然后轮次才发出最终事件。这些事件进入实时 Session,却从未进入写入器;之后幂等的句柄关闭无法恢复它们。应保留路由、写入器与写所有权,直到所有生产者结束,再执行最终排空。此项由源码追踪确认;仍需完整 Session 复现。
|
||||
|
||||
[基于句柄的持久化决定](../../implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)明确允许根释放期间丢失尚未发出的剩余事件。本提案有意收紧该策略,要求保留有序清理完成期间发出的事件。突然终止仍需要崩溃修复,但修复不能替代被等待的关闭过程中实际最终结果的持久化。[逻辑会话存储提案](2026-09-06-logical-session-storage-rebuild.zh.md)在阶段 4 重新分配入队所有权;它还必须建立这里要求的释放顺序。
|
||||
|
||||
### Storage 在 Schedule 提交已接受的投递前关闭
|
||||
|
||||
**所有者与证据。** storage-domain 插件独立注册[设施关闭与服务提供 (`apply / DomainFacility.closeAll`)](../../../../packages/storage/storage-domain/src/index.ts)。[Domain close (`DomainImpl.runClose`)](../../../../packages/storage/storage-domain/src/domain.ts) 将 domain 标记为 disposing,[之后的入队会拒绝 (`DomainImpl.enqueue`)](../../../../packages/storage/storage-domain/src/domain.ts)。Schedule [等待运行时与管理工作后才关闭自己的 domain (`ScheduleService`)](../../../../packages/schedule/schedule/src/index.ts)。已接受的投递[先 flush Session,再结束任务或推进下一次执行 (`ScheduleRuntime.drive`)](../../../../packages/schedule/schedule/src/runtime.ts)。
|
||||
|
||||
**失败与修正。** 暂停该 Session flush,卸载 storage-domain,然后放行 flush。provider 的同级关闭器可以在 Schedule 仍在排空时将 domain 标记为关闭,导致最终任务提交失败。已持久化到 inbox 的提醒在任务存储中仍处于活动状态,重启后可能再次投递。应先撤销服务并等待消费者,再关闭剩余 domain;在 domain 排空期间保留后端存储单元。[JSON 后端](../../../../packages/storage/storage-json/src/index.ts)与 [SQLite 后端](../../../../packages/storage/storage-sqlite/src/index.ts)也存在同级关闭的所有权风险,应纳入同一次实现审查,而不应在未确认消费者时另立发现。
|
||||
|
||||
使用实际 Cordis 与 storage-domain 实现的内存实验,在注入消费者最终写入前暂停,开始释放 provider,再放行消费者,得到 `domain 'audit' is closed`。这复现了资源提前关闭;完整 Schedule 重启后果由源码追踪得出。Session 持久化与任务提交之间已有的崩溃窗口仍然存在;有序关闭不应通过提前关闭存储主动制造这个窗口。
|
||||
|
||||
### Authorization 丢失必需的提交观察或已接受的凭据写入
|
||||
|
||||
**所有者与证据。** Authorization 的[提交观察器 (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts)是调用方拥有的 `ctx.on` effect。[提交操作 (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts)等待凭据存储,而[成功要求观察到更新 (`AuthorizationService.attempt`)](../../../../packages/credentials/authorization/src/index.ts)。账号服务[不会中止正在提交的尝试,并等待其完成 (`PlatformAccount`)](../../../../packages/credentials/deepseek-account-platform/src/index.ts)。
|
||||
|
||||
**写入成功,却报告失败。** 暂停凭据写入,释放账号消费者,再完成写入并发出更新。观察器可以与账号的排空并发自动退订;随后 Authorization 即使存储成功也会以 `NOT_COMMITTED` 拒绝。应让操作拥有观察器直到结果确定,或直接确认成功的 `session.commit`,同时给旧式直接写入观察明确的生命周期。使用实际 Cordis 与 Authorization、配合延迟的内存凭据 provider 的实验,复现了 provider 写入成功后返回 `NOT_COMMITTED`;完整浏览器登录路径由源码追踪确认。
|
||||
|
||||
**provider 释放期间拒绝已接受的写入。** 另一个问题是,credentials-local [先设置 `closed` 再等待队列操作 (`LocalCredentialProvider[Service.init]`)](../../../../packages/credentials/credentials-local/src/index.ts),而[排队中的记录写入再次检查该标志 (`LocalCredentialProvider.modifyRecord`)](../../../../packages/credentials/credentials-local/src/index.ts)。当较早的文档操作暂停时,账号已进入提交阶段;provider 开始释放后队列继续推进,该已接受写入仍可能被拒绝。应区分新工作准入与已接受操作,并保留写入能力直到依赖者的提交排空。此变体由源码追踪确认,未单独复现。仅修复观察器无法解决它。
|
||||
|
||||
### SSH 在依赖者的进程清理完成前禁用清理请求
|
||||
|
||||
**所有者与证据。** SSH 服务的[释放逻辑 (`SshConnection.disposeOnce`)](../../../../packages/ssh/ssh/src/index.ts)在等待 helper 关闭前设置 `closed` 并中止自身 lifetime。[请求检查 (`SshConnection.assertOpen`)](../../../../packages/ssh/ssh/src/index.ts)拒绝后续使用。注入该服务的 subprocess provider [在清理时终止并等待存活进程与终端 (`SshSubprocessRuntime`)](../../../../packages/ssh/subprocess-ssh/src/index.ts),这些工作需要 SSH 请求。
|
||||
|
||||
**失败与修正。** 即使有 `inject = ['ssh']`,SSH provider 释放仍可以在 subprocess 清理需要终止和退出确认时关闭请求准入。依赖者因此报告无法确认 SSH 进程清理。应保留清理请求能力直到依赖者结束,再关闭 helper、socket 和 master。helper 自身的 close 也会尝试远程清理:证据证明清理能力丢失与可能的释放错误,并不证明必然留下远程孤儿进程。聚焦复现使用实际 Cordis、SSH 与 subprocess provider,模拟子进程和文件系统传输。启动进程,并在退出等待 RPC 准入后卸载 SSH,句柄以 `SSH connection is closed` 拒绝;helper 收到 `close`,却未收到 `process.terminate`。这验证了 provider 组合中的缺陷,没有验证实际远程进程是否完全停止。
|
||||
|
||||
### Windows sandbox 在消费者清理期间释放进程临时资源
|
||||
|
||||
**所有者与证据。** 本地 sandbox 的[同级清理 effect (`LocalSandboxProvider`)](../../../../packages/sandbox/sandbox-local/src/index.ts)调用[权限撤销与临时目录移除 (`LocalSandboxProvider.revokeAclGrants`)](../../../../packages/sandbox/sandbox-local/src/index.ts)。[临时能力 (`LocalSandboxProvider.materializeAclGrant`)](../../../../packages/sandbox/sandbox-local/src/index.ts)属于 provider,并被嵌入受限 runner 的参数中。注入该服务的 Node PTC runtime [中止并等待已接受的运行 (`NodePtcRuntime`)](../../../../packages/ptc-runtime/ptc-runtime-node/src/index.ts);[完成流程等待进程退出与输出排空 (`NodePtcRuntime.execute`)](../../../../packages/ptc-runtime/ptc-runtime-node/src/index.ts)。
|
||||
|
||||
**失败与修正。** 卸载 sandbox 时,PTC 消费者可能仍在等待进程终止,但存活进程的临时目录权限已被撤销,目录已被移除。最终临时文件访问可能失败,删除操作也可能遇到仍在运行的进程。应在已准入进程存活期间保留临时能力,在进程真正停止后再撤销权限并移除目录。长期工作区权限有意保留;本项只涉及可撤销的临时资源。由持有者管理的 shell 进程也需要与进程生命周期绑定的所有权,因此仅排列 executor 卸载顺序并不足够。
|
||||
|
||||
受控复现使用实际 Cordis、本地 sandbox provider 与真实临时目录,并模拟 Windows ACL 操作。当注入消费者的 disposer 暂停、provider 释放仍未完成时,目录已不存在,临时权限已被撤销。这确认了 provider 的释放时序;具体 PTC 依赖通过源码追踪确认。未执行原生 Windows 受限令牌访问与存活 PTC 进程终止测试。
|
||||
|
||||
## 范围与现有决定
|
||||
|
||||
[持久化 PTY 决定](../../implemented/feature/2026-07-16-persistent-pty-sessions.zh.md)继续生效。其提前撤销注册的规则不允许移除完全停稳所需的观察器。准入与可选通知可以先停止;完成操作所需的依赖必须保持可用。这里澄清该清理策略的范围,不替代 PTY 所有权、回滚或进程树清理决定。
|
||||
|
||||
四个独立目录扫描分别覆盖 core/session/jobs;执行、SSH、终端、sandbox、subagent 与 workflow;host/client/API/boot/SDK/ACP/MCP/browser/computer 包与 apps;storage/schedule/credentials 及其余 provider 分组。候选发现从清理注册开始,再追踪消费者与资源所有者。刷新检出后,对保留的发现按当前 master 重新验证。这不是对每个包的穷尽语义证明。
|
||||
|
||||
当前 AgentLoop 的组合 effect 在 ownership 释放期间保留 projection 注册,因此排除早先的 projection 候选。Jobs 取消已等待结束。Working-directory 和 title 工作在 await 后重新检查取消。Projection-cache 失败明确允许可重建数据降级;已检查的生产 telemetry 消费者按需调用。Session history 的 promotion 候选涉及排空快照之后接纳新工作,没有确认提前释放的清理依赖,因此不纳入本次聚焦规则。
|
||||
|
||||
活动 Note 替代审查保留并交叉链接了上面的基于句柄的持久化决定与逻辑会话存储提案:只有有序关闭时的尾部保留发生重叠,而它们的句柄 API、格式、存储分离及其他理由仍然适用。本提案没有完整替代任何 implemented Note。[Host schedule 存储决定](../../implemented/architecture/2026-09-16-host-schedule-storage.zh.md)、[授权流程决定](../../implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md)和[账号登录决定](../../implemented/architecture/2026-09-14-deepseek-account-login.zh.md)继续生效:独立任务权威、当次提交证据与已准入提交的完成仍然必要。已有的 configured 记录不能替代本次尝试成功提交的证据。[Windows ACL sandbox 决定](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)也继续生效:临时能力仍可撤销,但最终撤销应排在进程生命周期结束之后。本提案补充缺失的跨所有者释放要求。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**增加注入或逆转 disposer 注册顺序。** 依赖撤销已经等待消费者,而逆序调用仍会并发启动同级异步清理。两者都无法保证资源跨越消费者 await 持续可用。
|
||||
|
||||
**全局串行化所有 Cordis disposer。** 这会改变无关生命周期,带来依赖死锁风险,并掩盖具体哪些操作需要哪些资源。显式组合可以在局部表达必要顺序。
|
||||
|
||||
**捕获资源已关闭错误或依赖崩溃修复。** 吞掉错误无法恢复未路由的结果或失败的任务提交,还可能将成功的凭据写入报告为失败。崩溃恢复仍适用于突然中断,不能替代有序完成。
|
||||
|
||||
**等待完整的存储或 Session 重构。** 逻辑会话提案可能移除对事件路由的依赖,但同一生命周期规则也影响凭据与 SSH。每个所有者都可以建立必要顺序,无需等待所有架构阶段完成。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 为每项发现增加受控 promise 回归:在真实消费者最后使用依赖前暂停,开始 provider 或外层所有者释放,放行暂停,并等待操作和释放都完成。验证必需资源与监听器在暂停期间仍然可用,之后确实释放。
|
||||
- Session 清理持久化实际发出的最终后缀,包括工具结果与结束事件;比较实时与恢复后的历史,而不只检查 `close()` 被调用。覆盖 provider 替换和根释放,包括清理失败路径。
|
||||
- Schedule 在 flush 到任务提交的间隔中卸载 provider,仍保留成功投递的最终任务状态;重启不会再次投递已完成的这次提醒。保留单独记录的突然崩溃重复投递策略。
|
||||
- 成功提交期间释放账号消费者,返回结果应与已存凭据一致。释放凭据 provider 应排空已接受且排队中的提交,并拒绝新准入,不能取消该提交。
|
||||
- SSH provider 带存活进程和 PTY 卸载时,先完成依赖者的终止与确认,再关闭请求资源。通过真实 helper 验证远程工作完全停止;测试失败传播,不能仅凭顶层进程退出就声称清理成功。
|
||||
- Windows sandbox 卸载或替换期间,保留临时权限与目录,直到受限进程完成延迟终止与最终临时写入。在原生 Windows 上验证受限令牌访问,再断言权限已撤销、目录已移除。
|
||||
- 修复交付时更新受影响 provider 文档与当前所属决策;保留持久格式,并为任何改变的用户可见结果补充所需组合与快照覆盖。
|
||||
|
||||
## 风险
|
||||
|
||||
错误组合依赖可能使关闭死锁或无限保留资源。测试即使失败也必须放行受控暂停,实现必须保留现有外部操作时限策略。延长 provider 存活时间可能增加关闭延迟;隐藏清理失败或静默丢弃必需的最终写入不能作为捷径。
|
||||
|
||||
聚焦实验隔离了 storage、Authorization、组合 SSH provider 的释放,以及 sandbox 资源释放时序,没有覆盖完整产品组合或原生 Windows 访问。Session、Schedule 后果、凭据队列变体、实际远程进程退出与原生 sandbox 行为仍需上述集成证据,才能认定修复完成。监听器所有权跟随实际调用方上下文,因此只将清理移动到 provider 对象,而不转移其 effect 所有权,可能仍会保留竞态。
|
||||
@@ -1,46 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.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/proposed/feature/2026-10-02-plugin-market-index-format.md
|
||||
/agent-note-plugin-market-index-format:
|
||||
en: 8da20a8601d7cb29
|
||||
zh: 74368eed4d4b8e7c
|
||||
/agent-note-plugin-market-index-format/problem:
|
||||
en: ce5e4a362f558af6
|
||||
zh: 29d93e0655b99217
|
||||
/agent-note-plugin-market-index-format/proposal:
|
||||
en: ed9148e95b2ad7c2
|
||||
zh: 25a7add3c56fd681
|
||||
/agent-note-plugin-market-index-format/proposal/example:
|
||||
en: 1b1c960c966cf912
|
||||
zh: 97be87cb14893c26
|
||||
/agent-note-plugin-market-index-format/proposal/root-fields:
|
||||
en: 4fac5a9b8261342d
|
||||
zh: f9168a8a4209feab
|
||||
/agent-note-plugin-market-index-format/proposal/plugin-fields:
|
||||
en: 19d743bd04dc65b3
|
||||
zh: 810a7f98c4fe8301
|
||||
/agent-note-plugin-market-index-format/proposal/version-fields:
|
||||
en: d6c0fa99e1cbb068
|
||||
zh: 3d2a7b85299c679a
|
||||
/agent-note-plugin-market-index-format/proposal/install-arguments:
|
||||
en: c748cdc4d8ce349a
|
||||
zh: 5740fa0d6106e637
|
||||
/agent-note-plugin-market-index-format/proposal/dsh-compatibility:
|
||||
en: 00fe507b9b5dfea7
|
||||
zh: 55de6aaeb3865e84
|
||||
/agent-note-plugin-market-index-format/proposal/generator-obligations:
|
||||
en: 8e848a102fe9d86e
|
||||
zh: feff226ef8321e00
|
||||
/agent-note-plugin-market-index-format/proposal/reader-rules:
|
||||
en: 92903cbe87d9c0ca
|
||||
zh: 54af6cb33e9da252
|
||||
/agent-note-plugin-market-index-format/alternatives-considered:
|
||||
en: 3d7cf519bc8e0593
|
||||
zh: 328dc0747836eaa8
|
||||
/agent-note-plugin-market-index-format/acceptance-criteria:
|
||||
en: 753abee6adf39812
|
||||
zh: 1102bcfdd48f7e90
|
||||
/agent-note-plugin-market-index-format/risks:
|
||||
en: 41170d483b17df80
|
||||
zh: 1bfd8f44e0f09036
|
||||
Loaded 100 of 1533 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user