diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index e7aef9adc0..448629299d 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,11 +9,11 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: e6ffc783859d7509 - zh: 8678f323985a24f4 + en: 6c8583bdefc5e36c + zh: 0928b4f2876bbfe4 /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: - en: 9ff0511566948d8b - zh: 4e1e37b79f63d390 + en: d6f40bf02df37ab7 + zh: e5b85ce9d41e8754 /agent-note-ship-experimental-capabilities-as-optional-bundles/consequences: - en: 9d2b313f15fc9ce0 - zh: 522341c83565ac33 + en: 38d27583533cd028 + zh: 9bf11f5489c1aa43 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index d529b27542..dbdd104da0 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -14,7 +14,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. Browser and computer providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior. -Bundles add scoped rows with the [profile patch operation](../../../../packages/boot/app-boot/README.md): `preset` names a declaration row, and the ordinary patch operates on its child list. Full Web presets receive the optional model tools; minimal retains its current shell and working-directory tools. Search, Ralph, and terminals supply their own isolated services. Search opens one in-memory index on demand per retained preset revision; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Existing Agents retain their preset generation, and later user patch layers retain precedence. Title replacement waits for disposal of the previous provider. +Bundles insert ordinary Host rows; optional model tools register in the global tool layer, so every preset, including minimal, receives them. Plugin management, profile composition, and reload need no bundle-specific behavior. Search, Ralph, and terminals supply their own isolated services in a Host group. Search opens one in-memory index on demand; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Switching a bundle registers or removes its tools for live Agents, and later user patch layers retain precedence. A replacement title provider may register once the previous provider's disposal starts; the previous provider's late results never commit. Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` catalog; the [on-demand native-bundle decision](2026-10-05-official-on-demand-bundles.md) owns the cost, version, and offline-discovery trade-offs. @@ -26,6 +26,8 @@ Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` **Register preset extensions at runtime.** A separate registration path would need its own ordering, module resolution, generation updates, and inspection behavior. Profile patches keep one effective composition for boot, dumps, validation, and user overrides. +**Insert rows into named presets with a `preset` patch operation.** Limiting tools to full presets required a second patch language across profile composition, reload, plugin management, and the plugin page, plus retained-generation removal checks. Global tools reach the same Agents through the existing Host composition. + ## Consequences -Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Scoped rows appear read-only under their bundle, and removal respects modules retained by live preset generations. Individual and combined composition tests cover the supported presets; live switching and recorded Sessions cover activation and model-visible behavior. +Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Bundle rows are ordinary Host rows with their usual switches. Individual and combined composition tests cover the Host rows and every preset's tool catalog; live switching and recorded Sessions cover activation and model-visible behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 13abff45a0..6c9df974e8 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -14,7 +14,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r 可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。浏览器与电脑提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。 -Bundle 通过 [profile patch 操作](../../../../packages/boot/app-boot/README.zh.md)添加作用域行:`preset` 指定声明行,普通 patch 对其子列表生效。完整 Web 预设获得可选模型工具;minimal 保持当前的 shell 与工作目录工具。搜索、Ralph 与终端提供自己的隔离服务。搜索在每个保留的预设修订中按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。已有 Agent 保留其预设代际,后续用户 patch 层继续优先。标题替换等待前一个提供方完成清理。 +Bundle 插入普通 Host 行;可选模型工具注册到全局工具层,因此包括 minimal 在内的所有预设都会获得它们。插件管理、profile 组合与重载无需针对 bundle 的特殊行为。搜索、Ralph 与终端在 Host 组内提供自己的隔离服务。搜索按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。切换 bundle 会为运行中的 Agent 注册或移除其工具,后续用户 patch 层继续优先。前一个标题提供方开始清理后,替代提供方即可注册;前一个提供方的迟到结果不会提交。 Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,其成本、版本与离线发现取舍由[按需原生 bundle 决策](2026-10-05-official-on-demand-bundles.zh.md)管理。 @@ -26,6 +26,8 @@ Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装, **在运行时注册预设扩展。** 独立注册路径需要自己的排序、模块解析、代际更新与检查行为。Profile patch 为启动、配置导出、校验和用户覆盖保留同一个有效组合。 +**用 `preset` patch 操作向指定预设插入行。** 只把工具限定在完整预设中,需要在 profile 组合、重载、插件管理和插件页中引入第二套 patch 语言,并为保留代际增加删除检查。全局工具通过现有 Host 组合即可到达同样的 Agent。 + ## 后果 -官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。作用域行在 bundle 内只读展示,删除操作尊重 live 预设代际仍持有的模块。独立与组合测试覆盖受支持的预设;实时切换与已记录 Session 覆盖激活和模型可见行为。 +官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。Bundle 行是普通 Host 行,使用常规开关。独立与组合测试覆盖 Host 行及每个预设的工具目录;实时切换与已记录 Session 覆盖激活和模型可见行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml index 4af09bcaae..3060e831f9 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml @@ -9,11 +9,11 @@ en: a590bcef6c9760c0 zh: 19e6f61c2c7d8d3b /agent-note-discover-official-bundles-without-installing-their-runtimes/decision: - en: 31e3c52841f31a11 - zh: ac2ad6e54ef9e150 + en: f0fabc0d4fbd1178 + zh: cf19938a7a7dea93 /agent-note-discover-official-bundles-without-installing-their-runtimes/alternatives-considered: en: 42cb0f808a145871 zh: 225012615d1ecdf5 /agent-note-discover-official-bundles-without-installing-their-runtimes/consequences: - en: 899b07d90024e019 - zh: 01d22ac4b2444feb + en: 293286dcd7e3325e + zh: c6c15212e85d050b diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md index 0b0ad0cf55..a67acd25f9 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md +++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md @@ -12,13 +12,13 @@ Native subagent integrations bring their own runtimes. Shipping those dependenci `ON_DEMAND_BUNDLES` admits the Claude Code and Codex bundles to an installation-owned catalog. The catalog embeds their package-owned localized metadata and icons, so discovery works offline without resolving or importing either package. Official identifies project maintenance; optional delivery and experimental maturity remain independent properties. -Selection uses the ordinary third-party bundle installer, profile dependency, scoped patches, compatibility evaluation, progress, cancellation, registry fallback, and build-script approval. Native authentication and configuration remain provider-owned. +Selection uses the ordinary third-party bundle installer, profile dependency, Host row patches, compatibility evaluation, progress, cancellation, registry fallback, and build-script approval. Native authentication and configuration remain provider-owned. The running installation owns the target. Published applications offer the exact DSH release version and save an exact registry dependency pin. Source Web and development Desktop offer ordinary `link:` dependencies into the same checkout as the running CLI, using its prepared workspace dependencies and peers. The source target follows the actual installation, never the current working directory or a prerelease suffix; an incomplete checkout does not fall back to the registry. A development checkout can share a version string with different published code, so replacing an installed registry copy or another checkout’s link compares the source as well as the version. Replacement is explicit, preserves selection, and reports required restarts; startup does not download providers. [Plugin Manager](../../../../packages/boot/plugin-manager/README.md#use-this-package) owns the operational details. Every Official package remains in the DSH release family. Packed DSH dependencies, optional dependencies, and peers use the exact family version. Publication prerequisites place catalog packages before the DSH package advertising them without adding their runtimes to DSH production dependencies. Catalog verification rejects missing metadata, unsupported membership, and stale generated content. -The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bundles.md) still owns lightweight installation-provided bundles. The [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns shared patch and preset-generation behavior. Native bundles require the full Web preset targets; custom profiles mount their providers and tools explicitly. The [identity compatibility decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) owns retained names, selectors, and deferred naming cleanup. +The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bundles.md) still owns lightweight installation-provided bundles. The [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns shared patch behavior and global optional tools. Native bundles insert ordinary Host rows, so any profile can select them; profiles that keep the package without its bundle mount the same rows explicitly. The [identity compatibility decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) owns retained names, selectors, and deferred naming cleanup. ## Alternatives considered @@ -34,4 +34,4 @@ The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bund ## Consequences -Registry delivery requires a reachable registry; development delivery requires a prepared, built checkout and normal rebuild/restart discipline. Equal source version strings do not certify identical build outputs. Off only deselects; Remove remains separate and respects retained preset modules. Activation failure can retain the saved dependency and selection; there is no rollback. Offline metadata, release pins, source-target selection, same-version source mismatches, clean installed artifacts, and release ordering have dedicated checks. Native-provider authentication is not part of catalog discovery. +Registry delivery requires a reachable registry; development delivery requires a prepared, built checkout and normal rebuild/restart discipline. Equal source version strings do not certify identical build outputs. Off only deselects; Remove remains separate and refuses while the bundle's rows are in use. Activation failure can retain the saved dependency and selection; there is no rollback. Offline metadata, release pins, source-target selection, same-version source mismatches, clean installed artifacts, and release ordering have dedicated checks. Native-provider authentication is not part of catalog discovery. diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md index 6fbdaa6e8f..0949f6b72c 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md @@ -12,13 +12,13 @@ Status: implemented `ON_DEMAND_BUNDLES` 将 Claude Code 和 Codex bundle 纳入安装方维护的目录。目录内嵌包自身拥有的本地化元信息和图标,因此发现过程离线可用,无需解析或导入这两个包。Official 表示项目维护;可选交付与实验成熟度仍是独立属性。 -选择操作复用普通第三方 bundle 的安装器、profile 依赖、作用域 patch、兼容性判断、进度、取消、注册表回退和构建脚本审批。原生认证与配置仍由提供方拥有。 +选择操作复用普通第三方 bundle 的安装器、profile 依赖、Host 行 patch、兼容性判断、进度、取消、注册表回退和构建脚本审批。原生认证与配置仍由提供方拥有。 当前运行的安装决定目标。已发布应用提供精确的 DSH 发布版本,并保存精确的注册表依赖版本。源码 Web 和开发 Desktop 提供指向当前 CLI 所在 checkout 的普通 `link:` 依赖,使用其中已准备好的工作区依赖和 peer。源码目标跟随实际安装位置,不由当前工作目录或预发布版本后缀决定;不完整的 checkout 不回退到注册表。开发 checkout 可以与内容不同的已发布代码共用版本字符串,因此替换已安装的注册表副本或其他 checkout 的链接时,同时比较来源和版本。替换需显式触发,保留选择状态,并报告是否需要重启;启动时不下载提供方。[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md#use-this-package)拥有操作细节。 所有 Official 包仍属于 DSH 发布家族。打包后的 DSH 依赖、可选依赖与 peer 均使用家族精确版本。发布前置关系要求先发布目录包,再发布宣传它们的 DSH 包,同时不把这些运行时加入 DSH 生产依赖。目录校验拒绝缺失的元信息、不受支持的成员和过期生成内容。 -[随附可选 bundle 策略](../process/2026-09-15-shipped-optional-bundles.zh.md)仍管理安装随附的轻量 bundle。[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)管理共享 patch 与预设代际行为。原生 bundle 要求完整 Web 预设目标存在;自定义 profile 显式挂载提供方和工具。[身份兼容决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)管理保留名称、选择器和暂缓的命名清理。 +[随附可选 bundle 策略](../process/2026-09-15-shipped-optional-bundles.zh.md)仍管理安装随附的轻量 bundle。[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)管理共享 patch 行为与全局可选工具。原生 bundle 插入普通 Host 行,因此任何 profile 都可以选择它们;保留包但不选择其 bundle 的 profile 显式挂载相同的行。[身份兼容决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)管理保留名称、选择器和暂缓的命名清理。 ## 考虑过的替代方案 @@ -34,4 +34,4 @@ Status: implemented ## 后果 -注册表交付要求注册表可达;开发交付要求准备并构建 checkout,遵循正常的重新构建和重启流程。源码版本字符串相同不证明构建输出相同。关闭仅取消选择;移除保持独立,并尊重预设仍保留的模块。激活失败可以保留已保存的依赖和选择,不加入回滚。离线元信息、发布版本锁定、源码目标选择、同版本来源差异、干净安装产物和发布顺序有专门检查。原生提供方认证不属于目录发现过程。 +注册表交付要求注册表可达;开发交付要求准备并构建 checkout,遵循正常的重新构建和重启流程。源码版本字符串相同不证明构建输出相同。关闭仅取消选择;移除保持独立,并在 bundle 的行仍在使用时拒绝执行。激活失败可以保留已保存的依赖和选择,不加入回滚。离线元信息、发布版本锁定、源码目标选择、同版本来源差异、干净安装产物和发布顺序有专门检查。原生提供方认证不属于目录发现过程。 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml index bab272d8e1..b22fc768ec 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml @@ -9,14 +9,14 @@ en: 8112c1c059d1cf10 zh: 852d23c56a93a555 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/decision: - en: ac725c281d7dfb41 - zh: 5adcaa6ca6eab384 + en: 90d74300f045bf6e + zh: 088e88944478ca2a /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/deferred-naming-cleanup: en: 4c428f65ef611d07 zh: 1fdc5467b0203d08 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/alternatives-considered: - en: 1bf03723fea6c9a4 - zh: 2d0d2fc997f7c022 + en: 65af55b5775c9bc5 + zh: 2ea75b3fd33ac0c2 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/consequences: - en: 60f7c823b5d2ed6f - zh: 75f375487cab3c56 + en: e8e96b0354f1a3c6 + zh: ad1bfe3600e14ff1 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md index def042e26a..c39f289a39 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md @@ -12,9 +12,9 @@ Moving a capability into the experimental directory changes its ownership and su The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of naming exceptions. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated exception is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection. -Existing badge, Ralph, and native delegation row IDs remain valid selectors inside their applicable Host or preset scope. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows, and a complete preset replacement remains authoritative. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle. +Existing badge, Ralph, and native delegation row IDs remain valid Host row selectors. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle. -Compatibility protection covers Web and Desktop profiles, including existing native provider package identities, selections, configuration, and authentication. Headless, SDK, and ACP compositions may require changes. Native bundles use the ordinary shared patch operation and require the full preset targets; separate provider-only compatibility wrappers are unnecessary for the protected profiles. Public APIs remain pre-stable, and new output metadata stays required unless an actual consumer needs an accommodation. +Compatibility protection covers Web and Desktop profiles, including existing native provider package identities, selections, configuration, and authentication. Native bundles insert ordinary Host rows, so Headless, SDK, and ACP compositions can select them too; separate provider-only compatibility wrappers are unnecessary. Public APIs remain pre-stable, and new output metadata stays required unless an actual consumer needs an accommodation. ## Deferred naming cleanup @@ -31,10 +31,10 @@ These naming changes are deferred, not prerequisites for experimental classifica **Publish aliases or keep duplicate compatibility rows.** Parallel identities need continuing resolution and removal rules and can register the same capability twice. Retaining the established identity avoids that additional mechanism. -**Keep provider-only bundles through additional wrapper packages.** Web and Desktop already provide the required presets. Extra delivery packages would preserve compositions outside the selected compatibility scope and increase catalog and release maintenance. +**Keep provider-only bundles through additional wrapper packages.** Native bundles insert ordinary Host rows that compose into any profile. Extra delivery packages would preserve compositions outside the selected compatibility scope and increase catalog and release maintenance. ## Consequences -The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides, disabled state, and preset replacement. No general alias or automatic profile migration mechanism is introduced. +The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced. The [classification decision](../process/2026-10-05-product-package-classification.md), [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md), and [on-demand discovery decision](2026-10-05-official-on-demand-bundles.md) retain their separate responsibilities. Released Session generations and persisted hook event names remain protected independently of this profile compatibility scope. diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md index 1f513060ea..4b58743cb5 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md @@ -12,9 +12,9 @@ Status: implemented 迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是命名例外唯一手工维护的清单。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的例外会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。 -既有徽章、Ralph 和原生委派行 ID 在对应的 Host 或预设作用域中保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行,完整预设替换仍具有决定权。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。 +既有徽章、Ralph 和原生委派行 ID 作为 Host 行选择器保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。 -兼容性保护覆盖 Web 和 Desktop profile,包括既有原生提供方的包身份、选择状态、配置和认证。Headless、SDK 和 ACP 组合可以要求修改。原生 bundle 使用普通的共享 patch 操作并要求完整预设目标存在;受保护的 profile 不需要单独的仅提供方兼容包装包。公共 API 仍处于稳定前阶段,新增输出元信息保持必填,除非实际消费者需要适配。 +兼容性保护覆盖 Web 和 Desktop profile,包括既有原生提供方的包身份、选择状态、配置和认证。原生 bundle 插入普通 Host 行,因此 Headless、SDK 和 ACP 组合也可以选择它们;不需要单独的仅提供方兼容包装包。公共 API 仍处于稳定前阶段,新增输出元信息保持必填,除非实际消费者需要适配。 ## 暂缓的命名清理 @@ -31,10 +31,10 @@ Status: implemented **发布别名或保留重复兼容行。** 并行身份需要持续维护解析和移除规则,还可能重复注册同一能力。保留既有身份可以避免增加这种机制。 -**通过额外包装包保留仅提供方 bundle。** Web 和 Desktop 已提供所需预设。额外交付包会保留选定兼容范围之外的组合,并增加目录和发布维护成本。 +**通过额外包装包保留仅提供方 bundle。** 原生 bundle 插入普通 Host 行,可组合到任何 profile。额外交付包会保留选定兼容范围之外的组合,并增加目录和发布维护成本。 ## 后果 -仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置、禁用状态和预设替换的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 +仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 [分类决策](../process/2026-10-05-product-package-classification.zh.md)、[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)和[按需发现决策](2026-10-05-official-on-demand-bundles.zh.md)继续承担各自独立的职责。已发布 Session 代际和持久化 hook 事件名的保护独立于此 profile 兼容范围。 diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml new file mode 100644 index 0000000000..7bd206e1f8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-08-desktop-shell-configuration.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-08-desktop-shell-configuration.md +/agent-note-desktop-shell-configuration-independent-of-the-host: + en: 860c8afa57227b89 + zh: bec6a19319050aed +/agent-note-desktop-shell-configuration-independent-of-the-host/problem: + en: 9f652292b62cd015 + zh: 14a97b5a4884c008 +/agent-note-desktop-shell-configuration-independent-of-the-host/decision: + en: e58311d3d5ebb6b6 + zh: b4c791fcfde7b986 +/agent-note-desktop-shell-configuration-independent-of-the-host/alternatives-considered: + en: 349210f3cf5f168b + zh: 019acfc3c3d71b4b +/agent-note-desktop-shell-configuration-independent-of-the-host/consequences: + en: a523f43819051d4b + zh: 69926bc3761195ea diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md new file mode 100644 index 0000000000..9531ff4bff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md @@ -0,0 +1,29 @@ +# Agent Note: Desktop shell configuration independent of the Host + +Status: implemented + +English | [中文](2026-10-08-desktop-shell-configuration.zh.md) + +## Problem + +Desktop updates and native recovery can run before the Host starts or while it is unavailable. Depending on Host configuration prevents these functions from resolving their preferences when needed. A shared settings page does not identify which process owns each preference. + +## Decision + +Electron main owns `userData/desktop/settings.json` independently of Host startup and Cordis evaluation. The test authentication popup switch uses this file; the [Desktop reference](../../../../apps/desktop/README.md#local-desktop-settings) owns its fields and behavior. + +Ownership follows runtime dependencies: Electron-owned functions needing configuration before Host startup or while the Host is unavailable use shell configuration. Host plugin settings, including shared Web/Desktop preferences, continue to use Cordis. Displaying a setting in Desktop alone does not justify shell ownership. A unified settings interface can use separate storage owners. + +The `desktop` subdirectory groups shell configuration without relocating existing files. The [shortcut decision](2026-09-20-device-local-shortcut-preferences.md) retains ownership of `userData/keybindings.json` and its persistence semantics. The [shared Web application decision](2026-09-10-desktop-web-wrapper.md) still governs Host composition and profile configuration. + +## Alternatives considered + +**Put every preference in Cordis.** This requires a working Host. Reading `cordis.patch.yml` directly in Electron duplicates configuration evaluation; a patch alone omits plugin defaults and other configuration layers. + +**Move every setting shown in Desktop.** Interface placement does not imply a Host-independent requirement. This splits shared Web/Desktop preferences and moves configuration away from its consuming plugins. + +## Consequences + +Shell and Host configuration have separate owners. Each setting needs documented ownership and effective timing. Independent shell configuration supports additional Host-independent functions without introducing a second application backend. + +Existing settings are candidates for individual evaluation when their functionality changes or a Host-independent use is required. This is an evaluation direction, not a scheduled migration or predefined list. A migration must identify a single authoritative source, transfer existing values and define when changes take effect, without ambiguous precedence between two stores. Shared Host-managed settings remain in Cordis unless their ownership requirements change. diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md new file mode 100644 index 0000000000..3dac97f51a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Desktop 壳层配置独立于 Host + +Status: implemented + +[English](2026-10-08-desktop-shell-configuration.md) | 中文 + +## Problem + +Desktop 更新和原生恢复可以在 Host 启动前或不可用时运行。依赖 Host 配置会使这些功能无法在需要时解析偏好。统一设置页面不能决定每项偏好由哪个进程负责。 + +## Decision + +Electron 主进程负责 `userData/desktop/settings.json`,不依赖 Host 启动或 Cordis 求值。测试鉴权弹窗开关使用此文件;[Desktop 参考文档](../../../../apps/desktop/README.zh.md#local-desktop-settings)统一说明字段和行为。 + +配置归属按运行依赖划分:由 Electron 负责且需要在 Host 启动前或不可用时读取配置的功能,使用壳层配置。Host 插件设置,包括 Web/Desktop 共用偏好,继续使用 Cordis。仅仅展示在 Desktop 中,不构成归属壳层的理由。统一设置界面可以使用不同的存储归属。 + +`desktop` 子目录集中存放壳层配置,不迁移已有文件。[快捷键决策](2026-09-20-device-local-shortcut-preferences.zh.md)继续负责 `userData/keybindings.json` 及其持久化语义。[共享 Web 应用决策](2026-09-10-desktop-web-wrapper.zh.md)仍约束 Host 组合和 profile 配置。 + +## Alternatives considered + +**所有偏好都放入 Cordis。** 这需要可用的 Host。由 Electron 直接读取 `cordis.patch.yml` 会重复实现配置求值;单独的 patch 不包含插件默认值和其他配置层。 + +**迁移所有在 Desktop 展示的设置。** 界面位置不意味着需要独立于 Host。这会拆分 Web/Desktop 共用偏好,并让配置脱离实际使用它的插件。 + +## Consequences + +壳层与 Host 配置分别有自己的归属。每项设置需说明归属和生效时机。独立壳层配置支持更多不依赖 Host 的功能,但不引入第二套应用后端。 + +现有设置在功能调整或需要独立于 Host 使用时,可逐项评估。这是评估方向,不是已排期的迁移计划或预设清单。实际迁移需明确唯一配置来源、迁移已有值并定义生效时机,避免两套存储间存在含糊的优先级。共用的 Host 设置在归属要求不变时继续使用 Cordis。 diff --git a/apps/cli/tests/expected/dump-config-schema.json b/apps/cli/tests/expected/dump-config-schema.json index 29f03673a0..ce57b82073 100644 --- a/apps/cli/tests/expected/dump-config-schema.json +++ b/apps/cli/tests/expected/dump-config-schema.json @@ -445,13 +445,6 @@ ] } }, - { - "not": { - "required": [ - "preset" - ] - } - }, { "anyOf": [ { @@ -501,13 +494,6 @@ ] } }, - { - "not": { - "required": [ - "preset" - ] - } - }, { "anyOf": [ { @@ -557,13 +543,6 @@ ] } }, - { - "not": { - "required": [ - "preset" - ] - } - }, { "anyOf": [ { @@ -599,11 +578,6 @@ "properties": { "insert": { "$ref": "#/$defs/entryList" - }, - "preset": { - "type": "string", - "pattern": "\\S", - "description": "Outer preset row id. The remaining patch fields apply to its current config.plugins list; a missing or unaddressable preset fails composition." } }, "description": "An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped." diff --git a/apps/cli/tests/fixtures/agent-shutdown.mjs b/apps/cli/tests/fixtures/agent-shutdown.mjs new file mode 100644 index 0000000000..22064e0e55 --- /dev/null +++ b/apps/cli/tests/fixtures/agent-shutdown.mjs @@ -0,0 +1,83 @@ +/** Scope-owned cancellation during shutdown of the shipped Web profile. */ +import { existsSync, writeFileSync } from 'node:fs' +import { setImmediate } from 'node:timers/promises' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' + +export const name = 'agent-shutdown-fixture' +export const inject = ['agents', 'llm', 'appReady', 'appExit'] + +class Adapter extends LlmAdapter { + async resolveModel(provider, model) { + return { provider, id: model, name: model } + } + + async *stream() { + throw new Error('The cancelled request must not reach the adapter.') + } +} + +/** Hold one turn until cancellation and report the state seen by scope cleanup. */ +export function apply(ctx, config) { + ctx.llm.registerAdapter(['shutdown-fixture'], new Adapter()) + const exit = ctx.appExit + ctx.effect(() => ctx.appReady.onReady(() => { + void start(ctx, config).catch(error => { console.error(error); exit(1) }) + })) + if (process.platform === 'win32') { + const timer = setInterval(() => { + if (!existsSync(config.interrupt)) return + clearInterval(timer) + process.emit('SIGTERM') + }, 20) + ctx.effect(() => () => { clearInterval(timer) }) + } +} + +async function start(ctx, config) { + const { agent } = await ctx.agents.create({ + sessionId: 'shutdown-fixture', + agentOptions: { provider: 'shutdown-fixture', model: 'shutdown-fixture' }, + meta: { cwd: config.root }, + }) + const controller = new AbortController() + let abortHandled = 0 + controller.signal.addEventListener('abort', () => { + agent.cancel({ kind: 'parent' }) + agent.cancel({ kind: 'parent' }) + abortHandled++ + }, { once: true }) + agent.ctx.effect(() => () => { + controller.abort() + controller.abort() + const events = agent.session.snapshotEvents() + writeFileSync(config.result, JSON.stringify({ + abortHandled, + status: agent.status, + nextStep: agent.inbox.nextStep, + nextTurn: agent.inbox.nextTurn, + lastEvent: events.at(-1), + cancelled: events.filter(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled'), + })) + }) + const message = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + if (config.running) { + const entered = Promise.withResolvers() + agent.ctx.on('agent/request', async ({ signal }, next) => { + const aborted = Promise.withResolvers() + signal.addEventListener('abort', () => { aborted.resolve() }, { once: true }) + entered.resolve() + await aborted.promise + await setImmediate() + return next() + }) + agent.followup(message('active')) + await entered.promise + } + agent.send(message('pending step'), 'next-step', false) + agent.send(message('pending turn'), 'next-turn', false) + if (config.cancelFirst) { + controller.abort() + await agent.whenIdle() + } + console.log('agent-shutdown: ready') +} diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index 78d0e45813..44d976bed4 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -185,7 +185,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('session_trace') expect(names(native.agent)).toContain('session_search') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) + expect(names(minimal.agent)).toContain('session_search') await turn(coded.agent, 'Read this session lineage through run_code.', [ toolCallResponse('ptc-search', 'run_code', { code: 'console.log(await tools.session_trace({}));', @@ -210,7 +210,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option const foreign = await agent('standard', otherWorkspace) await turn(foreign.agent, 'Keep OPTIONAL_SEARCH_MARKER in the other workspace.', [textResponse('Other marker recorded.')]) await select(bundles.search, true) - expect(names(live.agent)).not.toContain('session_search') + expect(names(live.agent)).toContain('session_search') const searcher = await agent() await turn(searcher.agent, 'Find the previous marker in session history.', [ toolCallResponse('search-history', 'session_search', { query: 'OPTIONAL_SEARCH_MARKER' }), textResponse('SEARCH_DONE'), @@ -220,11 +220,8 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(searcher.agent, 'search-history')).not.toContain(foreign.agent.session.id) expect(toolResult(searcher.agent, 'search-history')).toContain('OPTIONAL_SEARCH_MARKER') await select(bundles.search, false) + expect(names(searcher.agent)).not.toContain('session_search') expect(names((await agent()).agent)).not.toContain('session_search') - await turn(searcher.agent, 'Search the marker again using the retained capability.', [ - toolCallResponse('search-retained', 'session_search', { query: 'OPTIONAL_SEARCH_MARKER' }), textResponse('RETAINED_SEARCH_DONE'), - ]) - expect(toolResult(searcher.agent, 'search-retained')).toContain(seedId) const sessionId = searcher.agent.session.id await searcher.dispose() handles.delete(searcher) @@ -323,10 +320,10 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(titleRequests(restored.agent)[0]?.data.titleProvider).toBe('session-title-first-prompt-llm') }) - it('composes all five bundles while leaving minimal and host tools unchanged', async () => { + it('composes all five bundles as global tools across every preset', async () => { for (const name of Object.values(bundles)) await select(name, true) let coded: Agent | undefined - for (const preset of ['standard', 'cordis', 'ptc']) { + for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { const owner = await agent(preset) expect(names(owner.agent)).toEqual(expect.arrayContaining(['session_search', 'terminal_open', 'ralph'])) if (preset === 'ptc') coded = owner.agent @@ -340,9 +337,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option ]) expect(toolResult(coded, 'combined-program')).toContain(coded.session.id) expect(toolResult(coded, 'combined-program')).toContain('dsh-badge') - const minimal = await agent('minimal') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) - expect(names()).toEqual(['working_directory']) + expect(names()).toEqual(expect.arrayContaining(['session_search', 'terminal_open', 'ralph', 'working_directory'])) for (const name of [...Object.values(bundles)].reverse()) await select(name, false) expect(names((await agent()).agent)).not.toContain('session_search') }) diff --git a/apps/cli/tests/profiles/official-installer-packed.e2e.ts b/apps/cli/tests/profiles/official-installer-packed.e2e.ts index 3062b5c5b8..e9039e8a5a 100644 --- a/apps/cli/tests/profiles/official-installer-packed.e2e.ts +++ b/apps/cli/tests/profiles/official-installer-packed.e2e.ts @@ -158,8 +158,8 @@ it.skipIf(!built)('installs the exported registry\'s local closure and switches expect(step.install, JSON.stringify(step.install)).toMatchObject({ application: 'applied', enabled: true, version }) expect(step.savedVersion).toBe(version) expect(step.installed).toMatchObject({ official: true, availability: 'profile', enabled: true, installed: true, version }) - expect(step.installed.rows.filter(row => row.preset !== undefined).map(row => row.preset).sort()) - .toEqual(['preset-cordis', 'preset-ptc', 'preset-standard']) + const product = step.name.replace('@deepseek-ai/dsh-subagent-', '') + expect(step.installed.rows.map(row => row.rowId)).toEqual([`subagent-${product}`, `tool-subagent-${product}`]) expect(step.providerEnabled).toBe(true) expect(step.nativeArtifact).toBe(true) // A transitive repository package the closure needs can only have come from this run's artifacts. diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6fa2861ce0..db0d8e26e3 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -13,15 +13,12 @@ import { loadProfile, PluginPackages, type Profile, - type ProfilePatch, } from '@deepseek-ai/dsh-app-boot' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest' -import { dump, load } from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { bundlePatchPaths, composeEntries } from '@deepseek-ai/dsh-app-boot' import { createPluginProfile } from '../../desktop/src/project-manager.ts' /** Profile entry ids whose volatile fields these scenarios edit through Settings. */ @@ -63,7 +60,7 @@ const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell */ async function bootWeb( profileHome: string, - extra: ProfilePatch[] = [], + extra: PatchOptions[] = [], profilePackages: readonly string[] = [], profileBundles?: readonly string[], surface: 'web' | 'desktop' | 'spec' = 'spec', @@ -532,165 +529,74 @@ describe('the shipped Web composition', () => { }) }) -describe('product Bundle and user-preset intersection', () => { - const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const - type Product = 'codex' | 'claude-code' - type PresetId = typeof presetIds[number] +describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surface) => { + const products = ['codex', 'claude-code'] as const + type Product = typeof products[number] + const packageDir = (product: Product): string => (product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR) + const toolName = (product: Product): string => `subagent_${product.replaceAll('-', '_')}` - async function bootProducts(installed: readonly Product[]): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) - const definitions: import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] = [] - const standardConfig = composeEntries([webPatches('test')]).find(row => row.id === 'preset-standard')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition - const standard = dump(standardConfig.plugins, { schema: entryListSchema }) - for (const id of presetIds) { - const plugins = load(standard, { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] - // User presets declare their own delegation rows; provider bundles target only the three full shipped presets. - if (id === 'products-codex' || id === 'products-both') { - plugins.push({ id: 'custom-tool-subagent-codex', name: '@deepseek-ai/dsh-tool-subagent', config: { - provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed', - } }) - } - if (id === 'products-claude' || id === 'products-both') { - plugins.push({ id: 'custom-tool-subagent-claude-code', name: '@deepseek-ai/dsh-tool-subagent', config: { - provider: 'claude-code', toolName: 'subagent_claude_code', maxDepth: 'provider-managed', - } }) - } - definitions.push({ id: `preset-${id}`, name: '@deepseek-ai/dsh-agent-preset', config: { id, plugins } }) - } - const packageDir = (product: Product): string => ( - product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR - ) - const packageName = (product: Product): string => ( - product === 'codex' - ? '@deepseek-ai/dsh-subagent-codex' - : '@deepseek-ai/dsh-subagent-claude-code' - ) - return await bootWeb(root, [{ insert: definitions }], installed.map(packageDir), [ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-web-app', - ...installed.map(packageName), - ]) + async function bootSelected(installed: readonly Product[], extra: PatchOptions[] = []): Promise { + const root = await mkdtemp(join(tmpdir(), `dsh-${surface}-native-bundles-`)) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + return await bootWeb(root, extra, installed.map(packageDir), [ + '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...installed.map(product => `@deepseek-ai/dsh-subagent-${product}`), + ], surface) } - it('composes the intersection of installed Bundles and enabled preset rows', async () => { - const enabledByPreset: Record = { - 'products-none': [], - 'products-codex': ['codex'], - 'products-claude': ['claude-code'], - 'products-both': ['codex', 'claude-code'], - } - const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [ - { installed: [], presets: ['products-both'] }, - { installed: ['codex'], presets: ['products-both'] }, - { installed: ['claude-code'], presets: ['products-both'] }, - { installed: ['codex', 'claude-code'], presets: presetIds }, - ] + async function presetTools(productCtx: Context, preset: string): Promise { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`native-${surface}-${preset}-${randomUUID()}`), + setup: owner => productCtx.agentPresets.mount(owner, preset).then(() => undefined), + }) + try { + return toolNames(productCtx, handle.agent).filter(name => /(?:subagent|custom)_(?:codex|claude_code)$/.test(name)) + } finally { await handle.dispose() } + } - for (const { installed, presets } of scenarios) { - const productCtx = await bootProducts(installed) + it.each([[[]], [['codex']], [['claude-code']], [['codex', 'claude-code']]] as Array<[Product[]]>)( + 'adds the selected %j delegation tools to every preset as global tools', async (installed) => { + const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list() - .filter(name => name === 'codex' || name === 'claude-code') - .sort()) + expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) .toEqual([...installed].sort()) - for (const id of presets) { - const handle = await productCtx.agents.create({ - sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), - }) - try { - const productTools = enabledByPreset[id] - .filter(product => installed.includes(product)) - .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code') - .sort() - const tools = toolNames(productCtx, handle.agent) - expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) - .toEqual(productTools) - expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output'])) - for (const productTool of productTools) { - expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([ - 'cwd', 'description', 'prompt', - ]) - } - } finally { - await handle.dispose() - } + const expected = installed.map(toolName).sort() + for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { + expect(await presetTools(productCtx, preset)).toEqual(expected) } + const handle = await productCtx.agents.create({ + sessionId: SessionId(`native-${surface}-params-${randomUUID()}`), + setup: owner => productCtx.agentPresets.mount(owner, 'standard').then(() => undefined), + }) + try { + for (const product of installed) { + expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'prompt']) + } + } finally { await handle.dispose() } expect(spawn).not.toHaveBeenCalled() } finally { spawn.mockRestore() await productCtx.fiber.dispose() } - } - }, 120_000) -}) + }, 120_000) -describe.each(['web', 'desktop'] as const)('%s native bundle adoption', (surface) => { - const products = ['codex', 'claude-code'] as const - const bundleNames = products.map(product => `@deepseek-ai/dsh-subagent-${product}`) - - async function bootSelected(extra: ProfilePatch[]): Promise { - const root = await mkdtemp(join(tmpdir(), `dsh-${surface}-native-adoption-`)) - onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) - return await bootWeb(root, extra, [CODEX_PACKAGE_DIR, CLAUDE_CODE_PACKAGE_DIR], [ - '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...bundleNames, - ], surface) - } - - it('keeps selected provider identities and later tool overrides without duplicating custom preset tools', async () => { - const definition = composeEntries([webPatches('adoption')]).find(row => row.id === 'preset-cordis')! - .config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition - const config = { ...definition, plugins: [...definition.plugins, ...products.map(product => ({ - id: `tool-subagent-${product}`, name: '@deepseek-ai/dsh-tool-subagent', disabled: product === 'codex', - config: { provider: product, toolName: `custom_${product.replaceAll('-', '_')}`, maxDepth: 'provider-managed' }, - }))] } + it('keeps provider identities and applies later Host row overrides', async () => { const providerConfig = { model: 'adoption-model', env: { DSH_NATIVE_ADOPTION: 'preserved' } } - const productCtx = await bootSelected([ + const productCtx = await bootSelected(products, [ ...products.map(product => ({ id: `subagent-${product}`, config: providerConfig })), - { preset: 'preset-standard', id: 'tool-subagent-codex', disabled: true }, - { preset: 'preset-standard', id: 'tool-subagent-claude-code', config: { + { id: 'tool-subagent-codex', disabled: true }, + { id: 'tool-subagent-claude-code', config: { provider: 'claude-code', toolName: 'custom_claude_code', maxDepth: 'provider-managed', } }, - { id: 'preset-cordis', config }, ]) - const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([...products])) for (const product of products) { expect([...productCtx.loader.entries()].find(entry => entry.options.id === `subagent-${product}`)?.options.config) .toMatchObject(providerConfig) } - for (const [preset, expected] of [ - ['standard', ['custom_claude_code']], ['cordis', ['custom_claude_code']], - ['ptc', ['subagent_claude_code', 'subagent_codex']], ['minimal', []], - ] as const) { - const handle = await productCtx.agents.create({ - sessionId: SessionId(`adoption-${surface}-${preset}-${randomUUID()}`), - setup: owner => productCtx.agentPresets.mount(owner, preset).then(() => undefined), - }) - try { - expect(toolNames(productCtx, handle.agent).filter(name => /(?:subagent|custom)_(?:codex|claude_code)/.test(name))) - .toEqual(expected) - } finally { await handle.dispose() } + for (const preset of ['standard', 'ptc', 'minimal']) { + expect(await presetTools(productCtx, preset)).toEqual(['custom_claude_code']) } - expect(spawn).not.toHaveBeenCalled() - } finally { - spawn.mockRestore() - await productCtx.fiber.dispose() - } - }, 120_000) - - it('reports a redundant manual tool instead of silently replacing the bundle contribution', async () => { - const productCtx = await bootSelected([{ preset: 'preset-standard', insert: [{ - id: 'manual-codex', name: '@deepseek-ai/dsh-tool-subagent', config: { - provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed', - }, - }] }]) - try { - expect((await productCtx.agentPresets.list()).find(preset => preset.id === 'standard')?.broken) - .toContain('already registered') - expect(productCtx.profileContext.startedBundles).toEqual(expect.arrayContaining(bundleNames)) } finally { await productCtx.fiber.dispose() } }, 120_000) }) diff --git a/apps/cli/tests/web-agent-shutdown.e2e.ts b/apps/cli/tests/web-agent-shutdown.e2e.ts new file mode 100644 index 0000000000..86ecaa5c75 --- /dev/null +++ b/apps/cli/tests/web-agent-shutdown.e2e.ts @@ -0,0 +1,87 @@ +/** Keyless SIGTERM regression for Agent cancellation during Web teardown. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +it.each([ + { state: 'idle', cancelFirst: false }, + { state: 'running', cancelFirst: false }, + { state: 'idle', cancelFirst: true }, + { state: 'running', cancelFirst: true }, +])('preserves Agent cleanup during Web shutdown ($state, cancel first: $cancelFirst)', { retry: 0 }, async ({ state, cancelFirst }) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-agent-shutdown-')) + try { + const patch = join(root, 'shutdown.patch.yml') + const resultPath = join(root, 'result.json') + const interrupt = join(root, 'interrupt') + await writeFile(patch, [ + '- id: hmr', + ' disabled: true', + '- id: session-title-llm', + ' disabled: true', + '- insert:', + ` - name: ${JSON.stringify(new URL('./fixtures/agent-shutdown.mjs', import.meta.url).href)}`, + ` config: ${JSON.stringify({ root, result: resultPath, interrupt, running: state === 'running', cancelFirst })}`, + '', + ].join('\n')) + const launch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../src/bin.ts', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), + configArgs: ['--profile', 'web', '--patch', patch, '--no-open', '--port', '0'], + env: { + DSH_HOME: join(root, '.dsh'), + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_TELEMETRY_DISABLED: '1', + }, + }) + const child = execa(launch.command, launch.args, { + cwd: root, env: launch.env, stdin: 'pipe', + timeout: 90_000, killSignal: 'SIGKILL', reject: false, + }) + const lines = createInterface({ input: child.stdout }) + try { + const ready = Promise.withResolvers() + lines.on('line', (line) => { + if (line === 'agent-shutdown: ready') ready.resolve(undefined) + }) + await Promise.race([ + ready.promise, + child.then((result) => { throw new Error(`Web exited before readiness: ${result.stderr}`) }), + ]) + if (process.platform === 'win32') await writeFile(interrupt, '') + else child.kill('SIGTERM') + const result = await child + expect(result.timedOut, result.stderr).toBe(false) + expect(result.signal, result.stderr).toBeUndefined() + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stderr).not.toMatch(/dsh: fatal|projection registration is not active/u) + const evidence: unknown = JSON.parse(await readFile(resultPath, 'utf8')) + expect(evidence).toMatchObject({ + abortHandled: 1, + status: 'idle', + nextStep: [], + nextTurn: [], + cancelled: [ + { data: { target: 'next-step', removedCount: 1 } }, + { data: { target: 'next-turn', removedCount: 1 } }, + ], + }) + if (state === 'running') { + expect(evidence).toMatchObject({ + lastEvent: { type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: cancelFirst ? 'parent' : 'disposed' } } } }, + }) + } + } finally { + lines.close() + child.kill('SIGKILL') + await child + } + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 9c95cd3111..5930130c27 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: cfd5780dd26a8e46 - zh: f7a16b1ede793b7b + en: ed94776d1c3b7517 + zh: 9e6b1738b409d5e7 /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 @@ -66,11 +66,14 @@ en: be1379e4b4703e14 zh: 244ccd2874bb155a /deepseek-harness-desktop/updates: - en: 10d58aa5bbffbd67 - zh: d9c2476e744a6537 + en: 41d0f50eb7173342 + zh: ce940ef3cbcc6fa5 +/deepseek-harness-desktop/updates/local-desktop-settings: + en: 2e57457c44c85b5f + zh: 5b8510a3dc4c6c9d /deepseek-harness-desktop/updates/mandatory-update-policy: - en: 1527dceec540649e - zh: 543782c36f91f893 + en: 54596bef0706d305 + zh: 48737302dcf3c939 /deepseek-harness-desktop/updates/local-updater-qualification: en: bc50fd8a4d96c227 zh: fb0a6ce80c31cda6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 57eb2cb333..531ac41368 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -354,6 +354,8 @@ pnpm run package:desktop:win:x64:unsigned The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements. + + ### Windows installer interface The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; drive roots remain invalid after normalization, including repeated trailing separators. New destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running. @@ -445,6 +447,26 @@ If task teardown fails after confirmed Host exit, installation is refused and th Confirmed Host exit without successful task teardown displays localized recovery guidance in both ordinary and mandatory update dialogs. A typed preparation cause selects that guidance in each locale; changing translated wording cannot reclassify the failure. “View technical details” is collapsed by default and exposes only exit status, signal, shutdown acknowledgement, and deadline facts, not plugin stderr. Expanding it neither retries nor authorizes installation. + + +### Local desktop settings + +The [shell configuration decision](../../.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md) defines Host-independent ownership and the criteria for evaluating existing settings for migration. + +The Electron main process reads `app.getPath('userData')/desktop/settings.json` once at startup, independently of Host and Cordis configuration. The default packaged paths are `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` on Windows and `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json` on macOS. Development uses the `userData` path printed by its launcher. `DSH_HOME` does not relocate this file. + +On first launch, including after an upgrade, Desktop creates this document if it is absent: + +```json +{ + "updates": { + "allowTestAuthPopupWindow": false + } +} +``` + +Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). + ### Mandatory update policy @@ -466,7 +488,9 @@ Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=tes Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, DSH_CLIENT_VERSION, bundled dsh version, current locale and UTC offset, an empty bundle ID, and fixed Nightly. It uses no business login credentials or installation ID. -With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually. +With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login only when the local `updates.allowTestAuthPopupWindow` setting is `true`. It defaults to `false`, suppressing the entire authentication dialog flow during startup, manual update checks, mandatory-update refreshes, and deferred or repeated failures. Test gateway cookies are process-local. With popups disabled at startup, a gateway requiring authentication cannot provide new mandatory-update decisions during that process; enable the setting, restart and sign in to obtain them. Policy requests still enforce gateway authentication, and an authentication failure never clears a known mandatory block. Ordinary updates and product account authentication remain independent. Production keeps its anonymous policy requests regardless of this setting. + +When permitted, login is offered during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually. Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d8256885dc..097aa15d4e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -356,6 +356,8 @@ pnpm run package:desktop:win:x64:unsigned 该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。 + + ### Windows 安装界面 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;路径规范化后仍拒绝磁盘根目录,包括带重复末尾分隔符的写法。新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。 @@ -447,6 +449,26 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 已确认 Host 退出但任务未成功收尾时,常规与强更弹窗均展示本地化恢复提示。两种语言都根据类型化的准备失败原因选择提示,翻译文案变化不会改变失败分类。“查看技术详情”默认折叠,仅展示退出状态、信号、关闭确认和截止时间事实,不展示插件 stderr。展开详情既不重试,也不授权安装。 + + +### 本地桌面设置 + +[壳层配置决策](../../.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md)说明独立于 Host 的配置归属,以及评估现有设置迁移的标准。 + +Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/settings.json`,独立于 Host 和 Cordis 配置。打包应用默认路径为 Windows 的 `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` 和 macOS 的 `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json`。开发模式使用启动器打印的 `userData` 路径。`DSH_HOME` 不改变此文件的位置。 + +首次启动(包括升级后首次启动)时,Desktop 会在文件缺失时创建以下内容: + +```json +{ + "updates": { + "allowTestAuthPopupWindow": false + } +} +``` + +已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 + ### 强制更新策略 @@ -468,7 +490,9 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、DSH_CLIENT_VERSION、内置 dsh 版本、当前语言与 UTC 偏移、空 bundle ID 和固定 Nightly。不使用业务登录凭据或安装 ID。 -启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。 +启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应仅在本地 `updates.allowTestAuthPopupWindow` 为 `true` 时提供登录入口。该字段默认为 `false`,禁止启动、手动检查更新、强更刷新以及延迟或重复失败触发的整个鉴权弹窗流程。测试网关 Cookie 仅在当前进程有效。启动时关闭弹窗后,需要鉴权的网关无法在本次进程中提供新的强更决策;需开启设置、重启并登录后才能获取。策略请求仍需网关鉴权,鉴权失败不会解除已知强更阻塞。常规更新和产品账号鉴权保持独立。正式环境始终使用原有匿名策略请求,不受此设置影响。 + +允许弹窗时,用户主动检查和打包应用首次启动检查会提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b4b8153b7f..848f586d06 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -51,6 +51,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopP import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts' import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' +import { readDesktopSettings } from './settings.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' @@ -313,6 +314,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi } async function main(): Promise { + const desktopSettings = readDesktopSettings(app.getPath('userData')) void pruneCrashReports(app.getPath('logs')) const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) @@ -875,6 +877,7 @@ async function main(): Promise { void authenticatePolicy().catch((error: unknown) => { console.error(error) }) } const queuePolicyAuthentication = (): void => { + if (!desktopSettings.updates.allowTestAuthPopupWindow) return if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() return @@ -883,7 +886,8 @@ async function main(): Promise { flushQueuedPolicyAuthentication() } const runPolicyAuthentication = async () => { - if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined + if (!desktopSettings.updates.allowTestAuthPopupWindow + || policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined const parent = mandatoryUI?.confirmationWindow ?? currentDialogWindow() if (parent === undefined) return undefined const consent = await updateDialog.show(parent, { type: 'info', title: locale.messages.policyLoginTitle, @@ -905,7 +909,7 @@ async function main(): Promise { const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => { if (authenticationOperation !== undefined) return authenticatePolicy() const policy = await mandatoryPolicy?.check('manual', true) - if (policy?.error !== 'authentication-required') return policy + if (policy?.error !== 'authentication-required' || !desktopSettings.updates.allowTestAuthPopupWindow) return policy if (authentication === 'immediate') return authenticatePolicy() queuePolicyAuthentication() return policy diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts new file mode 100644 index 0000000000..a4bb67b3bd --- /dev/null +++ b/apps/desktop/src/settings.ts @@ -0,0 +1,57 @@ +/** + * Local Electron shell settings, loaded once per process independently of the Host. + * Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md. + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */ +export interface DesktopSettings { + readonly updates: { + readonly allowTestAuthPopupWindow: boolean + } +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Read shell settings, creating the default document exclusively when missing. + * @param userData - Electron's userData directory, independent of DSH_HOME. + * @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart. + */ +export function readDesktopSettings(userData: string): DesktopSettings { + const directory = join(userData, 'desktop') + const path = join(directory, 'settings.json') + const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } } + try { + let raw: string + try { raw = readFileSync(path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + mkdirSync(directory, { recursive: true, mode: 0o700 }) + try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) { + // An existing document belongs to the user, including one created during initialization. + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + raw = readFileSync(path, 'utf8') + } + const value: unknown = JSON.parse(raw) + if (!record(value) || (value.updates !== undefined && !record(value.updates))) { + throw new Error('expected a JSON object with an optional updates object') + } + const allow = value.updates?.allowTestAuthPopupWindow + if (allow !== undefined && typeof allow !== 'boolean') { + throw new Error('updates.allowTestAuthPopupWindow must be true or false') + } + return { updates: { allowTestAuthPopupWindow: allow ?? false } } + } catch (error) { + try { + console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error) + } catch (_logError) { + // Diagnostic output is best effort; its failure must not make optional settings fatal. + // TODO: Persist settings diagnostics when console output is unavailable, without blocking startup. + } + return defaults + } +} diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json index 822c1a0f30..4e63787b24 100644 --- a/apps/desktop/tests/expected/policy-login-en.json +++ b/apps/desktop/tests/expected/policy-login-en.json @@ -1,4 +1,8 @@ { + "disabled": [ + "Checking for updates…", + "You’re up to date!" + ], "returned": [ "Checking for updates…", "You’re up to date!", diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b6b6fd476f..097c8e5371 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -3,7 +3,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' -import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron' import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts' @@ -371,6 +371,12 @@ vi.mock('../src/welcome-backend.ts', () => ({ }), })) +function configureTestAuthPopup(allow: boolean): void { + const directory = join(harness.app.getPath('userData'), 'desktop') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'settings.json'), JSON.stringify({ updates: { allowTestAuthPopupWindow: allow } })) +} + function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown { const handler = harness.handlers.get(channel) if (handler === undefined) throw new Error(`missing handler ${channel}`) @@ -437,6 +443,19 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + const raw = '{"updates":{"allowTestAuthPopupWindow":"true"}}' + writeFileSync(path, raw) + await readyForUpdate() + expect(harness.hosts).toHaveLength(1) + expect(testAuth.login).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error)) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed })) + expect(readFileSync(path, 'utf8')).toBe(raw) + }) + it('routes shell update documents and assets through the registered main protocol handler', async () => { const root = join(import.meta.dirname, '..') vi.spyOn(harness.app, 'getAppPath').mockReturnValue(root) @@ -531,7 +550,98 @@ describe('desktop main startup', () => { expect(console.error).toHaveBeenLastCalledWith(expect.objectContaining({ message: 'overlay unavailable' })) }) + it.each(['missing-file', 'missing-field', 'disabled'] as const)('suppresses every test auth dialog for %s while continuing update checks', async (configuration) => { + if (configuration === 'disabled') configureTestAuthPopup(false) + if (configuration === 'missing-field') { + configureTestAuthPopup(true) + writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{}') + } + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } + const request = vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + vi.stubGlobal('fetch', request) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'include', redirect: 'error' })) + // Editing the file cannot change the consent policy of an already running process. + configureTestAuthPopup(true) + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + harness.powerMonitor.emit('resume') + await vi.advanceTimersByTimeAsync(10_000) + expect(request.mock.calls.length).toBeGreaterThan(2) + expect(testAuth.login).not.toHaveBeenCalled() + expect(testAuth.focus).not.toHaveBeenCalled() + const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) + const expected = JSON.parse(readFileSync(new URL('./expected/policy-login-en.json', import.meta.url), 'utf8')) as Record + expect(messages).toEqual(expected.disabled) + expect(harness.updateDownload).not.toHaveBeenCalled() + }) + + it('checks updates from the application menu without offering disabled test authentication', async () => { + configureTestAuthPopup(false) + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'] } + const request = vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + vi.stubGlobal('fetch', request) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + request.mockClear() + harness.updateCheck.mockClear() + const checkUpdates = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void + checkUpdates() + await vi.advanceTimersByTimeAsync(10_000) + expect(request).toHaveBeenCalled() + expect(harness.updateCheck).toHaveBeenCalledWith(true) + expect(testAuth.login).not.toHaveBeenCalled() + expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)) + .not.toContain(en.policyLoginRequired) + }) + + it.each([false, true])('retains anonymous production checks with popup permission %s', async (allow) => { + configureTestAuthPopup(allow) + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'anonymous', + allowedPageOrigins: ['https://downloads.example.com'] } + const request = vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + vi.stubGlobal('fetch', request) + await readyForUpdate() + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'omit' })) + expect(testAuth.login).not.toHaveBeenCalled() + expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)) + .not.toContain(en.policyLoginRequired) + }) + + it('keeps a received mandatory decision when test cookies expire with popups disabled', async () => { + configureTestAuthPopup(false) + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } + const request = vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + .mockResolvedValueOnce(Response.json({ code: 40005, data: { show_content: { title: 'Update', detail: 'Required' }, + desktop_app_link: 'https://downloads.example.com/' } })) + vi.stubGlobal('fetch', request) + const host = await readyForUpdate() + await harness.policyBlocked.promise + await vi.advanceTimersByTimeAsync(1000) + const modal = harness.windows[0]! + const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({ + policy: { blocking: true, error: 'authentication-required' }, + }) + expect(testAuth.login).not.toHaveBeenCalled() + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(modal.isDestroyed()).toBe(false) + expect(host.stop).not.toHaveBeenCalled() + }) + it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => { + configureTestAuthPopup(true) harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => @@ -575,6 +685,7 @@ describe('desktop main startup', () => { }) it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => { + configureTestAuthPopup(true) harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 } const request = vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) @@ -602,6 +713,7 @@ describe('desktop main startup', () => { }) it('does not open Feishu when the user declines test login', async () => { + configureTestAuthPopup(true) harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'] } vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))) @@ -625,6 +737,7 @@ describe('desktop main startup', () => { }) it('retains the embedded block and running Host after expired test login is cancelled', async () => { + configureTestAuthPopup(true) harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } const request = vi.fn().mockImplementation(async () => @@ -1557,7 +1670,8 @@ describe('desktop main startup', () => { } }) - it('queues policy authentication until the ordinary result dialog closes', async () => { + it.each([false, true])('handles a deferred authentication failure with popup permission %s', async (allow) => { + configureTestAuthPopup(allow) harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 } const request = vi.fn() @@ -1583,7 +1697,10 @@ describe('desktop main startup', () => { .not.toContain(en.policyLoginRequired) ordinaryResult.resolve({ response: 0 }) await operation - await policyShown.promise + await vi.advanceTimersByTimeAsync(0) + if (allow) await policyShown.promise + else expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)) + .not.toContain(en.policyLoginRequired) expect(testAuth.login).not.toHaveBeenCalled() }) diff --git a/apps/desktop/tests/settings.spec.ts b/apps/desktop/tests/settings.spec.ts new file mode 100644 index 0000000000..b285bb98e9 --- /dev/null +++ b/apps/desktop/tests/settings.spec.ts @@ -0,0 +1,83 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' +import { readDesktopSettings } from '../src/settings.ts' + +vi.mock('node:fs', async importOriginal => ({ ...await importOriginal() })) + +beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) }) +afterEach(() => { vi.restoreAllMocks() }) + +function fixture() { + const userData = mkdtempSync(join(tmpdir(), 'dsh-desktop-settings-')) + onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) }) + const directory = join(userData, 'desktop') + mkdirSync(directory) + return { userData, path: join(directory, 'settings.json') } +} + +it('creates a discoverable default file on first launch without touching other preferences', () => { + const { userData, path } = fixture() + rmSync(join(userData, 'desktop'), { recursive: true }) + writeFileSync(join(userData, 'keybindings.json'), 'existing shortcuts\n') + expect(readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } }) + expect(readFileSync(path, 'utf8')).toBe('{\n "updates": {\n "allowTestAuthPopupWindow": false\n }\n}\n') + expect(readFileSync(join(userData, 'keybindings.json'), 'utf8')).toBe('existing shortcuts\n') +}) + +it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', (raw) => { + const { userData, path } = fixture() + writeFileSync(path, raw) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect(readFileSync(path, 'utf8')).toBe(raw) +}) + +it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', () => { + const { userData, path } = fixture() + for (const allow of [true, false]) { + const raw = JSON.stringify({ updates: { allowTestAuthPopupWindow: allow }, custom: 'preserve' }) + writeFileSync(path, raw) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(allow) + expect(readFileSync(path, 'utf8')).toBe(raw) + } + const previous = readDesktopSettings(userData) + writeFileSync(path, '{"updates":{"allowTestAuthPopupWindow":true}}') + expect(previous.updates.allowTestAuthPopupWindow).toBe(false) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(true) +}) + +it.each(['', '{', 'null', '[]', '{"updates":null}', '{"updates":[]}', + '{"updates":{"allowTestAuthPopupWindow":"false"}}', '{"updates":{"allowTestAuthPopupWindow":1}}', + '{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', (raw) => { + const { userData, path } = fixture() + writeFileSync(path, raw) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error)) + expect(readFileSync(path, 'utf8')).toBe(raw) +}) + +it('reports filesystem errors instead of replacing unreadable settings', () => { + const { userData, path } = fixture() + mkdirSync(path) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error)) + expect(fs.statSync(path).isDirectory()).toBe(true) +}) + +it.each(['mkdirSync', 'writeFileSync'] as const)('continues with disabled popups when %s fails', (operation) => { + const { userData, path } = fixture() + const failure = Object.assign(new Error('settings creation denied'), { code: 'EACCES' }) + vi.spyOn(fs, operation).mockImplementation(() => { throw failure }) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), failure) +}) + +it('continues with defaults even when warning output fails', () => { + const { userData, path } = fixture() + writeFileSync(path, '{') + vi.mocked(console.warn).mockImplementation(() => { throw new Error('output unavailable') }) + expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect(readFileSync(path, 'utf8')).toBe('{') +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 9848c7fb26..7915248243 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -102,6 +102,7 @@ vi.mock('electron', () => ({ vi.mock('../src/tray.ts', () => ({ DesktopTray: class { relabel() {} dispose() {} } })) vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) })) +vi.mock('../src/settings.ts', () => ({ readDesktopSettings: () => ({ updates: { allowTestAuthPopupWindow: false } }) })) vi.mock('../src/login-shell-environment.ts', async importOriginal => ({ ...await importOriginal(), readDesktopLoginShellEnvironment: async (base: NodeJS.ProcessEnv) => ({ environment: base, failures: [] }), diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts index aab040691a..8a21671b29 100644 --- a/apps/web/tests/official-subagent-bundle.e2e.ts +++ b/apps/web/tests/official-subagent-bundle.e2e.ts @@ -100,10 +100,10 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl }) expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === BUNDLE)) .toMatchObject({ official: true, availability: 'profile', enabled: false }) - expect((await scaffold.ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'ptc')?.rows - .some(row => row.entryId === 'tool-subagent-claude-code')).toBe(false) + expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'subagent_claude_code')).toBe(false) expect(await scaffold.ctx.pluginManager.setBundleEnabled(BUNDLE, true)) .toMatchObject({ application: 'applied', changed: true }) + expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'subagent_claude_code')).toBe(true) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) diff --git a/apps/web/tests/standalone-string-editor.e2e.ts b/apps/web/tests/standalone-string-editor.e2e.ts index 79e1efed80..a04dc6e96c 100644 --- a/apps/web/tests/standalone-string-editor.e2e.ts +++ b/apps/web/tests/standalone-string-editor.e2e.ts @@ -1,4 +1,4 @@ -/** A standalone tool in a custom scoped patch reaches the PTC SDK and a persisted Session. */ +/** A standalone global tool from a custom Host patch reaches the PTC SDK and a persisted Session. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -32,8 +32,7 @@ describe('web snapshot: standalone string editor through PTC', () => { compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) - expect((await scaffold.ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'ptc')?.rows - .some(row => row.moduleName === '@deepseek-ai/dsh-tool-str-replace-editor')).toBe(true) + expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'str_replace_editor')).toBe(true) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) diff --git a/apps/web/tests/standalone-string-editor.overlay.yml b/apps/web/tests/standalone-string-editor.overlay.yml index 7522f1800c..78d68e1f1f 100644 --- a/apps/web/tests/standalone-string-editor.overlay.yml +++ b/apps/web/tests/standalone-string-editor.overlay.yml @@ -1,4 +1,3 @@ -- preset: preset-ptc - insert: +- insert: - id: optional-tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ddf79420f0..8206f3e6dc 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -21,8 +21,8 @@ en: 20ef882e44b06a10 zh: c10c3934ed5570e9 /deepseek-harness-architecture/events: - en: 214c3c81c21df6f9 - zh: 75d2cdc3c9fc9930 + en: e89802f066e4955d + zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: en: e6402b28c0ed15fa zh: 6c07be9047b42056 diff --git a/docs/architecture.md b/docs/architecture.md index 5ea69749df..03d88148c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ Events are the extension points, and picking the right domain is the first decis - **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight. - **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop. -AgentLoop awaits serial `agent/created` initialization before starting queued work. Initialization failure rolls back creation; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines teardown ordering. +AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering. The [event map](event-producer-consumer.md) lists every event's producers and consumers. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6978ecf2b1..870fa1c996 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -74,7 +74,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 - **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。 - **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。 -AgentLoop 在启动已排队工作前等待串行 `agent/created` 初始化。初始化失败会回滚创建;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义 teardown 顺序。 +AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。 [事件映射](event-producer-consumer.zh.md)列出每个事件的生产方与消费方。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b5af052eac..b091fa4da4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -92,7 +92,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:292`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -139,7 +139,7 @@ export type Config = PresetDefinition - `inject`: `loader` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts) +- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) ```ts config-catalog /** Registry selection policy. */ @@ -757,7 +757,7 @@ export interface ToolResultPruneConfig { ## `@deepseek-ai/dsh-cordis-host-runner` - `inject`: `tools` -- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:93`](../packages/extensions/cordis-host-runner/src/index.ts) +- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:103`](../packages/extensions/cordis-host-runner/src/index.ts) ```ts config-catalog /** Runner configuration. */ @@ -2360,7 +2360,7 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:44`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7d4ee4e616..399101c45c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -94,7 +94,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:292`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -141,7 +141,7 @@ export type Config = PresetDefinition - `inject`: `loader` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts) +- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) ```ts config-catalog /** Registry selection policy. */ @@ -759,7 +759,7 @@ export interface ToolResultPruneConfig { ## `@deepseek-ai/dsh-cordis-host-runner` - `inject`: `tools` -- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:93`](../packages/extensions/cordis-host-runner/src/index.ts) +- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:103`](../packages/extensions/cordis-host-runner/src/index.ts) ```ts config-catalog /** Runner configuration. */ @@ -2362,7 +2362,7 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:44`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5adc51ba90..d070d6fd23 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:56`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -56,9 +56,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:270`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:277`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 0133716910..4d62fcfea2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -29,7 +29,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:56`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -58,9 +58,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:270`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:277`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 63a00c948d..815b1d99b6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1159,7 +1159,6 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval - pkg_plugin_manager --> pkg_agent_preset_registry pkg_plugin_manager --> pkg_brand pkg_plugin_manager --> pkg_hmr pkg_plugin_manager --> pkg_host_plugin_inventory @@ -1729,7 +1728,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9b3a1e2036..5002603ea9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1161,7 +1161,6 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval - pkg_plugin_manager --> pkg_agent_preset_registry pkg_plugin_manager --> pkg_brand pkg_plugin_manager --> pkg_hmr pkg_plugin_manager --> pkg_host_plugin_inventory @@ -1731,7 +1730,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 52d584bd1f..5d10d9774d 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: a10d3f358e9b7c8e - zh: 28b0f44589a5020d + en: a1412da0219c2a09 + zh: b6869fd548f4c693 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index be79a823a3..049049957a 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -6,8 +6,6 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p ## Management records -`ProfilePatch` adds an optional literal `preset` row id to native Include patch fields; `id` then targets a child in that preset. [App-boot](../../packages/boot/app-boot/README.md#profiles) owns compilation, ordering, validation, and path resolution. - `PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id. `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. @@ -32,7 +30,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.configEditor` — `ConfigEditor` -Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader. +Persist complete raw configs and apply them through the normal Loader path. ```ts cordis-catalog /** Addressable profile rows; nested Includes have independent configuration ownership. @@ -116,7 +114,7 @@ Manage profile files and apply their declared reload lifecycle. * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ -@Remote async listBundles(): Promise +@Remote listBundles(): Promise /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 93024b965a..b2b800f3c9 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -6,8 +6,6 @@ ## 管理记录 -`ProfilePatch` 在原生 Include patch 字段上增加可选的字面量 `preset` 条目 id;此时 `id` 指向该 preset 内的子条目。[App-boot](../../packages/boot/app-boot/README.zh.md#profiles) 定义编译、顺序、校验与路径解析规则。 - `PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。 `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 @@ -32,7 +30,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.configEditor` — `ConfigEditor` -Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader. +Persist complete raw configs and apply them through the normal Loader path. ```ts cordis-catalog /** Addressable profile rows; nested Includes have independent configuration ownership. @@ -116,7 +114,7 @@ Manage profile files and apply their declared reload lifecycle. * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ -@Remote async listBundles(): Promise +@Remote listBundles(): Promise /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now. diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 665df11563..c6c02c6c42 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -497,12 +497,12 @@ async register(definition: PresetDefinition): Promise<() => Promise> /** Inspect retained revisions, or the exact revision an Agent joined. * @param ctx - optional Agent context; omission includes all retained revisions. - * @returns detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list. + * @returns detached module references and isolation diagnostics; no match returns an empty list. */ inspectCompositions(ctx?: Context): AgentPresetInspection[] /** Read every declared preset, including activation failures. - * @returns Display metadata, declaring Loader row identities, and loading diagnostics. + * @returns Display metadata and loading diagnostics. */ async list(): Promise diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 44ee6d3605..582a817f1f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -507,12 +507,12 @@ async register(definition: PresetDefinition): Promise<() => Promise> /** Inspect retained revisions, or the exact revision an Agent joined. * @param ctx - optional Agent context; omission includes all retained revisions. - * @returns detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list. + * @returns detached module references and isolation diagnostics; no match returns an empty list. */ inspectCompositions(ctx?: Context): AgentPresetInspection[] /** Read every declared preset, including activation failures. - * @returns Display metadata, declaring Loader row identities, and loading diagnostics. + * @returns Display metadata and loading diagnostics. */ async list(): Promise diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 699b0148a6..207af94795 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -193,7 +193,8 @@ async refresh(session: Session, signal?: AbortSignal): Promise/icon` when that field is omitted; a subpath uses `/icon`. An icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. `ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata and icons for offline discovery; `pnpm gen-official-bundle-catalog` explicitly updates generated content, and `verify-official-bundle-catalog` checks resource completeness and freshness; builds consume the committed catalog without importing provider code. The catalog contains no install target: [Plugin Manager](../plugin-manager/README.md#use-this-package) derives it from the running version and installation and uses the ordinary bundle installer. The [product-use check](../../../scripts/verify-product-use.ts) composes shipped and on-demand selections against Web without executing providers. @@ -107,7 +91,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi ### Startup and reload failures -Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Active root rows that disappear or become literally disabled release their resources before replacement rows activate, so a singleton provider can drain in-flight work before its successor registers. Compilation and disposal checks use the same parsed root entries for each reconciliation. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. +Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 236c59d522..5199c9a23d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -62,21 +62,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- 启用的 `dsh-hmr` 插件会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的配置写入串行化;包操作在其队列之外执行。启动器不安装 HMR 或监视器;HMR 被禁用或不存在时,更改需要重启。 -插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目、其嵌套分组和插入的 preset 定义中的绝对路径,以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 - -profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch 字段应用到该条目的字面量 `config.plugins` 列表中。任何 bundle 或用户层都可以提供这些操作。每个操作作用于当前子条目列表;较晚的用户操作优先,而较晚的整个 preset `config` 替换会覆盖此前所有子条目贡献。普通外层 patch 保留 Include 的单次索引查找语义:替换 group 的 `config` 所引入的子条目无法作为外层目标。外层 preset 目标缺失或格式错误、插入造成子条目 id 重复都会使组合失败。内层 `id` 不匹配时只警告并跳过,因此禁用 bundle 不会使用户保留的子条目覆盖失效。 - -```yaml -- preset: preset-standard - insert: - - id: optional-string-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' -- preset: preset-standard - id: optional-string-editor - disabled: true -``` - -`ProfilePatch` 在原生 patch 字段上增加 `preset`。`applyProfilePatches` 组合这些操作;`compileProfilePatches` 返回普通 Include patch。启动、profile 重载、有效配置 dump、schema 检查及兼容性预检共用此编译器。限定到 preset 的 `insert` 模块路径以声明它的 patch 所在目录为基准,包括嵌套分组;`!!js` 保持不求值,直至对应子条目激活。原生被 Include 引入文件的 patch 列表仍使用普通 Include 语法。 读取的作用域插入行若未声明 ID,会依据声明文件的规范路径及条目位置获得稳定的内部 ID,嵌套组内的行也如此;普通匿名根行保留 Loader 的既有行为。生成的 ID 用于在重载和保留代际之间识别贡献项,是实现细节,不是作者应在 patch 中引用的名称。编译失败会标明声明 patch 文件和操作序号,profile 层合并后也保留这些信息。 +插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。 @@ -98,8 +84,6 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch `resolvePluginResource(specifier, parentURL)` 通过活动的 Node ESM 解析器将插件模块或导出资源解析为本地文件路径,不执行其代码。解析器不可用或资源无法解析为本地文件时会抛出错误。 -`realModuleFile(path)` 在 Node 和打包可执行程序中规范化已有模块资源的路径。它跟随物理文件符号链接,并通过规范化后的所在目录保留归档资源。资源不存在或无法访问时抛出错误。 - 使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。包根标识缺失的 locale 字段回退到可访问的 `package.json`;子路径标识从不读取 `package.json`。格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会加载图片 data URL:包根使用清单 `icon`,省略该字段时使用 `<包名>/icon`;子路径使用 `<标识>/icon`。图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 `ON_DEMAND_BUNDLES` 列出仅按需安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包拥有的本地化元信息和图标,支持离线发现;`pnpm gen-official-bundle-catalog` 显式更新生成内容,`verify-official-bundle-catalog` 检查资源完整性与新鲜度;构建只读取已提交的目录,不导入提供者代码。目录不保存安装目标;[插件管理器](../plugin-manager/README.zh.md#use-this-package)根据当前版本和安装位置确定目标,并使用普通组合包安装器。[产品用途检查](../../../scripts/verify-product-use.ts)将随附和按需选择与 Web 组合,但不执行提供者。 @@ -107,7 +91,7 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch ### 启动与重载失败 -profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。消失或被字面量禁用的活跃根条目先释放资源,再激活替代条目,因此单实例提供方可以先排空进行中的工作,再让后继者注册。每次重组时,编译与释放检查使用同一份已解析的根条目。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 +profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 diff --git a/packages/boot/app-boot/src/compatibility-preflight.ts b/packages/boot/app-boot/src/compatibility-preflight.ts index 1e33abe184..5b4589b117 100644 --- a/packages/boot/app-boot/src/compatibility-preflight.ts +++ b/packages/boot/app-boot/src/compatibility-preflight.ts @@ -7,7 +7,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { load } from 'js-yaml' -import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts' import { resolvePluginResource } from './package-meta.ts' import { barePackageName } from './profile-resolution/resolver.ts' import type {} from './profile-resolution/service.ts' @@ -179,10 +178,10 @@ function preflight( * @returns One prepared insertion patch for profiles, or the original patches for non-profile callers. */ export function prepareProfilePatches( - ctx: Context, patches: ProfilePatch[], parentURL: string, binName = 'dsh', + ctx: Context, patches: PatchOptions[], parentURL: string, binName = 'dsh', ): PatchOptions[] { if (ctx.get('profileContext') === undefined) return patches - const entries = applyProfilePatches([], patches, patchWarning(ctx)) + const entries = applyEntryPatches([], patches, patchWarning(ctx)) const rows = prepareProfileEntries(ctx, entries, parentURL, binName) return rows.length === 0 ? [] : [{ insert: rows }] } diff --git a/packages/boot/app-boot/src/config-schema/document.ts b/packages/boot/app-boot/src/config-schema/document.ts index 768fb3c152..70428c6671 100644 --- a/packages/boot/app-boot/src/config-schema/document.ts +++ b/packages/boot/app-boot/src/config-schema/document.ts @@ -143,23 +143,13 @@ export async function buildConfigSchemaDocument( required: ['id'], properties: { id: { const: id } }, allOf: [ { not: { required: ['insert'] } }, - { not: { required: ['preset'] } }, { anyOf: [{ not: { required: ['name'] } }, { properties: { name: { enum: ['', target.name] } } }] }, ], }, then: { properties: { config: { $ref: configRef } } }, }) } - definitions.patch = { - ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules], - properties: { - insert: ref('entryList'), - preset: { - type: 'string', pattern: '\\S', - description: 'Outer preset row id. The remaining patch fields apply to its current config.plugins list; a missing or unaddressable preset fails composition.', - }, - }, - } + definitions.patch = { ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules] } const positions = new Map(entries.map((entry, index) => [entry.path, index])) diagnostics.sort((left, right) => (positions.get(left.path ?? '') ?? -1) - (positions.get(right.path ?? '') ?? -1)) return { diff --git a/packages/boot/app-boot/src/config-schema/index.ts b/packages/boot/app-boot/src/config-schema/index.ts index e515c961f0..44bb553249 100644 --- a/packages/boot/app-boot/src/config-schema/index.ts +++ b/packages/boot/app-boot/src/config-schema/index.ts @@ -1,6 +1,6 @@ /** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */ -import type { ProfilePatch } from '../profile-patches.ts' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts' import { collectConfigSchemas } from './collect.ts' import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts' @@ -20,7 +20,7 @@ export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts' */ export async function generateConfigSchema( profile: Profile, - layers: readonly ProfilePatch[][], + layers: readonly PatchOptions[][], installAnchor: string, ): Promise { const diagnostics: ConfigSchemaDiagnostic[] = [] diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 38abd1f256..e042c4e458 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -13,7 +13,7 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' @@ -25,12 +25,9 @@ export { setProfileVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' -import { applyProfilePatches, compileProfilePatches, profilePatchPreset, prepareLoadedProfilePatches, type ProfilePatch } from './profile-patches.ts' -export { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch, type ProfilePatchWarning } from './profile-patches.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta, resolvePluginResource } from './package-meta.ts' export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatalogEntry } from './official-bundles.ts' -export { realModuleFile } from './profile-resolution/legacy-links.ts' export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' export { isNativeConfigSchema } from './config-schema/native.ts' @@ -276,7 +273,7 @@ const userPatchesSchema = entryListSchema * @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject. */ export async function reconcileProfilePatches( - ctx: Context, patches: ProfilePatch[], binName: string, requiredIds: readonly string[] = [], + ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [], ): Promise { const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) @@ -290,9 +287,7 @@ export async function reconcileProfilePatches( const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config // The recomposition judges the rows the launch judged, resolved from the file this Include read. const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href - const initial = readRootEntries(includeConfig.path, parentURL, binName) - const prepared = prepareRootPatches(ctx, patches, includeConfig.path, parentURL, binName, initial) - await drainRetiringEntries(ctx, entry, initial, prepared) + const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) await entry.update({ config: { ...includeConfig, patches: prepared } }) const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) await ctx.loader.await() @@ -308,35 +303,10 @@ export async function reconcileProfilePatches( return failures.map(inactiveDiagnostic) } -/** Disappearing and literal-disabled rows release singleton services before their replacements activate. */ -async function drainRetiringEntries( - ctx: Context, root: Entry, initial: EntryOptions[], patches: readonly ProfilePatch[], -): Promise { - const rows = applyProfilePatches(initial, patches) - const next = new Map() - const visit = (rows: EntryOptions[], outerDisabled = false): void => { - for (const row of rows) { - const disabled = outerDisabled || row.disabled === true - next.set(row.id, !row.group && disabled) - if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], disabled) - } - } - visit(rows) - const retiring = [...ctx.loader.entries()].flatMap((entry) => { - const fiber = entry.fiber - return entry.parent.tree === root.subtree && fiber?.state === FIBER_ACTIVE - && (!next.has(entry.options.id) || next.get(entry.options.id)) ? [fiber] : [] - }) - await Promise.all(retiring.map(async (fiber) => { - await fiber.dispose() - await fiber.await() - })) -} - /** - * Load an optional patch-list file: a top-level YAML array of profile patch - * entries: id-targeted overrides, `insert` lists, and preset-scoped operations, - * with `!!js` expressions allowed. A missing + * Load an optional patch-list file: a top-level YAML array of loader patch + * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config + * overrides and `insert` lists, with `!!js` expressions allowed. A missing * file means "no layer"; an unreadable, unparsable, or non-array file throws — * a present patch file that cannot apply is a misconfiguration and must fail * loud at boot, never be silently skipped. @@ -344,7 +314,7 @@ async function drainRetiringEntries( * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ -export function loadOptionalPatches(binName: string, file: string): ProfilePatch[] | undefined { +export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { let content: string try { content = readFileSync(file, 'utf8') @@ -364,7 +334,7 @@ export function loadOptionalPatches(binName: string, file: string): ProfilePatch * @param file - absolute path of the overlay file. * @returns the parsed patch list. */ -export function loadOverlayPatches(binName: string, file: string): ProfilePatch[] { +export function loadOverlayPatches(binName: string, file: string): PatchOptions[] { let content: string try { content = readFileSync(file, 'utf8') @@ -375,30 +345,24 @@ export function loadOverlayPatches(binName: string, file: string): ProfilePatch[ } /** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */ -function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): ProfilePatch[] { +function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] { const base = dirname(resolve(file)) const visit = (entry: EntryOptions): void => { if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) { entry.name = pathToFileURL(resolve(base, entry.name)).href } if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit) - const config = entry.config as { plugins?: unknown } | undefined - if (entry.name === '@deepseek-ai/dsh-agent-preset' && Array.isArray(config?.plugins)) { - (config.plugins as EntryOptions[]).forEach(visit) - } - } - for (const patch of patches) { - patch.insert?.forEach(visit) } + for (const patch of patches) patch.insert?.forEach(visit) return patches } /** * Parse one loader patch list: a top-level YAML array of - * profile patches (id-targeted overrides, `insert` lists, and preset-scoped - * operations, with `!!js` expressions allowed). Invalid lists and malformed - * preset targets throw because they cannot be applied. An ordinary or scoped - * child patch whose target is absent stays a per-entry Loader warning; a - * missing outer preset target fails composition. + * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and + * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, + * because a patch file that cannot be applied at all is a misconfiguration; a + * single patch whose target row is absent stays a per-entry Loader warning, so + * one overlay shared across surfaces does not have to match every tree. * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. @@ -407,7 +371,7 @@ function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): Profi */ function parsePatchList( binName: string, file: string, content: string, label: string, -): ProfilePatch[] { +): PatchOptions[] { let parsed: unknown try { parsed = yaml.load(content, { schema: userPatchesSchema }) @@ -422,7 +386,7 @@ function parsePatchList( throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) - return anchorInsertedPluginNames(prepareLoadedProfilePatches(parsed as ProfilePatch[], file, binName), file) + return anchorInsertedPluginNames(parsed as PatchOptions[], file) } /** One overlay patch list with the source label printed in dump comments. */ @@ -430,13 +394,13 @@ export interface ConfigDumpLayer { /** Source name shown in dump comments (a file basename or path). */ label: string /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ - patches: ProfilePatch[] + patches: PatchOptions[] } /** * Compose the configured entry list: parse the base config file with the - * include's entry-list dialect, compile preset operations, then apply the - * flattened native patch list through Include's `applyEntryPatches` — the + * include's entry-list dialect, apply every layer's patches as ONE flattened + * list through the include's own patch algorithm (`applyEntryPatches`) — the * same call `boot()` makes, so even patch-visibility corner cases (a later * layer targeting a group child a plain config replacement introduced, which * the single-pass id index never sees) compose identically — then render the @@ -456,8 +420,8 @@ export interface ConfigDumpLayer { * A patch that matches no row is reported through `warn` with its layer * label, mirroring the Loader's boot-time warning. Earlier layers' patches * see an identical preceding state in every snapshot that includes them, so - * additional warnings belong to the added layer. Warning multiplicity is - * preserved when scoped and ordinary diagnostics have different ordering. + * each snapshot's warning list extends the previous one and the new tail + * belongs to the added layer. * @param binName - the diagnostic prefix on read/parse errors. * @param absoluteConfigPath - the base config file `boot()` would include. * @param layers - overlay layers in application order (later wins). @@ -490,14 +454,16 @@ export function renderConfigDump( // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. - const base = parsed as EntryOptions[] + const base = parsed as Parameters[0] // snapshot_k = ONE application of layers 1..k flattened, using the exact // arguments boot passes for that prefix. snapshot_N is the mounted composition. - // The compiler clones patch objects for each snapshot: native insertions retain - // references, so sharing them would let later snapshots mutate earlier results. - const snapshot = (count: number, warnings: string[]): EntryOptions[] => { - const flattened = layers.slice(0, count).flatMap(layer => layer.patches) - return applyProfilePatches(base, flattened, (message: string, ...args: unknown[]) => { + // The patches are cloned per call: applyEntryPatches detaches the entry + // list but pushes `insert` rows by reference from the patch list, so + // sharing patch objects across snapshot calls would leak a later + // snapshot's mutations into an earlier one's result. + const snapshot = (count: number, warnings: string[]): ReturnType => { + const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) + return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { // The include logs through cordis's printf-style logger (`%C` = code); a // dump has no logger, so substitute inline for a plain line. let index = 0 @@ -514,11 +480,8 @@ export function renderConfigDump( if (layer === undefined) continue const warnings: string[] = [] composed = snapshot(count, warnings) - const remaining = [...previousWarnings] - for (const line of warnings) { - const previousIndex = remaining.indexOf(line) - if (previousIndex >= 0) remaining.splice(previousIndex, 1) - else warn(`${binName}: [${layer.label}] ${line}`) + for (const line of warnings.slice(previousWarnings.length)) { + warn(`${binName}: [${layer.label}] ${line}`) } const before = previous.map(entry => JSON.stringify(entry)) for (let index = 0; index < composed.length; index += 1) { @@ -562,22 +525,6 @@ function groupedDump( return lines.join('\n') + '\n' } -/** Compile profile operations before passing an ordinary patch list to the root Include. */ -function readRootEntries(configPath: string, parentURL: string, binName: string): EntryOptions[] { - const parsed: unknown = yaml.load(readFileSync(new URL(configPath, parentURL), 'utf8'), { schema: entryListSchema }) - if (!Array.isArray(parsed)) throw new Error(`${binName}: config ${configPath} must be a top-level array of entries`) - return parsed as EntryOptions[] -} - -function prepareRootPatches( - ctx: Context, patches: ProfilePatch[], configPath: string, parentURL: string, binName: string, initial?: EntryOptions[], -): ProfilePatch[] { - if (ctx.get('profileContext') !== undefined) return prepareProfilePatches(ctx, patches, parentURL, binName) - if (!patches.some(patch => profilePatchPreset(patch) !== undefined)) return patches - return compileProfilePatches(initial ?? readRootEntries(configPath, parentURL, binName), patches, - (message, ...args) => { ctx.logger.warn(message, ...args) }) -} - /** * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. @@ -593,7 +540,7 @@ function prepareRootPatches( export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, - patches: readonly ProfilePatch[] = [], + patches: readonly PatchOptions[] = [], bareModuleBaseUrl?: string, binName = 'dsh', ): Promise { @@ -621,7 +568,7 @@ export async function mountRootInclude( // diagnostics unstable across runs (and snapshot fixtures). // The launcher's own copy is prepared here: compatibility decisions must be made before the root // Include imports anything, and they change no profile patch layer, manifest, or bundle list. - const prepared = prepareRootPatches(ctx, [...patches], pathToFileURL(absoluteConfigPath).href, pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName) + const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName) const includeConfig: Include.Config = { path: pathToFileURL(absoluteConfigPath).href, ...prepared.length > 0 ? { patches: prepared } : {}, @@ -1027,7 +974,7 @@ export async function auditStartupEntries( export async function boot( binName: string, absoluteConfigPath: string, - patches?: ProfilePatch[], + patches?: PatchOptions[], prepare?: (ctx: Context) => Promise | void, bareModuleBaseUrl?: string, ): Promise { diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts index 4f299ae699..328b543e00 100644 --- a/packages/boot/app-boot/src/profile-context.ts +++ b/packages/boot/app-boot/src/profile-context.ts @@ -2,7 +2,7 @@ import { join } from 'node:path' import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Profile } from './profile.ts' import { loadOptionalPatches } from './index.ts' -import { cloneProfilePatches, type ProfilePatch } from './profile-patches.ts' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' /** Application-owned package manager executable; environment applies only to package operations. */ export interface ProfilePnpmInvocation { @@ -24,7 +24,7 @@ export interface ProfileContext { /** Bundle packages used to start this process, before any persisted edits. */ readonly startedBundles: readonly string[] /** Parsed command-line overlays, applied above profile and home patches. */ - readonly overlays: readonly ProfilePatch[] + readonly overlays: readonly PatchOptions[] /** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */ readonly telemetryDisabledEnv: string | undefined } @@ -49,7 +49,7 @@ const TELEMETRY_ROW_ID = 'session-telemetry-otel' * @param hasRow - whether the composition carries the telemetry row. * @returns the disable patch, or `undefined` when no hard-disable patch is required. */ -export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): ProfilePatch | undefined { +export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { if ((disabledEnv ?? '') === '' || !hasRow) return undefined return { id: TELEMETRY_ROW_ID, disabled: true } } @@ -60,9 +60,9 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @param initialProfile Already loaded startup profile; omitted reads the current files. * @returns Detached ordered patches; this function does not update the Loader. */ -export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): ProfilePatch[] { +export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) - const patches = cloneProfilePatches([ + const patches = structuredClone([ ...profile.layers.flatMap(layer => layer.patches), ...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []), ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), diff --git a/packages/boot/app-boot/src/profile-patches.ts b/packages/boot/app-boot/src/profile-patches.ts deleted file mode 100644 index d7f34ab539..0000000000 --- a/packages/boot/app-boot/src/profile-patches.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** Compile preset-scoped profile operations to the native Include patch language. */ - -import { createHash } from 'node:crypto' -import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import { realModuleFile } from './profile-resolution/legacy-links.ts' - -/** A native entry patch, optionally applied inside one preset row's `config.plugins`. */ -export interface ProfilePatch extends PatchOptions { - /** Literal id of an addressable outer preset row; `id` then addresses one of its child rows. */ - preset?: string -} - -interface PatchSource { readonly binName: string; readonly file: string; readonly operation: number } -const patchSources = new WeakMap() - -function sourcedError(patch: ProfilePatch, error: unknown): unknown { - const source = patchSources.get(patch) - return source === undefined ? error : new Error( - `${source.binName}: ${error instanceof Error ? error.message : String(error)} in ${source.file} (patch ${source.operation + 1})`, - { cause: error }, - ) -} - -/** Clone patch data while preserving the file attribution held outside its serialized fields. - * @param patches Complete ordered patches, possibly loaded from files. - * @returns Detached patches with the same diagnostic sources and shared-reference relationships. - */ -export function cloneProfilePatches(patches: readonly ProfilePatch[]): ProfilePatch[] { - const copied = structuredClone([...patches]) - const sources = patches.map(patch => patchSources.get(patch)) - copied.forEach((patch, index) => { - const source = sources[index] - if (source !== undefined) patchSources.set(patch, source) - }) - return copied -} - -/** Attach source diagnostics and stable internal identities to parsed scoped insertions. - * @param patches Parsed file patches, modified before they reach any composition consumer. - * @param file Declaring patch file; its canonical path distinguishes anonymous contributions. - * @param binName Diagnostic prefix. - * @returns The same patches, with anonymous scoped rows identified by file and position. - * @throws When a preset target is malformed, naming its declaring file and operation. - */ -export function prepareLoadedProfilePatches(patches: ProfilePatch[], file: string, binName: string): ProfilePatch[] { - const canonical = realModuleFile(file) - patches.forEach((patch, operation) => { - patchSources.set(patch, { binName, file, operation }) - try { - if (profilePatchPreset(patch) === undefined || patch.insert === undefined) return - const identify = (rows: EntryOptions[], parent: readonly number[]): void => { - rows.forEach((row, index) => { - const position = [...parent, index] - if (!row.id) row.id = `__dsh_profile_${createHash('sha256').update(JSON.stringify([canonical, operation, position])).digest('hex').slice(0, 32)}` - if (row.group && Array.isArray(row.config)) identify(row.config as EntryOptions[], position) - }) - } - identify(patch.insert, []) - } catch (error) { - throw sourcedError(patch, error) - } - }) - return patches -} - -/** Native Include diagnostic arguments. - * @param message Diagnostic format; `%C` marks a value rendered as code. - * @param args Values substituted into the diagnostic format. - */ -export type ProfilePatchWarning = (message: string, ...args: unknown[]) => void - -function visitRows(rows: readonly EntryOptions[], visit: (row: EntryOptions) => void): void { - for (const row of rows) { - visit(row) - if (row.group && Array.isArray(row.config)) visitRows(row.config as EntryOptions[], visit) - } -} - -/** Validate the profile-only target while keeping native patch fields under Include's existing rules. - * @param patch Parsed or programmatically supplied profile patch. - * @returns The literal outer row id, or undefined for an ordinary patch. - * @throws When a declared preset target is not a nonempty literal string. - */ -export function profilePatchPreset(patch: ProfilePatch): string | undefined { - if (!Object.hasOwn(patch, 'preset')) return undefined - if (typeof patch.preset !== 'string' || patch.preset.trim() === '') { - throw new Error('profile patch preset must be a nonempty literal row id') - } - return patch.preset -} - -function targetOf(initial: EntryOptions[], patches: PatchOptions[], preset: string): EntryOptions { - // The native index retains replaced-away children and never indexes replacement-created children. - // A marker override locates its actual reachable target without rebuilding that index from the result. - const marker = {} - const rows = applyEntryPatches(initial, [...structuredClone(patches), { id: preset, inject: marker }], () => {}) - let target: EntryOptions | undefined - visitRows(rows, (row) => { if (row.inject === marker) target = row }) - if (target === undefined) throw new Error(`profile patch preset ${JSON.stringify(preset)} is missing or not addressable`) - const config: unknown = target.config - if (config === null || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(Reflect.get(config, 'plugins'))) { - throw new Error(`profile patch preset ${JSON.stringify(preset)} must have a literal config.plugins entry list`) - } - return target -} - -function assertInsertedIds(preset: string, rows: readonly EntryOptions[]): void { - const ids = new Set() - visitRows(rows, (row) => { - if (!row.id) return - if (ids.has(row.id)) throw new Error(`profile patch preset ${JSON.stringify(preset)} has duplicate entry id ${JSON.stringify(row.id)}`) - ids.add(row.id) - }) -} - -/** Lower preset operations to native whole-config replacements without changing native root patch indexing. - * Each scoped operation sees the then-current preset children. Inputs remain unchanged. - * @param initial Root entries before profile patches. - * @param patches Complete ordered profile patch list. - * @param warn Diagnostic sink for scoped child patches; ordinary patches report when Include applies the result. - * @returns Detached native patches, suitable for the root Include. - * @throws For malformed or unaddressable preset targets and duplicate inserted child ids. - */ -export function compileProfilePatches( - initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {}, -): PatchOptions[] { - const lowered: PatchOptions[] = [] - for (const patch of cloneProfilePatches(patches)) { - try { - const preset = profilePatchPreset(patch) - if (preset === undefined) { - lowered.push(patch) - continue - } - const target = targetOf(initial, lowered, preset) - const config = target.config as Record & { plugins: EntryOptions[] } - const { preset: _preset, ...operation } = patch - const plugins = applyEntryPatches(config.plugins, [operation], (message: string, ...args: unknown[]) => { - warn(`preset ${JSON.stringify(preset)}: ${message}`, ...args) - }) - if (operation.insert !== undefined) assertInsertedIds(preset, plugins) - lowered.push({ id: preset, config: { ...config, plugins } }) - } catch (error) { - throw sourcedError(patch, error) - } - } - return lowered -} - -/** Compose root entries and profile patches with the same compiler used for startup and reload. - * @param initial Root entries before profile patches. - * @param patches Complete ordered profile patch list. - * @param warn Diagnostic sink for unmatched ordinary and scoped child patches. - * @returns The effective entry list, with expressions unevaluated. - * @throws For malformed or unaddressable preset targets and duplicate inserted child ids. - */ -export function applyProfilePatches( - initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {}, -): EntryOptions[] { - return applyEntryPatches(initial, compileProfilePatches(initial, patches, warn), warn) -} diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e8aee3ce21..cbece01be2 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -25,12 +25,12 @@ import { createRequire } from 'node:module' import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve, sep } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest' import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts' import { readProfileVersionExemptions } from './profile-compatibility.ts' import { loadOverlayPatches } from './index.ts' -import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts' import { realModuleDirectory } from './profile-resolution/legacy-links.ts' /** Directory under the Harness home holding every profile. */ @@ -83,7 +83,7 @@ export interface ProfileLayer { /** Absolute paths of the bundle's patch files, in application order. */ patchPaths: readonly string[] /** The parsed patch lists of every file, concatenated in application order. */ - patches: ProfilePatch[] + patches: PatchOptions[] } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -97,7 +97,7 @@ export interface Profile { /** Absolute path of the profile's own patch file. */ patchPath: string /** The profile's own patches; empty when the file is absent. */ - patches: ProfilePatch[] + patches: PatchOptions[] /** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */ skippedBundles: SkippedBundle[] } @@ -806,16 +806,16 @@ export function loadProfile( /** * Compose patch layers into the effective entry list over an empty root — - * compile preset operations and apply the same native patch list as boot, - * so flag derivation and config dumps see exactly what mounts. + * the same single `applyEntryPatches` call the boot include makes, so flag + * derivation and config dumps see exactly what mounts. * @param layers - patch lists in application order. * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them). * @returns the composed entry list. */ export function composeEntries( - layers: readonly ProfilePatch[][], warn: (message: string) => void = () => {}, + layers: readonly PatchOptions[][], warn: (message: string) => void = () => {}, ): EntryOptions[] { - return applyProfilePatches([], layers.flat(), (message: string, ...args: unknown[]) => { + return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => { let index = 0 warn(message.replace(/%C/g, () => JSON.stringify(args[index++]))) }) diff --git a/packages/boot/app-boot/tests/config-schema.spec.ts b/packages/boot/app-boot/tests/config-schema.spec.ts index aaacfcd68e..debb11641a 100644 --- a/packages/boot/app-boot/tests/config-schema.spec.ts +++ b/packages/boot/app-boot/tests/config-schema.spec.ts @@ -500,10 +500,6 @@ describe('collectConfigSchemas', () => { expect(validates(result, [{ name: 'server', config: { port: 'wrong' } }])).toBe(false) expect(validates(result, [{ name: 'new-plugin', config: { arbitrary: true } }])).toBe(true) expect(validates(result, [{ id: 'server', disabled: true }], 'patchList')).toBe(true) - expect(validates(result, [{ preset: 'preset-standard', id: 'server', config: 'child-owned' }], 'patchList')).toBe(true) - expect(validates(result, [{ preset: '', insert: [] }], 'patchList')).toBe(false) - expect(validates(result, [{ preset: ' ', insert: [] }], 'patchList')).toBe(false) - expect(validates(result, [{ preset: { __jsExpr: 'ctx.preset' }, insert: [] }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', config: { port: 'wrong' } }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', config: {} }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', name: 'not-server', config: 'ignored' }], 'patchList')).toBe(true) diff --git a/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts b/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts deleted file mode 100644 index d9d913d11e..0000000000 --- a/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** Patch identities work with archive-backed files and preserve physical filesystem aliases. */ -import { lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, onTestFinished, vi } from 'vitest' -import { loadOverlayPatches, realModuleFile } from '../src/index.ts' - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, realpathSync: Object.assign(vi.fn(actual.realpathSync), { native: actual.realpathSync.native }) } -}) - -afterEach(() => { vi.mocked(realpathSync).mockReset() }) - -function packagedRuntime(): void { - const previous = Object.getOwnPropertyDescriptor(process, 'pkg') - Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) - onTestFinished(() => { - if (previous === undefined) Reflect.deleteProperty(process, 'pkg') - else Object.defineProperty(process, 'pkg', previous) - }) -} - -function fixture(): string { - const root = mkdtempSync(join(tmpdir(), 'profile-patch-carrier-')) - onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) - return root -} - -it('loads ordinary and anonymous scoped patches when packaged files support reads and directory realpath', () => { - packagedRuntime() - const root = fixture() - const file = join(root, 'cordis.patch.yml') - writeFileSync(file, JSON.stringify([ - { insert: [{ id: 'base', name: 'base' }] }, - { preset: 'preset-standard', insert: [{ name: 'anonymous' }] }, - ])) - vi.mocked(realpathSync).mockImplementation((path, options) => { - if (lstatSync(path).isFile()) throw Object.assign(new Error('archive file has no physical realpath'), { code: 'ENOENT' }) - return realpathSync.native(path, options) - }) - expect(() => realpathSync(file)).toThrow('archive file has no physical realpath') - const first = loadOverlayPatches('test', file) - expect(first[0]).toEqual({ insert: [{ id: 'base', name: 'base' }] }) - expect(first[1]?.insert?.[0]).toEqual({ id: expect.any(String) as string, name: 'anonymous' }) - expect(loadOverlayPatches('test', file)).toEqual(first) - expect(realModuleFile(file)).toBe(join(realpathSync(root), 'cordis.patch.yml')) -}) - -it.each([false, true])('keeps directory and file symlinks on the same patch identity (packaged: %s)', (packaged) => { - if (packaged) packagedRuntime() - const root = fixture() - const directory = join(root, 'bundle') - mkdirSync(directory) - const file = join(directory, 'Patch.yml') - writeFileSync(file, JSON.stringify([{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }])) - const directoryAlias = join(root, 'bundle-alias') - const fileAlias = join(root, 'patch-alias.yml') - symlinkSync(directory, directoryAlias, process.platform === 'win32' ? 'junction' : 'dir') - symlinkSync(file, fileAlias, 'file') - const first = loadOverlayPatches('test', file) - expect(loadOverlayPatches('test', join(directoryAlias, 'Patch.yml'))).toEqual(first) - expect(loadOverlayPatches('test', fileAlias)).toEqual(first) - expect(realModuleFile(fileAlias)).toBe(realpathSync(file)) -}) - -it('keeps ordinary physical resource normalization identical to Node realpath', () => { - const root = fixture() - const file = join(root, 'MixedCase.yml') - writeFileSync(file, '[]\n') - const requested = process.platform === 'win32' ? file.toLowerCase() : file - expect(realModuleFile(requested)).toBe(realpathSync(requested)) -}) - -it('reports missing packaged resources instead of assigning them an identity', () => { - packagedRuntime() - expect(() => realModuleFile(join(fixture(), 'absent.yml'))).toThrow('ENOENT') -}) diff --git a/packages/boot/app-boot/tests/profile-patches.spec.ts b/packages/boot/app-boot/tests/profile-patches.spec.ts deleted file mode 100644 index 7afd826d32..0000000000 --- a/packages/boot/app-boot/tests/profile-patches.spec.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** Preset operations preserve native root patch lookup and share startup/dump/reload compilation. */ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { describe, expect, it, onTestFinished } from 'vitest' -import { load } from 'js-yaml' -import { Context } from '@deepseek-ai/cordis' -import { EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import Group from '@deepseek-ai/cordis-plugin-group' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { - applyProfilePatches, boot, compileProfilePatches, composeEntries, loadOverlayPatches, - prepareProfilePatches, profilePatchPreset, reconcileProfilePatches, renderConfigDump, type ProfilePatch, -} from '../src/index.ts' - -import { cloneProfilePatches } from '../src/profile-patches.ts' - -const preset = (plugins: EntryOptions[] = []): Omit & { - config: { id: string; description: string; plugins: EntryOptions[] } -} => ({ - id: 'preset-standard', name: 'cordis:test-preset', config: { id: 'standard', description: 'Original', plugins }, -}) -const row = (id: string): EntryOptions => ({ id, name: 'test-plugin', config: { value: 'initial' } }) -const children = (rows: EntryOptions[]): EntryOptions[] => (rows[0]!.config as { plugins: EntryOptions[] }).plugins -const temp = (): string => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-preset-patches-')) - onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) - return dir -} - -describe('preset patch composition', () => { - it('composes bundle insertions and later scoped user overrides without mutating inputs', () => { - const initial = [preset([row('base')])] - const patches: ProfilePatch[] = [ - { preset: 'preset-standard', insert: [row('optional')] }, - { preset: 'preset-standard', id: 'optional', config: { value: { __jsExpr: 'ctx.value' } } }, - { preset: 'preset-standard', id: 'base', disabled: true }, - ] - const original = structuredClone({ initial, patches }) - const result = applyProfilePatches(initial, patches) - expect(children(result)).toEqual([ - { ...row('base'), disabled: true }, - { ...row('optional'), config: { value: { __jsExpr: 'ctx.value' } } }, - ]) - expect(result[0]?.config).toMatchObject({ description: 'Original' }) - expect({ initial, patches }).toEqual(original) - expect(composeEntries([[{ insert: initial }], patches])).toEqual(result) - expect(compileProfilePatches(initial, patches).every(patch => !Object.hasOwn(patch, 'preset'))).toBe(true) - }) - - it('allows a later whole-preset replacement to replace all scoped edits', () => { - const replacement = { id: 'standard', plugins: [row('user')] } - expect(applyProfilePatches([preset()], [ - { preset: 'preset-standard', insert: [row('optional')] }, - { id: 'preset-standard', config: replacement }, - ])[0]?.config).toEqual(replacement) - }) - - it('applies each scoped operation to the current child list, including replacement children', () => { - const group = { id: 'group', name: 'cordis:group', group: true, config: [] } - const result = applyProfilePatches([preset([group])], [ - { preset: 'preset-standard', id: 'group', config: [row('replaced-child')] }, - { preset: 'preset-standard', id: 'replaced-child', disabled: true }, - { preset: 'preset-standard', id: 'group', insert: [row('inserted-child')] }, - ]) - expect(children(result)[0]?.config).toEqual([ - { ...row('replaced-child'), disabled: true }, row('inserted-child'), - ]) - }) - - it('retains ordinary root single-index behavior before and after a scoped operation', () => { - const warn: string[] = [] - const result = applyProfilePatches([ - { id: 'group', name: 'cordis:group', group: true, config: [] }, preset(), - ], [ - { id: 'group', config: [row('replacement')] }, - { preset: 'preset-standard', insert: [row('optional')] }, - { id: 'replacement', disabled: true }, - ], message => warn.push(message)) - expect(result[0]?.config).toEqual([row('replacement')]) - expect(warn).toEqual(['patch: entry %C not found']) - }) - - it.each([ - [{ id: 'group', name: 'cordis:group', group: true, config: [] }, [preset()]], - [{ id: 'group', name: 'cordis:group', group: true, config: [preset()] }, []], - ])('rejects an outer target added or removed by a whole-group replacement', (group, replacement) => { - expect(() => applyProfilePatches([group as EntryOptions], [ - { id: 'group', config: replacement }, { preset: 'preset-standard', insert: [row('optional')] }, - ])).toThrow('is missing or not addressable') - }) - - it('accepts an outer target inserted in a patchable group', () => { - const result = applyProfilePatches([{ id: 'group', name: 'cordis:group', group: true, config: [] }], [ - { id: 'group', insert: [preset()] }, - { preset: 'preset-standard', insert: [row('optional')] }, - ]) - expect(children(result[0]?.config as EntryOptions[])).toEqual([row('optional')]) - }) - - it('warns and skips a stale user override after its optional bundle is deselected', () => { - const messages: unknown[][] = [] - expect(applyProfilePatches([preset()], [{ preset: 'preset-standard', id: 'optional', disabled: true }], - (...args) => messages.push(args))).toEqual([preset()]) - expect(messages).toEqual([['preset "preset-standard": patch: entry %C not found', 'optional']]) - }) - - it.each([null, '', ' ', 1, { __jsExpr: 'ctx.preset' }])('rejects a nonliteral or empty outer target %j', (target) => { - const dir = temp() - const path = join(dir, 'patch.yml') - writeFileSync(path, JSON.stringify([{ preset: target, insert: [] }])) - expect(() => loadOverlayPatches('test', path)).toThrow('nonempty literal row id') - expect(() => loadOverlayPatches('test', path)).toThrow(path) - }) - - it('allows callers to omit a warning sink for skipped ordinary and scoped rows', () => { - expect(applyProfilePatches([], [{ id: 'missing', disabled: true }])).toEqual([]) - expect(compileProfilePatches([preset()], [{ preset: 'preset-standard', id: 'missing', disabled: true }])) - .toEqual([{ id: 'preset-standard', config: preset().config }]) - }) - - it('rejects a missing outer row', () => { - expect(() => applyProfilePatches([], [{ preset: 'missing', insert: [] }])).toThrow('preset "missing" is missing') - expect(profilePatchPreset({ id: 'ordinary' })).toBeUndefined() - }) - - it.each([undefined, null, [], { plugins: { __jsExpr: '[]' } }])('rejects a malformed preset config %j', (config) => { - expect(() => applyProfilePatches([{ ...preset(), config }], [{ preset: 'preset-standard', insert: [] }])) - .toThrow('literal config.plugins entry list') - }) - - it.each([ - { inserted: [row('same')] }, - { inserted: [{ id: 'nested', name: 'cordis:group', group: true, config: [row('same')] }] }, - ])('rejects inserted ids colliding with existing nested ids', ({ inserted }) => { - expect(() => applyProfilePatches([preset([ - { id: 'old-group', name: 'cordis:group', group: true, config: [row('same')] }, - ])], [{ preset: 'preset-standard', insert: inserted }])).toThrow('duplicate entry id "same"') - }) - - it('rejects duplicate ids inside an insertion and allows anonymous rows', () => { - expect(() => applyProfilePatches([preset()], [{ preset: 'preset-standard', insert: [row('same'), row('same')] }])) - .toThrow('duplicate entry id "same"') - const path = join(temp(), 'anonymous.patch.yml') - writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }])) - expect(children(applyProfilePatches([preset()], loadOverlayPatches('test', path)))) - .toEqual([{ id: expect.any(String) as string, name: 'anonymous' }]) - }) - - it('keeps anonymous scoped identities stable by file and position without naming ordinary anonymous rows', () => { - const dir = temp() - const file = join(dir, 'contribution.patch.yml') - const content = JSON.stringify([ - { insert: [{ name: 'ordinary-anonymous' }] }, - { preset: 'preset-standard', insert: [ - { name: 'anonymous' }, - { id: 'explicit', name: 'cordis:group', group: true, config: [{ name: 'nested-anonymous' }] }, - ] }, - { preset: 'preset-standard', insert: [{ name: 'anonymous' }] }, - ]) - writeFileSync(file, content) - const first = loadOverlayPatches('test', file) - const second = loadOverlayPatches('test', file) - expect(second).toEqual(first) - expect(first[0]?.insert?.[0]?.id).toBeUndefined() - expect(first[1]?.insert?.[1]?.id).toBe('explicit') - const nested = first[1]?.insert?.[1]?.config as EntryOptions[] - const ids = [first[1]?.insert?.[0]?.id, nested[0]?.id, first[2]?.insert?.[0]?.id] - expect(ids.every(id => typeof id === 'string' && id.length > 0)).toBe(true) - expect(new Set(ids).size).toBe(3) - const other = join(dir, 'other.patch.yml') - writeFileSync(other, content) - expect(loadOverlayPatches('test', other)[1]?.insert?.[0]?.id).not.toBe(ids[0]) - expect(children(applyProfilePatches([preset()], first))).toEqual(children(applyProfilePatches([preset()], second))) - expect(Object.keys(first[1]!)).toEqual(['preset', 'insert']) - }) - - it.each([ - { initial: [], insert: [] }, - { initial: [preset()], insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row('duplicate'), row('duplicate')] }] }, - { initial: [{ ...preset(), config: null }], insert: [] }, - ])('attributes scoped errors after cloning and flattening through composition, dumps, and boot', async ({ initial, insert }) => { - const dir = temp() - const path = join(dir, 'cordis.yml') - writeFileSync(path, JSON.stringify(initial)) - const patchFile = join(dir, 'third-party.patch.yml') - writeFileSync(patchFile, JSON.stringify([{ preset: 'preset-standard', insert }])) - const patches = cloneProfilePatches(loadOverlayPatches('test', patchFile)) - expect(() => composeEntries([[{ insert: initial }], patches])).toThrow(patchFile) - expect(() => renderConfigDump('test', path, [{ label: 'third-party', patches }])).toThrow(patchFile) - await expect(boot('test', path, patches)).rejects.toThrow(patchFile) - }) - - it('keeps anonymous rows from the initial preset beside newly inserted rows', () => { - const initial = load(JSON.stringify([{ ...preset(), config: { id: 'standard', plugins: [{ name: 'anonymous-base' }] } }])) as EntryOptions[] - expect(children(applyProfilePatches(initial, [{ preset: 'preset-standard', insert: [row('contributed')] }]))) - .toEqual([{ name: 'anonymous-base' }, row('contributed')]) - }) - - it('attributes errors from a caller warning sink to the loaded scoped operation', () => { - const path = join(temp(), 'warning.patch.yml') - writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', id: 'absent', disabled: true }])) - expect(() => applyProfilePatches([preset()], loadOverlayPatches('test', path), () => { throw 'warning sink unavailable' })) - .toThrow(`warning sink unavailable in ${path}`) - }) - - it('attributes ordinary and scoped warnings to their own dump layers', () => { - const dir = temp() - const path = join(dir, 'cordis.yml') - writeFileSync(path, JSON.stringify([preset()])) - const warnings: string[] = [] - renderConfigDump('test', path, [ - { label: 'ordinary', patches: [{ id: 'missing', disabled: true }] }, - { label: 'scoped', patches: [{ preset: 'preset-standard', id: 'missing', disabled: true }] }, - ], message => warnings.push(message)) - expect(warnings).toEqual([ - 'test: [ordinary] patch: entry "missing" not found', - 'test: [scoped] preset "preset-standard": patch: entry "missing" not found', - ]) - }) -}) - -const capturePreset = { - [EntryGroup.key]: true, - async apply(ctx: Context, config: { plugins: EntryOptions[] }) { await ctx.plugin(Group, config.plugins) }, -} - -it('boots, dumps, and reloads a third-party relative module inside a contributed group', async () => { - const dir = temp() - const bundleDir = join(dir, 'third-party') - mkdirSync(bundleDir) - writeFileSync(join(bundleDir, 'relative.mjs'), [ - 'export function apply(ctx, config) {', - ' ctx.effect(() => ctx.reflect.provide("presetPatchResult", config.value))', - '}', - '', - ].join('\n')) - const path = join(dir, 'cordis.yml') - writeFileSync(path, JSON.stringify([preset()])) - const patchPath = join(bundleDir, 'cordis.patch.yml') - writeFileSync(patchPath, [ - '- preset: preset-standard', - ' insert:', - ' - id: nested', - ' name: cordis:group', - ' group: true', - ' config:', - ' - id: contributed', - ' name: ./relative.mjs', - ' config:', - ' value: !!js ctx.get("fixtureValue")', - '', - ].join('\n')) - const patches = loadOverlayPatches('test', patchPath) - const ctx = await boot('test', path, patches, (context) => { - context.loader.builtins['test-preset'] = capturePreset - context.provide('fixtureValue', 'from-child-context') - }) - onTestFinished(() => ctx.fiber.dispose()) - expect(ctx.get('presetPatchResult')).toBe('from-child-context') - const declared = [...ctx.loader.entries()].find(entry => entry.options.id === 'preset-standard')! - const dump = renderConfigDump('test', path, [{ label: 'third-party', patches }]) - expect(load(dump, { schema: entryListSchema })).toEqual([declared.options]) - expect(dump).toContain(pathToFileURL(join(bundleDir, 'relative.mjs')).href) - expect(dump).toContain('!!js ctx.get("fixtureValue")') - await reconcileProfilePatches(ctx, [...patches, { - preset: 'preset-standard', id: 'contributed', config: { value: 'user' }, - }], 'test') - expect(ctx.get('presetPatchResult')).toBe('user') - await reconcileProfilePatches(ctx, [], 'test') - expect(ctx.get('presetPatchResult')).toBeUndefined() - await reconcileProfilePatches(ctx, [{ preset: 'preset-standard', id: 'contributed', disabled: true }], 'test') - expect(ctx.get('presetPatchResult')).toBeUndefined() - await reconcileProfilePatches(ctx, patches, 'test') - expect(ctx.get('presetPatchResult')).toBe('from-child-context') -}) - -it('rejects a malformed root configuration before compiling its scoped patch', async () => { - const dir = temp() - const path = join(dir, 'cordis.yml') - writeFileSync(path, '{}\n') - await expect(boot('test', path, [{ preset: 'preset-standard', insert: [] }])) - .rejects.toThrow('must be a top-level array of entries') -}) - -it('leaves lower-level nonprofile compatibility callers in control of their native patches', async () => { - const ctx = new Context() - onTestFinished(() => ctx.fiber.dispose()) - const patches = [{ id: 'custom', disabled: true }] - expect(prepareProfilePatches(ctx, patches, pathToFileURL(temp()).href)).toBe(patches) -}) - -it('anchors relative modules in an inserted preset definition and its nested groups', () => { - const dir = temp() - const path = join(dir, 'cordis.patch.yml') - writeFileSync(path, JSON.stringify([{ insert: [{ - ...preset([{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: './child.mjs' }] }]), - name: '@deepseek-ai/dsh-agent-preset', - }] }])) - const patches = loadOverlayPatches('test', path) - expect(patches[0]?.insert?.[0]?.config).toMatchObject({ plugins: [{ config: [{ - name: pathToFileURL(join(dir, 'child.mjs')).href, - }] }] }) -}) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 6db0154321..465f9d82c6 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -414,13 +414,11 @@ describe('profile reconciliation settlement', () => { onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() }) let settled = false const operation = reconcileProfilePatches(ctx, [], NAME).then(() => { settled = true }) - onTestFinished(async () => { release.resolve(undefined); await operation }) await started.promise - expect([...ctx.loader.entries()].find(entry => entry.options.id === 'held')?.fiber?.uid).toBeNull() + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false) expect(settled).toBe(false) release.resolve(undefined) await operation - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false) expect(settled).toBe(true) }) }) diff --git a/packages/boot/config-editor/README.i18n.yaml b/packages/boot/config-editor/README.i18n.yaml index 265864b3ec..5727bdff08 100644 --- a/packages/boot/config-editor/README.i18n.yaml +++ b/packages/boot/config-editor/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1eedaa5d72b48177 zh: 39abc1d927a692d2 /deepseek-ai-dsh-config-editor/understand-the-implementation: - en: 6c0265c949db473c - zh: fe1849f32f2d111c + en: baa887b5ecfa7001 + zh: d3e03c2b1d10c67e /deepseek-ai-dsh-config-editor/further-exploration: en: 0c9e1d1401a865cc zh: 6130d52636e84676 diff --git a/packages/boot/config-editor/README.md b/packages/boot/config-editor/README.md index e67bdad23a..17da6551f9 100644 --- a/packages/boot/config-editor/README.md +++ b/packages/boot/config-editor/README.md @@ -42,8 +42,6 @@ The [editor](src/index.ts) reconciles external changes before deriving a candida Configuration reads share one composition for entries without profile config overrides. Overridden entries are composed separately with only their own override removed; profile inserts and other entries' overrides remain effective. Returned configs are detached, and compositions are not cached between reads. -Host configuration reads, edits, and resets ignore preset-scoped operations, including those whose child IDs match Host entry IDs. - diff --git a/packages/boot/config-editor/README.zh.md b/packages/boot/config-editor/README.zh.md index 62abcab4bc..d1ff18344f 100644 --- a/packages/boot/config-editor/README.zh.md +++ b/packages/boot/config-editor/README.zh.md @@ -42,8 +42,6 @@ kind: "package-reference" 读取配置时,没有 profile config 覆盖项的条目共用一次组合结果。有覆盖项的条目分别组合,仅移除自身的覆盖项;profile 插入的条目和其他条目的覆盖项仍然生效。返回的配置为独立副本,组合结果不跨读取缓存。 -Host 配置读取、编辑和重置均忽略预设作用域操作,包括子项 ID 与 Host 条目 ID 相同的操作。 - diff --git a/packages/boot/config-editor/src/index.ts b/packages/boot/config-editor/src/index.ts index 4a3e5be473..ffec879f40 100644 --- a/packages/boot/config-editor/src/index.ts +++ b/packages/boot/config-editor/src/index.ts @@ -3,11 +3,11 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { Context, FiberState, Service, resolveConfig } from '@deepseek-ai/cordis' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import yaml from 'js-yaml' import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-hmr' -import { composeEntries, loadProfileDirectory, profilePatchPreset, readProfilePatches, reconcileProfilePatches, type ProfilePatch } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, loadProfileDirectory, readProfilePatches, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { isMap, isSeq, parseDocument, Scalar, visit } from 'yaml' @@ -22,7 +22,7 @@ function flatten(rows: EntryOptions[]): EntryOptions[] { return rows.flatMap(row => [row, ...row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []]) } -/** Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader. */ +/** Persist complete raw configs and apply them through the normal Loader path. */ export class ConfigEditor extends Service { static inject = ['loader', 'profileContext'] @@ -50,9 +50,8 @@ export class ConfigEditor extends Service { const profile = this.ownerContext.profileContext const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor) const entries = this.entries() - const hostPatches = loaded.patches.filter(patch => profilePatchPreset(patch) === undefined) // An own config key can replace inherited config even when its value is undefined. - const overridden = new Set(hostPatches.filter(patch => patch.insert === undefined && Object.hasOwn(patch, 'config')).map(patch => patch.id)) + const overridden = new Set(loaded.patches.filter(patch => patch.insert === undefined && Object.hasOwn(patch, 'config')).map(patch => patch.id)) const composed = new Map() if (entries.some(entry => !overridden.has(entry.options.id))) { for (const row of flatten(composeEntries([...loaded.layers.map(layer => layer.patches), loaded.patches]))) { @@ -64,7 +63,7 @@ export class ConfigEditor extends Service { inherited: overridden.has(entry.options.id) ? this.inherited(entry, loaded) : structuredClone((composed.get(entry.options.id)?.config ?? {}) as Record), - override: structuredClone((hostPatches.findLast( + override: structuredClone((loaded.patches.findLast( row => row.id === entry.options.id && row.config !== undefined, )?.config ?? {}) as Record), })) @@ -72,7 +71,7 @@ export class ConfigEditor extends Service { private inherited(entry: Entry, loaded: ReturnType): Record { const patches = loaded.patches.map((patch) => { - if (patch.id !== entry.options.id || patch.insert !== undefined || profilePatchPreset(patch) !== undefined) return patch + if (patch.id !== entry.options.id || patch.insert !== undefined) return patch const rest = { ...patch }; Reflect.deleteProperty(rest, 'config') return rest }) @@ -116,12 +115,12 @@ export class ConfigEditor extends Service { if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence') document.contents.flow = false const index = document.contents.items.findLastIndex((item, index) => isMap(item) - && document.getIn([index, 'id']) === entry.options.id && !item.has('insert') && !item.has('preset') + && document.getIn([index, 'id']) === entry.options.id && !item.has('insert') && (!item.has('name') || document.getIn([index, 'name']) === entry.options.name)) if (isDeepStrictEqual(next, inherited)) { for (let index = document.contents.items.length - 1; index >= 0; index--) { const row = document.contents.items[index] - if (!isMap(row) || document.getIn([index, 'id']) !== entry.options.id || row.has('insert') || row.has('preset')) continue + if (!isMap(row) || document.getIn([index, 'id']) !== entry.options.id || row.has('insert')) continue row.delete('config') if (row.items.length === Number(row.has('id')) + Number(row.has('name'))) document.delete(index) } @@ -135,7 +134,7 @@ export class ConfigEditor extends Service { } }) const profile = this.ownerContext.profileContext const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor) - const patches = readProfilePatches('dsh', profile, { ...loaded, patches: yaml.load(String(document), { schema: entryListSchema }) as ProfilePatch[] }) + const patches = readProfilePatches('dsh', profile, { ...loaded, patches: yaml.load(String(document), { schema: entryListSchema }) as PatchOptions[] }) const effective = flatten(composeEntries([patches])).find(row => row.id === entry.options.id) if (!isDeepStrictEqual(effective?.config ?? {}, next)) { throw new Error(`Configuration for "${entry.options.id}" is overridden by a home patch or command-line overlay`) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 7a2bca2062..033a14fc65 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: af5bf0a205b5a2a1 - zh: 0114039816454a1a + en: 4eef24eeb95ebb30 + zh: 99fb6a7e4e3f759f /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 @@ -45,8 +45,8 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: 7d7ea29063e358ca - zh: e3399db77a97f62a + en: fde2d9491fe5dd4f + zh: ef6e6db15c4f19d2 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: en: 4447e4df8d62b274 zh: c76443e2edb97b1d diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 6763a7a9f8..bc1550d9aa 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,8 +41,6 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -Scoped bundle rows carry both their declared `rowId` and outer `preset` target. The manager reads declarations without composing the bundle alone, so rows remain visible while Off. Their optional `composition` state comes from the current preset inventory; they have no editable root `entryId`, and their `preset-managed` read-only reason directs configuration to the preset or bundle. Enabling audits every affected preset using its registered declaring row, including failures the registry retained instead of throwing; unrelated unchanged preset failures become warnings. Bundle toggles validate the saved composition even without HMR; composition failures keep the saved selection and return a diagnostic immediately. Remove refuses while a current or retained preset generation uses a contributed row, matched by defining preset row, child row ID, and resolved module, or any module belonging to the bundle package itself. This protection lasts after Off until those generations release the modules; unrelated rows using a shared dependency do not prevent removal. Anonymous scoped insertions receive the same protection. Missing module files do not make a different contribution match; identical retained declarations remain protected when their files are unavailable. - `listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. `listBundles` marks shipped optional bundles and on-demand catalog entries as `official`, independently of package installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` only records whether the profile declares the dependency. An absent on-demand entry carries embedded localized metadata and a host-derived `installTarget` containing the running DSH version and install spec, without registry access. Profile-declared packages with unreadable files retain their declaration and expose the normal error. @@ -141,7 +139,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Web approves the entire displayed pending group; it has no per-package selection. - Package replacements require restarting the process to load a fresh JavaScript module generation. - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`. -- The manager cannot disable its own management components, change another profile, or directly edit individual rows inside an agent preset. +- The manager cannot disable its own management components, change another profile, or edit an agent preset's composition. - A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. - Management results describe Host activation. Browser synchronization failures appear separately in the Settings plugin list. - Desktop package operations remain owned by the Desktop shell. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index ba365135f2..8d5d3aabe7 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,8 +41,6 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -限定到 preset 的组合包行同时携带声明的 `rowId` 和外层 `preset` 目标。管理器读取声明,不单独组合该 bundle,因此 Off 状态下仍能列出这些行。可选的 `composition` 状态来自当前 preset 清单;这些行没有可编辑的根条目 `entryId`,其 `preset-managed` 只读原因指引用户通过预设或组合包进行配置。启用根据注册的声明行检查所有受影响的 preset,包括注册表保留而未抛出的故障;无关且未变化的 preset 故障作为警告返回。即使没有 HMR,组合包开关也会校验保存后的组合;组合失败会保留已保存的选择并立即返回诊断。当前或被保留的 preset 代仍使用贡献行(按定义 preset 的行、子行 ID 和解析后的模块匹配),或属于组合包自身的任意模块时,Remove 都会拒绝。这项保护在 Off 后仍持续,直到这些代释放模块;无关行使用共享依赖不会阻止移除。匿名作用域插入行受到相同保护。模块文件缺失不会导致另一贡献项被误认为匹配;文件不可用时,相同的保留声明仍受保护。 - `listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `listBundles` 将随附可选组合包和按需目录项标为 `official`,与包的安装状态独立。`availability` 表示可读取的安装随附包、profile 包,或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 仅表示 profile 是否声明该依赖。尚未安装的按需条目携带内嵌的本地化元信息,以及 Host 生成、包含当前 DSH 版本与安装 spec 的 `installTarget`,无需访问注册表。profile 已声明但文件不可读的包保留依赖声明,并展示原有错误。 @@ -141,7 +139,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 -- 管理器不能关闭自身所需的管理组件、修改其他 profile 或直接编辑 agent 预设内部的单个条目。 +- 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 - 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 - 管理结果描述 Host 激活状态。浏览器同步失败会在设置的插件列表中单独显示。 - Desktop 包管理操作仍由 Desktop shell 负责。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 4a83a1781a..c6fc6af78a 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -75,8 +75,7 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-hmr": { @@ -84,9 +83,6 @@ }, "@deepseek-ai/dsh-user-approval": { "optional": true - }, - "@deepseek-ai/dsh-agent-preset-registry": { - "optional": true } }, "devDependencies": { @@ -107,10 +103,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", - "@deepseek-ai/dsh-agent-preset": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-session-title": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*" } } diff --git a/packages/boot/plugin-manager/src/bundle-rows.ts b/packages/boot/plugin-manager/src/bundle-rows.ts deleted file mode 100644 index 9a150f3bd7..0000000000 --- a/packages/boot/plugin-manager/src/bundle-rows.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Raw bundle declarations remain inspectable while their prerequisite layers are disabled. */ -import { existsSync } from 'node:fs' -import { dirname, isAbsolute, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import { realModuleFile, profilePatchPreset, type ProfilePatch, type PluginPackages } from '@deepseek-ai/dsh-app-boot' - -/** A declared child row and the outer preset operation that contributes it. */ -export interface BundleDeclaration { - readonly row: EntryOptions - readonly preset?: string -} - -/** Read inserted rows without applying a bundle against an incomplete profile. - * @param patches Parsed bundle patches, whose module paths are already anchored. - * @returns Declarations in patch order, including nested groups. - */ -export function bundleDeclarations(patches: readonly ProfilePatch[]): BundleDeclaration[] { - const result: BundleDeclaration[] = [] - const visit = (rows: EntryOptions[], preset?: string): void => { - for (const row of rows) { - result.push({ row, ...preset === undefined ? {} : { preset } }) - if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], preset) - } - } - for (const patch of patches) { - if (patch.insert !== undefined) visit(patch.insert, profilePatchPreset(patch)) - } - return result -} - -/** Locate the package owning a bare import or filesystem module without importing its code. - * @param specifier Configured module name or anchored file URL. - * @param base Declaring Loader tree's resolution URL. - * @param packages Optional profile package lookup for bare specifiers. - * @returns Canonical package manifest path, or undefined for builtins, missing modules, and unowned files. - */ -export function bundleModuleOwner( - specifier: string, base: string, packages?: Pick, -): string | undefined { - const known = packages?.packageOf(specifier, base) - if (known !== undefined) return realModuleFile(known.manifestPath) - if (!specifier.startsWith('file:') && !specifier.startsWith('.') && !isAbsolute(specifier)) return undefined - const file = isAbsolute(specifier) ? specifier : fileURLToPath(new URL(specifier, base)) - if (!existsSync(file)) return undefined - let dir = dirname(realModuleFile(file)) - while (true) { - const manifest = join(dir, 'package.json') - if (existsSync(manifest)) return realModuleFile(manifest) - const parent = dirname(dir) - if (parent === dir) return undefined - dir = parent - } -} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 5c77c0a97c..cc4db943eb 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -7,16 +7,15 @@ import { pathToFileURL } from 'node:url' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' -import type { AgentPreset } from '@deepseek-ai/dsh-agent-preset-registry' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import z from '@deepseek-ai/schemastery' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { - readPluginMeta, resolvePluginResource, realModuleFile, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, + readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, profilePatchPreset, type ProfilePatch, + setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' @@ -29,7 +28,6 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } import { writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' -import { bundleDeclarations, bundleModuleOwner, type BundleDeclaration } from './bundle-rows.ts' import { checkGithubConnection } from './github-connection.ts' import type { BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange, @@ -281,9 +279,7 @@ export class PluginManager extends TypertRemoteService { * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ @Remote - async listBundles(): Promise { - const inventory = await readPluginInventory(this.ctx) - const definitions = await this.ctx.get('agentPresets')?.list() ?? [] + listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] @@ -341,7 +337,7 @@ export class PluginManager extends TypertRemoteService { ...meta === undefined ? {} : { meta }, ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), - ...this.declaredRows(name, info, inventory, definitions), + ...this.declaredRows(name, info), ...targetError === undefined ? {} : { error: targetError } }) } catch (error) { if (enabled || installed || optional || offered !== undefined) { @@ -352,7 +348,7 @@ export class PluginManager extends TypertRemoteService { } } } - return bundles + return Promise.resolve(bundles) } /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. @@ -481,9 +477,7 @@ export class PluginManager extends TypertRemoteService { return this.change(result => this.configure(async () => { await this.selectBundle(name, enabled) if (enabled) await this.refreshPackages() - const rows = enabled ? this.bundleRows(name) : [] - result.warnings = await this.reload(rows.filter(row => row.preset === undefined).map(({ row }) => row.id), - enabled ? this.bundlePresets(name) : []) + result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []) if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages() }), { stage: 'enable', target: name, enabled }, 'bundle') } @@ -608,7 +602,7 @@ export class PluginManager extends TypertRemoteService { if (options?.enabled !== false) await this.selectBundle(name, true) if (Object.hasOwn(before, name)) return 'restart-required' await this.refreshPackages() - if (options?.enabled !== false) result.warnings = await this.reload([], this.bundlePresets(name)) + if (options?.enabled !== false) result.warnings = await this.reload() }) }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') control.result = result @@ -653,21 +647,18 @@ export class PluginManager extends TypertRemoteService { const installed = await this.configure(async () => { const bundle = (await this.listBundles()).find(item => item.name === name) if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable') - const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name) - || contributions.some(({ row, preset }) => preset === undefined && [...this.ctx.loader.entries()] - .some(entry => entry.options.id === row.id && entry.fiber !== undefined)) - || this.retainedBundleModules(name, contributions))) { + || (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()] + .some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) { throw new ManagementFailure('stop-profile') } + const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (bundle.enabled) { await this.selectBundle(name, false) result.warnings = await this.reload() } if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null - && contributions.some(({ row, preset }) => preset === undefined - && row.id === entry.options.id && row.name === entry.options.name)) - || this.retainedBundleModules(name, contributions)) { + && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) { throw new ManagementFailure('bundle-in-use') } return bundle.installed @@ -681,65 +672,39 @@ export class PluginManager extends TypertRemoteService { }, { stage: 'remove', target: name }, 'remove') } - /** The rows a bundle declares, joined by outer preset and child id where applicable. */ - private declaredRows(name: string, info: ProfileManifest, inventory: PluginInventorySnapshot, definitions: readonly AgentPreset[]): Pick { - const patches = this.bundlePatches(name, info) + /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */ + private declaredRows(name: string, info: ProfileManifest): Pick { + const bundle = info.dsh?.bundle + /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */ + if (bundle === undefined) return { rows: [], overrides: [] } const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) - const entries = [...this.ctx.loader.entries()] + const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file)) + // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. + const live = new Map() + for (const entry of this.ctx.loader.entries()) { + /* v8 ignore next -- the Loader gives every entry an id before it is listed */ + if (typeof entry.options.id === 'string') live.set(entry.options.id, { + entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl, + }) + } const rows: BundleRowInfo[] = [] const packages = this.ctx.get('pluginPackages') - for (const { row, preset } of bundleDeclarations(patches)) { - if (typeof row.id !== 'string' || typeof row.name !== 'string') continue - const matches = preset === undefined ? entries.filter(entry => entry.options.id === row.id && entry.options.name === row.name) : [] - const active = matches.length === 1 ? matches[0] : undefined - const base = active?.parent.tree.ctx.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href + for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) { + // Group rows are containers without a plugin inventory entry; their children carry the switches. + if (typeof row.id !== 'string' || typeof row.name !== 'string' || row.group === true) continue + const active = live.get(row.id) + const entryId = active?.entryId + const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href const meta = packages?.metaOf(row.name, base) - const presetId = preset === undefined ? undefined : definitions.find(item => item.definitionEntryId === preset)?.id - const composition = inventory.agentPresets?.find(item => item.id === presetId)?.rows - .find(item => item.entryId === row.id && item.moduleName === row.name) rows.push({ rowId: row.id, moduleName: row.name, - ...preset === undefined ? {} : { preset, readOnlyReason: 'preset-managed' as const }, - ...composition === undefined ? {} : { composition: { enabled: composition.enabled, fiberPhase: composition.fiberPhase } }, - ...active === undefined ? {} : { entryId: pluginEntryId(active.id) }, ...meta === undefined ? {} : { meta } }) + ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } }) } - const key = (preset: string | undefined, id: string): string => JSON.stringify([preset, id]) - const declared = new Set(rows.map(row => key(row.preset, row.rowId))) - const overrides = [...new Set(patches.flatMap((item) => { - const preset = profilePatchPreset(item) - return item.insert === undefined && typeof item.id === 'string' && !declared.has(key(preset, item.id)) - ? [preset === undefined ? item.id : `${preset}/${item.id}`] : [] - }))] + const declared = new Set(rows.map(row => row.rowId)) + const overrides = [...new Set(patches.flatMap(item => + item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))] return { rows, overrides } } - /** Retained revisions still hold module code even after a bundle has been deselected. */ - private retainedBundleModules(name: string, declarations: readonly BundleDeclaration[]): boolean { - const presets = this.ctx.get('agentPresets') - if (presets === undefined || declarations.length === 0) return false - const manifest = join(resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir), 'package.json') - const base = pathToFileURL(manifest).href - const owner = realModuleFile(manifest) - const moduleKey = (specifier: string, parentURL: string): string | undefined => { - if (specifier.startsWith('cordis:')) return specifier - try { return realModuleFile(resolvePluginResource(specifier, parentURL)) } - catch (error) { - // An externally removed module cannot match another resolved file; identical declarations remain protected below. - if (['ENOENT', 'ENOTDIR', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(String((error as NodeJS.ErrnoException).code))) return undefined - throw error - } - } - return presets.inspectCompositions().some(composition => composition.modules.some((module) => { - const moduleBase = module.baseUrl ?? base - if (bundleModuleOwner(module.moduleName, moduleBase, this.ctx.get('pluginPackages')) === owner) return true - return module.useHostBase && declarations.some(({ row, preset }) => { - if (preset === undefined || preset !== composition.definitionEntryId || row.id !== module.entryId) return false - const declared = moduleKey(row.name, base) - const retained = moduleKey(module.moduleName, moduleBase) - return declared === undefined || retained === undefined ? row.name === module.moduleName : declared === retained - }) - })) - } - /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ private async runPnpm( args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId, @@ -817,31 +782,21 @@ export class PluginManager extends TypertRemoteService { if (enabled) this.protectsManager(name) } - private bundlePatches(name: string, known?: ProfileManifest): ProfilePatch[] { - const info = known ?? bundleManifest(name, this.profile.dir, this.profile.installAnchor) + private bundleRows(name: string): EntryOptions[] { + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info?.dsh?.bundle === undefined) return [] const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) - return bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file)) - } - - private bundleRows(name: string): BundleDeclaration[] { return bundleDeclarations(this.bundlePatches(name)) } - - private bundlePresets(name: string): string[] { - return [...new Set(this.bundlePatches(name).flatMap((patch) => { - const preset = profilePatchPreset(patch) - return preset === undefined ? [] : [preset] - }))] + return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))])) } private protectsManager(name: string): boolean { if (this.managementBundles.has(name)) return true - let rows: BundleDeclaration[] + let rows: EntryOptions[] try { rows = this.bundleRows(name) } catch (_error) { // Unreadable bundles contribute no new rows; listBundles reports their diagnostics. return false } - const protectedBundle = rows.some(({ row, preset }) => preset === undefined - && (protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)) + const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId) if (protectedBundle) this.managementBundles.add(name) return protectedBundle } @@ -861,33 +816,9 @@ export class PluginManager extends TypertRemoteService { await this.ownerContext.get('pluginPackages')?.refresh() } - /** Configuration identity distinguishes a changed failed preset from an unrelated pre-existing warning. */ - private presetConfiguration(preset: AgentPreset): string { - return JSON.stringify([...this.ctx.loader.entries()].flatMap((entry) => { - const config = entry.options.config as { id?: unknown; plugins?: unknown } | undefined - return entry.options.id === preset.definitionEntryId && config?.id === preset.id && Array.isArray(config.plugins) ? [config] : [] - })) - } - - private async reload(requiredIds: readonly string[] = [], requiredPresets: readonly string[] = []): Promise { - if (this.ownerContext.get('hmr') === undefined) { - composeEntries([readProfilePatches('dsh', this.profile)]) - return [] - } - const presets = this.ctx.get('agentPresets') - const before = new Map((await presets?.list() ?? []).map(row => - [row.id, { broken: row.broken, config: this.presetConfiguration(row) }])) - const warnings = await reconcileProfilePatches( - this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', [...requiredIds, ...requiredPresets], - ) - const after = await this.ctx.get('agentPresets')?.list() ?? [] - const required = new Set(after.filter(row => row.definitionEntryId !== undefined && requiredPresets.includes(row.definitionEntryId)) - .map(row => row.id)) - const broken = after.filter(row => row.broken !== undefined) - const failed = broken.filter(row => required.has(row.id) || before.get(row.id)?.broken !== row.broken - || before.get(row.id)?.config !== this.presetConfiguration(row)) - if (failed.length > 0) throw new Error(failed.map(row => `agent preset ${row.id}: ${row.broken}`).join('\n')) - return [...warnings, ...broken.map(row => `agent preset ${row.id}: ${row.broken}`)] + private async reload(requiredIds: readonly string[] = []): Promise { + if (this.ownerContext.get('hmr') === undefined) return [] + return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds) } private async change( diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 8d58402e76..2df35b7efc 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -4,17 +4,17 @@ import { existsSync, readFileSync } from 'node:fs' import { mkdir, mkdtemp, open, rm } from 'node:fs/promises' import { join, resolve } from 'node:path' import { execa } from 'execa' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest, - resolveBundleDir, resolveProfileDir, loadOverlayPatches, readProfileVersionExemptions, + resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions, evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { parseInstallSpec } from './install-spec.ts' import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from './run-tree.ts' import { incompatiblePlugin } from './failure.ts' -import { bundleDeclarations } from './bundle-rows.ts' import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts' export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' @@ -251,11 +251,15 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor if (bundle === undefined) return [] const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file)) const names = new Set() - for (const { row } of bundleDeclarations(patches)) { - if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':')) continue - const parts = row.name.split('/') - names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/')) + const visit = (rows: EntryOptions[]) => { + for (const row of rows) { + if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[]) + if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':')) continue + const parts = row.name.split('/') + names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/')) + } } + visit(composeEntries([patches.filter(patch => patch.insert !== undefined)])) return [...names].flatMap((name) => { let packageDir: string try { packageDir = resolveBundleDir('dsh', name, anchor, dir) } catch (error) { diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 3a4aa69f09..5776b586fa 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -4,9 +4,9 @@ import { isMap, isSeq, parseDocument } from 'yaml' import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -/** Replace the last matching Host override or append one, preserving preset-scoped operations. +/** Replace the last matching override or append one after existing insertions. * @param filename Current profile patch file. - * @param id Unique Host composition entry id. + * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. * @param enabled Desired entry enablement. * @returns Whether the file changed. @@ -28,7 +28,7 @@ export async function writePluginEnabled(filename: string, id: string, name: str loadOptionalPatches('dsh', filename) const items = document.contents.items const target = items.findLast((item, index) => { - if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert') || item.has('preset')) return false + if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false const expectedName = document.getIn([index, 'name']) return !expectedName || expectedName === name }) diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 725d146c3f..5c780a2575 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -1,12 +1,12 @@ /** Public plugin management records shared with clients. */ import type { Branded } from '@deepseek-ai/dsh-brand' import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' -import type { PluginInventoryEntry, AgentPresetPluginRow } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' /** Reasons a profile control cannot modify its target. */ -export type ReadOnlyReason = 'management-required' | 'unaddressable' | 'preset-managed' +export type ReadOnlyReason = 'management-required' | 'unaddressable' /** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */ export interface IncompatiblePlugin { @@ -33,14 +33,8 @@ export type PluginInfo = PluginInventoryEntry & ( /** One row a bundle's patch declares, with its live entry while the bundle contributes it. */ export interface BundleRowInfo { - /** Declared row id, or an internal identity for an anonymous scoped insertion. */ + /** The row id as the patch declares it. */ rowId: string - /** Outer preset row id; together with rowId identifies a scoped declaration. */ - preset?: string - /** Why direct row controls are unavailable; scoped rows are configured through their preset or bundle. */ - readOnlyReason?: ReadOnlyReason - /** Current preset composition state; scoped rows have no editable root Loader entry. */ - composition?: Pick /** The module the row names. */ moduleName: string /** Local package display metadata, including rows whose bundle is disabled. */ diff --git a/packages/boot/plugin-manager/tests/bundle-rows.spec.ts b/packages/boot/plugin-manager/tests/bundle-rows.spec.ts deleted file mode 100644 index e65959f8d5..0000000000 --- a/packages/boot/plugin-manager/tests/bundle-rows.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** Declaration identity and module ownership before activating a bundle. */ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { expect, it, onTestFinished } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { PluginPackages } from '@deepseek-ai/dsh-app-boot' -import { bundleDeclarations, bundleModuleOwner } from '../src/bundle-rows.ts' - -it('reads nested declarations without requiring their target profile or preset', () => { - const row = { id: 'child', name: './child.mjs' } - expect(bundleDeclarations([ - { id: 'absent-group', insert: [row] }, - { preset: 'absent-preset', insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row] }] }, - { preset: 'absent-preset', id: 'other', disabled: true }, - ])).toEqual([ - { row }, - { row: { id: 'group', name: 'cordis:group', group: true, config: [row] }, preset: 'absent-preset' }, - { row, preset: 'absent-preset' }, - ]) -}) - -it('finds canonical owners for bare, relative, absolute, and file URL modules without evaluating them', async () => { - const dir = mkdtempSync(join(tmpdir(), 'bundle-owner-')) - const ctx = new Context() - onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) }) - const packageDir = join(dir, 'node_modules', 'fixture') - mkdirSync(join(packageDir, 'lib'), { recursive: true }) - const manifest = join(packageDir, 'package.json') - writeFileSync(manifest, '{"name":"fixture","version":"1.0.0","exports":"./lib/index.mjs"}\n') - const module = join(packageDir, 'lib', 'index.mjs') - writeFileSync(module, 'throw new Error("must not import ownership probes")\n') - const base = pathToFileURL(join(dir, 'profile.json')).href - await ctx.plugin(PluginPackages) - const expected = realpathSync(manifest) - expect(bundleModuleOwner('fixture', base, ctx.pluginPackages)).toBe(expected) - expect(bundleModuleOwner('fixture/lib/index.mjs', base, ctx.pluginPackages)).toBe(expected) - expect(bundleModuleOwner('./node_modules/fixture/lib/index.mjs', base)).toBe(expected) - expect(bundleModuleOwner(module, base)).toBe(expected) - expect(bundleModuleOwner(pathToFileURL(module).href, base)).toBe(expected) - expect(bundleModuleOwner('cordis:group', base)).toBeUndefined() - expect(bundleModuleOwner('uninstalled', base)).toBeUndefined() - expect(bundleModuleOwner('./absent.mjs', base)).toBeUndefined() - const loose = join(dir, 'loose.mjs') - writeFileSync(loose, '') - expect(bundleModuleOwner(loose, base)).toBeUndefined() - const linked = join(dir, 'linked') - symlinkSync(packageDir, linked, process.platform === 'win32' ? 'junction' : 'dir') - expect(bundleModuleOwner(pathToFileURL(join(linked, 'lib/index.mjs')).href, base)).toBe(expected) - const otherPackage = join(dir, 'other-package') - mkdirSync(otherPackage) - writeFileSync(join(otherPackage, 'package.json'), '{"name":"other-package"}\n') - const otherModule = join(otherPackage, 'foreign.mjs') - writeFileSync(otherModule, 'export function apply() {}\n') - const fileLink = join(packageDir, 'borrowed.mjs') - symlinkSync(otherModule, fileLink, 'file') - expect(bundleModuleOwner(fileLink, base)).toBe(realpathSync(join(otherPackage, 'package.json'))) - -}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 52cd122dce..be914515fd 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -761,6 +761,7 @@ it('addresses children inside profile groups and marks ambiguous ids read-only', config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }]) expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' }) expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined() + expect((await manager.listBundles()).find(item => item.name === 'grouped')?.rows.map(row => row.rowId)).toEqual(['child']) const entries = composeEntries([readProfilePatches('test', profile)]) const duplicate = entries.find(row => row.id === 'managed')! writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }])) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index f9aa34314c..9a062e8af9 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -471,7 +471,7 @@ it.each(['devDependencies', 'optionalDependencies'])('checks installed direct pl expect(await runProfilePnpm(context, ['install'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode: 1 }) }) -it.each([undefined, 'preset-standard'])('rejects an incompatible component contributed to %s by a newly installed bundle', async (preset) => { +it('rejects an incompatible component declared by a newly installed bundle', async () => { const { dir, context, pnpm } = fixture() pnpm.mutate = (target) => { install(target, 'bundle') @@ -481,16 +481,7 @@ it.each([undefined, 'preset-standard'])('rejects an incompatible component contr writeFileSync(join(component, 'package.json'), JSON.stringify({ name: 'component', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' }, })) - const unrelated = join(target, 'node_modules', 'component') - mkdirSync(unrelated, { recursive: true }) - writeFileSync(join(unrelated, 'package.json'), JSON.stringify({ name: 'component', version: '2.0.0' })) - mkdirSync(join(packageDir, 'nested')) - writeFileSync(join(packageDir, 'nested', 'group.mjs'), 'export function apply() {}\n') - writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ - ...preset === undefined ? {} : { preset }, insert: [{ id: 'relative-group', name: './nested/group.mjs', group: true, - config: [{ id: 'component', name: 'component/subpath' }], - }], - }])) + writeFileSync(join(packageDir, 'cordis.patch.yml'), '- insert:\n - id: component\n name: component/subpath\n') } const outcome = await runProfilePnpm(context, ['add', 'bundle'], { execution: 'service', outputBytes: 8192, activateNewBundles: false }) expect(outcome.exitCode).toBe(1) @@ -512,7 +503,7 @@ it('restores the manifest on malformed installed peer metadata and bounds the wa expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) }) -it('validates nested preset contributions without requiring their target preset to be selected', async () => { +it('checks scoped components in nested groups while leaving unresolved and local rows to startup', async () => { const { context, pnpm } = fixture() pnpm.mutate = (target) => { install(target, 'bundle') @@ -520,7 +511,7 @@ it('validates nested preset contributions without requiring their target preset const component = join(packageDir, 'node_modules', '@example', 'component') mkdirSync(component, { recursive: true }) writeFileSync(join(component, 'package.json'), JSON.stringify({ name: '@example/component', version: '1.0.0' })) - writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ preset: 'preset-standard', insert: [ + writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ insert: [ { id: 'group', group: true, config: [{ id: 'component', name: '@example/component/subpath' }] }, { id: 'empty-group', group: true, config: {} }, { id: 'relative', name: './plugin.mjs' }, diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index a51c544d2f..30bb947c84 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -41,19 +41,6 @@ it('creates a missing patch file and appends after insertions', async () => { ]) }) -it.each([false, true])('preserves same-ID preset overrides when a Host override exists: %s', async (hostOverride) => { - const preset = { preset: 'preset-standard', id: 'tool-bash', name: 'package', disabled: true } - const file = await fixture(JSON.stringify([ - ...hostOverride ? [{ id: 'tool-bash', disabled: false }] : [], preset, - ])) - expect(await writePluginEnabled(file, 'tool-bash', 'package', false)).toBe(true) - const disabled = { id: 'tool-bash', disabled: true } - expect(loadOptionalPatches('test', file)).toEqual(hostOverride ? [disabled, preset] : [preset, disabled]) - expect(await writePluginEnabled(file, 'tool-bash', 'package', true)).toBe(true) - const enabled = { id: 'tool-bash', disabled: false } - expect(loadOptionalPatches('test', file)).toEqual(hostOverride ? [enabled, preset] : [preset, enabled]) -}) - it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => { const file = await fixture(text) await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow() diff --git a/packages/boot/plugin-manager/tests/preset-bundles.spec.ts b/packages/boot/plugin-manager/tests/preset-bundles.spec.ts deleted file mode 100644 index 08c063b180..0000000000 --- a/packages/boot/plugin-manager/tests/preset-bundles.spec.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** Scoped bundles use real preset generations, profile reloads, and package-removal admission. */ -import fs, { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { syncBuiltinESMExports } from 'node:module' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { expect, it, onTestFinished, vi } from 'vitest' -import type { Context } from '@deepseek-ai/cordis' -import Timer from '@deepseek-ai/cordis-plugin-timer' -import { boot, initProfile, PluginPackages, readProfileManifest, readProfilePatches, createRuntimeResolution, loadProfileDirectory, type ProfileContext, type ProfilePatch } from '@deepseek-ai/dsh-app-boot' -import Hmr from '@deepseek-ai/dsh-hmr' -import AgentPreset from '@deepseek-ai/dsh-agent-preset' -import AgentPresets from '@deepseek-ai/dsh-agent-preset-registry' -import { createScope } from '@deepseek-ai/dsh-scope' -import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' -import SessionProjections from '@deepseek-ai/dsh-session-projection' -import SessionTitle, { SessionTitleProviderId, type SessionTitleProviderRequest, type SessionTitleProviderResult } from '@deepseek-ai/dsh-session-title' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import PluginManager from '../src/index.ts' -import * as operations from '../src/operations.ts' -import * as appBoot from '@deepseek-ai/dsh-app-boot' - -async function fixture(options: { title?: boolean; hmr?: boolean; presets?: boolean } = {}) { - const home = mkdtempSync(join(tmpdir(), 'preset-bundle-')) - let owner: Context | undefined - onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) - const dir = join(home, 'profiles', 'test') - const anchor = join(home, 'package.json') - writeFileSync(anchor, '{"name":"test-installation","dependencies":{}}\n') - initProfile(dir, ['core']) - writeFileSync(join(dir, 'cordis.yml'), '[]\n') - const bundle = (name: string, patches: ProfilePatch[]): string => { - const path = join(dir, 'node_modules', name) - mkdirSync(path, { recursive: true }) - writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) - writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify(patches)) - writeFileSync(join(path, 'plugin.mjs'), 'export function apply(ctx, config) { if (config?.fail) throw new Error("scoped plugin failed"); ctx.get("scopedBundleTrace").push("start"); ctx.effect(() => () => { ctx.get("scopedBundleTrace").push("stop") }) }\n') - return path - } - bundle('core', [{ insert: [ - { id: 'manager', name: 'cordis:manager' }, - { id: 'sessions', name: 'cordis:sessions' }, - { id: 'session-projections', name: 'cordis:session-projections' }, - { id: 'presets', name: 'cordis:presets', config: { default: 'standard' } }, - ...(options.presets === false ? [] : ['standard', 'cordis']).map(id => ({ id: `preset-${id}`, name: 'cordis:preset', config: { id, plugins: [] } })), - ...options.title === true ? [ - { id: 'title', name: 'cordis:title', config: { fallbackMaxWords: 5, fallbackMaxBytes: 100, maxTitleBytes: 100 } }, - { id: 'default-title', name: 'cordis:title-provider', config: { id: 'default' } }, - ] : [], - ] }]) - const addon = bundle('addon', ['standard', 'cordis'].map(preset => ({ preset: `preset-${preset}`, insert: [{ - id: 'shared-id', name: './plugin.mjs', - }] }))) - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { addon: '1.0.0' } - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - const profile: ProfileContext = { - name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, - cwd: home, home, startedBundles: ['core'], overlays: [], telemetryDisabledEnv: undefined, - } - const resolution = await createRuntimeResolution({ installAnchor: anchor, profile: loadProfileDirectory('test', dir, anchor), home }) - const calls: { - id: string - request: SessionTitleProviderRequest - pending: ReturnType> - }[] = [] - const entered: { current: ReturnType> } = { current: Promise.withResolvers() } - const trace: string[] = [] - const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), async (host) => { - owner = host - host.provide('profileContext', profile) - host.provide('scopedBundleTrace', trace) - host.provide('appReady', { onReady(listener) { listener(); return () => {} } }) - Object.assign(host.loader.builtins, { - manager: PluginManager, sessions: SessionStore, 'session-projections': SessionProjections, - presets: AgentPresets, preset: AgentPreset, title: SessionTitle, - 'title-provider': { - inject: ['sessionTitle'], - apply(ctx: Context, config: { id: string }) { - ctx.sessionTitle.register({ - id: SessionTitleProviderId(config.id), automatic: 'all-prompts', - generate(request) { - const pending = Promise.withResolvers() - calls.push({ id: config.id, request, pending }) - entered.current.resolve(undefined) - return pending.promise - }, - }) - }, - }, - }) - await host.plugin(PluginPackages, { resolution }) - }) - await ctx.plugin(Timer) - if (options.hmr !== false) { - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await ctx.hmr.runExclusive(async () => {}) - } - onTestFinished(() => { - for (const call of calls) call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) }) - }) - return { ctx, manager: ctx.pluginManager, profile, dir, addon, bundle, trace, calls, entered } -} - -it('lists scoped declarations while Off and keeps equal inner ids distinct across presets', async () => { - const { manager, addon } = await fixture() - const patchFile = join(addon, 'cordis.patch.yml') - const patches = JSON.parse(readFileSync(patchFile, 'utf8')) as ProfilePatch[] - writeFileSync(patchFile, JSON.stringify([...patches, - { id: 'shared-id', disabled: true }, - { preset: 'preset-standard', id: 'absent', disabled: true }, - ])) - const moduleName = pathToFileURL(join(addon, 'plugin.mjs')).href - expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows).toEqual([ - { rowId: 'shared-id', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName }, - { rowId: 'shared-id', preset: 'preset-cordis', readOnlyReason: 'preset-managed', moduleName }, - ]) - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied', changed: true }) - const enabled = (await manager.listBundles()).find(row => row.name === 'addon')! - expect(enabled.rows).toEqual([ - { rowId: 'shared-id', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName, composition: { enabled: true, fiberPhase: 'active' } }, - { rowId: 'shared-id', preset: 'preset-cordis', readOnlyReason: 'preset-managed', moduleName, composition: { enabled: true, fiberPhase: 'active' } }, - ]) - expect(enabled.rows.every(row => row.entryId === undefined)).toBe(true) - expect(enabled.overrides).toEqual(['shared-id', 'preset-standard/absent']) - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows.every(row => row.composition === undefined)).toBe(true) -}) - -it('loads a third-party relative child inside a real preset group and retains its package until release', async () => { - const { manager, ctx, dir, addon, bundle, trace } = await fixture() - bundle('addon', [{ preset: 'preset-standard', insert: [{ - id: 'relative-group', name: 'cordis:group', group: true, config: [{ - id: 'relative-child', name: './nested/relative.mjs', config: { value: 'nested-preset' }, - }], - }] }]) - mkdirSync(join(addon, 'nested')) - writeFileSync(join(addon, 'nested/relative.mjs'), [ - 'export function apply(ctx, config) {', - ' ctx.get("scopedBundleTrace").push(`relative:start:${config.value}`)', - ' ctx.effect(() => () => { ctx.get("scopedBundleTrace").push("relative:stop") })', - '}', - '', - ].join('\n')) - const moduleName = pathToFileURL(join(addon, 'nested/relative.mjs')).href - const declared = (await manager.listBundles()).find(row => row.name === 'addon')?.rows - .find(row => row.rowId === 'relative-child') - expect(declared).toEqual({ rowId: 'relative-child', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName }) - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - expect(trace).toEqual(['relative:start:nested-preset']) - expect((await ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'standard')?.rows) - .toMatchObject([{ entryId: 'relative-child', moduleName, enabled: true }]) - - const owner = createScope(ctx, {}) - onTestFinished(() => owner.dispose()) - await ctx.agentPresets.mount(owner.ctx, 'standard') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - expect(ctx.agentPresets.inspectCompositions(owner.ctx)[0]?.modules).toMatchObject([{ moduleName }]) - expect(trace).toEqual(['relative:start:nested-preset']) - const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const manifest = readProfileManifest('test', dir) - delete manifest.dependencies?.addon - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') } - }) - onTestFinished(() => { remove.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) - expect(remove).not.toHaveBeenCalled() - await owner.dispose() - expect(trace).toEqual(['relative:start:nested-preset', 'relative:stop']) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied', changed: true }) - expect(remove).toHaveBeenCalledOnce() -}) - -it('retains a failed selection, reports affected preset failures again, and keeps unrelated failures as warnings', async () => { - const { manager, dir, bundle, ctx } = await fixture() - bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }]) - const result = await manager.setBundleEnabled('broken', true) - expect(result).toMatchObject({ application: 'failed', changed: true, error: { diagnostic: expect.stringContaining('agent preset standard') as string } }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('broken') - expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed', changed: false }) - expect((await ctx.agentPresets.list()).find(row => row.id === 'standard')?.broken).toContain('scoped plugin failed') - bundle('unrelated', [{ insert: [{ id: 'unrelated', name: './plugin.mjs' }] }]) - expect(await manager.setBundleEnabled('unrelated', true)).toMatchObject({ application: 'applied', warnings: [expect.stringContaining('agent preset standard')] }) - expect(await manager.setBundleEnabled('broken', false)).toMatchObject({ application: 'applied' }) - expect((await ctx.agentPresets.list()).every(row => row.broken === undefined)).toBe(true) -}) - -it('refuses a no-op scoped enable when its Host preset row already failed before registering', async () => { - const { manager, ctx, bundle, dir } = await fixture() - bundle('invalid-definition', [{ id: 'preset-standard', config: { id: '', plugins: [] } }]) - expect(await manager.setBundleEnabled('invalid-definition', true)).toMatchObject({ application: 'failed' }) - expect((await ctx.agentPresets.list()).map(row => row.id)).not.toContain('standard') - bundle('scoped-noop', [{ preset: 'preset-standard', insert: [] }]) - expect(await manager.setBundleEnabled('scoped-noop', true)).toMatchObject({ - application: 'failed', changed: true, error: { diagnostic: expect.stringContaining('Preset id must not be empty') as string }, - }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('scoped-noop') -}) - -it.each([{ id: 'standard' }, { id: 'standard', plugins: [] }])( - 'keeps existing preset failures as warnings when an unrelated Host config is %j', async (config) => { - const { manager, bundle, ctx } = await fixture() - expect(await ctx.agentPresets.list()).toMatchObject([{ id: 'cordis', definitionEntryId: 'preset-cordis' }, { id: 'standard', definitionEntryId: 'preset-standard' }]) - expect((await ctx.agentPresets.remoteExportList()).presets.every(row => !Object.hasOwn(row, 'definitionEntryId'))).toBe(true) - bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }]) - expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' }) - bundle('ordinary', [{ insert: [{ id: 'ordinary', name: './plugin.mjs', config }] }]) - expect(await manager.setBundleEnabled('ordinary', true)).toMatchObject({ - application: 'applied', warnings: [expect.stringContaining('agent preset standard')], - }) - }, -) - -it('reports invalid composition immediately without HMR while retaining the saved selection', async () => { - const { manager, dir } = await fixture({ hmr: false, presets: false }) - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ - application: 'failed', changed: true, error: { diagnostic: expect.stringContaining(join('addon', 'cordis.patch.yml')) as string }, - }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'restart-required', changed: true }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon') -}) - -it('refuses Remove after Off while an old Agent scope retains the bundle, then removes after release', async () => { - const { manager, ctx, dir, trace } = await fixture() - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - const agent = createScope(ctx, {}) - onTestFinished(() => agent.dispose()) - await ctx.agentPresets.mount(agent.ctx, 'standard') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - expect(ctx.agentPresets.inspectCompositions(agent.ctx)[0]?.modules).toHaveLength(1) - const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const manifest = readProfileManifest('test', dir) - delete manifest.dependencies?.addon - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') } - }) - onTestFinished(() => { remove.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' }, changed: false }) - expect(remove).not.toHaveBeenCalled() - await agent.dispose() - expect(trace.filter(item => item === 'stop')).toHaveLength(2) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied', changed: true }) - expect(remove).toHaveBeenCalledOnce() -}) - -it.each([false, true].flatMap(anonymous => [false, true].map(archive => ({ anonymous, archive })))) -('removes a released contribution while base presets keep using the same shared module (anonymous=$anonymous, archive=$archive)', async ({ anonymous, archive }) => { - // Resolver identities must be created under the same carrier that later refreshes them. - if (archive) { - const previousPkg = Object.getOwnPropertyDescriptor(process, 'pkg') - onTestFinished(() => { - if (previousPkg === undefined) Reflect.deleteProperty(process, 'pkg') - else Object.defineProperty(process, 'pkg', previousPkg) - }) - Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) - } - const { manager, ctx, dir, addon } = await fixture() - const shared = join(dir, 'node_modules', 'shared-fixture') - mkdirSync(shared) - writeFileSync(join(shared, 'package.json'), JSON.stringify({ name: 'shared-fixture', version: '1.0.0', type: 'module', exports: './index.mjs' })) - writeFileSync(join(shared, 'index.mjs'), 'export function apply() {}\n') - const core = join(dir, 'node_modules', 'core') - const corePatches = JSON.parse(readFileSync(join(core, 'cordis.patch.yml'), 'utf8')) as ProfilePatch[] - for (const row of corePatches[0]!.insert!) { - if (row.id === 'preset-standard') row.config = { id: 'standard', plugins: [{ id: 'base-shared', name: 'shared-fixture' }] } - if (row.id === 'preset-cordis') row.config = { id: 'cordis', plugins: [{ id: 'addon-shared', name: 'shared-fixture' }] } - } - writeFileSync(join(core, 'cordis.patch.yml'), JSON.stringify(corePatches)) - writeFileSync(join(addon, 'cordis.patch.yml'), JSON.stringify([{ - preset: 'preset-standard', insert: [{ ...anonymous ? {} : { id: 'addon-shared' }, name: 'shared-fixture' }], - }])) - for (const packageDir of [core, addon]) { - const manifest = readProfileManifest('test', packageDir) - manifest.dependencies = { 'shared-fixture': '1.0.0' } - writeFileSync(join(packageDir, 'package.json'), JSON.stringify(manifest)) - } - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - const owner = createScope(ctx, {}) - onTestFinished(() => owner.dispose()) - await ctx.agentPresets.mount(owner.ctx, 'standard') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - expect(ctx.agentPresets.inspectCompositions(owner.ctx)).toMatchObject([{ - id: 'standard', definitionEntryId: 'preset-standard', modules: [ - { entryId: 'base-shared', moduleName: 'shared-fixture' }, - { entryId: anonymous ? expect.any(String) as string : 'addon-shared', moduleName: 'shared-fixture' }, - ], - }]) - if (archive) { - const original = fs.realpathSync - onTestFinished(() => { fs.realpathSync = original; syncBuiltinESMExports() }) - fs.realpathSync = new Proxy(original, { - apply(target, receiver: unknown, args: unknown[]): unknown { - if (['package.json', 'index.mjs'].some(file => String(args[0]).endsWith(join('shared-fixture', file)))) { - throw Object.assign(new Error('archive-backed files have no realpath entry'), { code: 'ENOENT' }) - } - return Reflect.apply(target, receiver, args) - }, - }) - syncBuiltinESMExports() - expect(() => fs.realpathSync(join(shared, 'package.json'))).toThrow('archive-backed') - expect(readFileSync(join(shared, 'index.mjs'), 'utf8')).toContain('export function apply') - } - const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const manifest = readProfileManifest('test', dir) - delete manifest.dependencies?.addon - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') } - }) - onTestFinished(() => { remove.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) - expect(remove).not.toHaveBeenCalled() - await owner.dispose() - expect(ctx.agentPresets.inspectCompositions().flatMap(preset => preset.modules).map(module => module.moduleName)) - .toEqual(['shared-fixture', 'shared-fixture']) - const removed = await manager.removeBundle('addon') - expect(removed, JSON.stringify(removed.error)).toMatchObject({ application: 'applied', changed: true }) - expect(remove).toHaveBeenCalledOnce() -}) - -it('removes an unrelated bundle when another retained row with the same id has lost its module file', async () => { - const { manager, ctx, dir, addon, bundle } = await fixture() - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - const owner = createScope(ctx, {}) - onTestFinished(() => owner.dispose()) - await ctx.agentPresets.mount(owner.ctx, 'standard') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - bundle('unrelated', [{ preset: 'preset-standard', insert: [{ id: 'shared-id', name: './plugin.mjs' }] }]) - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { ...manifest.dependencies, unrelated: '1.0.0' } - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - rmSync(join(addon, 'plugin.mjs')) - const remove = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ - exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log'), - }) - onTestFinished(() => { remove.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) - expect(remove).not.toHaveBeenCalled() - const resolution = vi.spyOn(appBoot, 'resolvePluginResource').mockImplementationOnce(() => { throw new Error('resolver unavailable') }) - onTestFinished(() => { resolution.mockRestore() }) - expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'failed', error: { diagnostic: 'resolver unavailable' } }) - expect(remove).not.toHaveBeenCalled() - resolution.mockRestore() - expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'applied' }) - expect(remove).toHaveBeenCalledOnce() - expect(ctx.agentPresets.inspectCompositions(owner.ctx)[0]?.modules).toHaveLength(1) -}) - -it('retains contributed builtin rows even when no package owns their module names', async () => { - const { manager, ctx, bundle } = await fixture() - ctx.loader.builtins['retained-fixture'] = { apply() {} } - bundle('addon', [{ preset: 'preset-standard', insert: [{ id: 'builtin', name: 'cordis:retained-fixture' }] }]) - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - const agent = createScope(ctx, {}) - onTestFinished(() => agent.dispose()) - await ctx.agentPresets.mount(agent.ctx, 'standard') - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - const inspect = ctx.agentPresets.inspectCompositions.bind(ctx.agentPresets) - const projection = vi.spyOn(ctx.agentPresets, 'inspectCompositions').mockImplementation(() => inspect().map(composition => ({ - ...composition, modules: composition.modules.map(({ baseUrl: _baseUrl, ...module }) => module), - }))) - onTestFinished(() => { projection.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) -}) - -it('refuses removal when a different module from the same package remains in a preset', async () => { - const { manager, ctx, dir, addon } = await fixture() - const source = readFileSync(join(addon, 'plugin.mjs'), 'utf8') - writeFileSync(join(addon, 'other.mjs'), source) - expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) - writeFileSync(join(dir, 'cordis.patch.yml'), JSON.stringify([{ - preset: 'preset-standard', insert: [{ id: 'independent', name: pathToFileURL(join(addon, 'other.mjs')).href }], - }])) - expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) - expect(ctx.agentPresets.inspectCompositions().some(row => row.modules.some(module => module.moduleName.endsWith('/other.mjs')))).toBe(true) - const remove = vi.spyOn(operations, 'runProfilePnpm') - onTestFinished(() => { remove.mockRestore() }) - expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) - expect(remove).not.toHaveBeenCalled() -}) - -async function requestTitle(session: Session): Promise { - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Title for provider switch' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) - await new Promise((resolve) => { queueMicrotask(resolve) }) - session.append('request/header', { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' }) -} - -it('drains and rejects stale title completions before activating each replacement in both directions', async () => { - const { manager, ctx, bundle, calls, entered } = await fixture({ title: true }) - bundle('titles', [ - { id: 'default-title', disabled: true }, - { insert: [{ id: 'optional-title', name: 'cordis:title-provider', config: { id: 'optional' } }] }, - ]) - for (const [enabled, expected] of [[true, 'default'], [false, 'optional']] as const) { - entered.current = Promise.withResolvers() - const session = ctx.sessions.create(SessionId(`title-switch-${String(enabled)}`)) - await requestTitle(session) - await entered.current.promise - const call = calls.at(-1)! - expect(call.id).toBe(expected) - const aborted = Promise.withResolvers() - call.request.signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) - let complete = false - const switching = manager.setBundleEnabled('titles', enabled).then((result) => { complete = true; return result }) - onTestFinished(async () => { - call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) }) - await switching - }) - await aborted.promise - expect(complete).toBe(false) - expect(calls).toHaveLength(enabled ? 1 : 2) - call.pending.resolve({ title: 'stale result', messageSeqs: call.request.messages.map(message => message.seq) }) - expect(await switching).toMatchObject({ application: 'applied' }) - expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') - } - entered.current = Promise.withResolvers() - const final = ctx.sessions.create(SessionId('title-switch-final')) - await requestTitle(final) - await entered.current.promise - expect(calls.at(-1)?.id).toBe('default') - calls.at(-1)?.pending.resolve({ title: 'final title', messageSeqs: calls.at(-1)!.request.messages.map(message => message.seq) }) -}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index 643f870407..ba181795a1 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -64,9 +64,6 @@ }, { "path": "../../interaction/user-approval" - }, - { - "path": "../../preset/agent-preset-registry" } ] } diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index b8b29a0453..ca0a12fc89 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -9,8 +9,8 @@ en: 1393fc0c2c6cf60c zh: 61e2f2c46463101b /deepseek-ai-dsh-sdk-app/summary: - en: f96841d96dc95a30 - zh: 88aa3e92ae1deb9c + en: 5583f814d74d685f + zh: 7d52ecc2cdcd78d4 /deepseek-ai-dsh-sdk-app/table-of-contents: en: 4e53164159b5ce41 zh: 89582dc0294d0776 @@ -20,16 +20,16 @@ /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona: - en: af149b1e7ed0a5fc - zh: fe483822d03deddb -/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/what-the-model-sees: - en: afe0af085d77833e - zh: f41ef2a2219f1248 -/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/token-effect: - en: 371d28831239e0a8 - zh: 57595359ff30a259 -/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/kv-cache-effect: +/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context: + en: 4f88e647c6df4f42 + zh: 2bd3e4b0bdfe9412 +/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/what-the-model-sees: + en: 40f68e7d3f181ed6 + zh: 64b0d7c8bf63dfa1 +/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/token-effect: + en: 92d8ecb761e3ca7b + zh: e14c71ee2c31e818 +/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/kv-cache-effect: en: 5b0e33295fb90e19 zh: 4d48ca75bb677202 /deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work: diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index df3d0aa353..e2615fc435 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider with its own profile name. +The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider with its own profile name. ## Table of Contents @@ -40,15 +40,15 @@ The packaged Python runtime enables the [Office runtime query and skills](../../ ## Model Experience -### SDK coding-agent persona +### SDK model context #### What the model sees -The profile supplies `You are a coding agent powered by the {{model}} model.` before first-party guidance; the SDK initialization route resolves `{{model}}`. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. +The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, without adding a task-specific persona. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. #### Token effect -One short stable persona plus the data-dependent base prompt sections and selected tool schemas. +The base prompt sections and selected tool schemas determine token usage; this profile adds no persona text. #### KV Cache effect diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index 026d442b1a..610f4bef5c 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) bundle 复用同一个启动提供方,并提供自己的 profile 名称。 +以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) bundle 复用同一个启动提供方,并提供自己的 profile 名称。 ## 目录 @@ -40,15 +40,15 @@ SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_rep ## 模型体验 -### SDK coding agent persona +### SDK 模型上下文 #### 模型看到什么 -profile 在第一方指导之前提供 `You are a coding agent powered by the {{model}} model.`;SDK 初始化路由解析 `{{model}}`。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。 +profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,不额外添加特定任务的 persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。 #### Token 影响 -一段简短稳定的 persona,加上随数据变化的 base 提示词段落与所选工具 schema。 +base 提示词段落与所选工具 schema 决定 token 用量;此 profile 不额外添加 persona 文本。 #### KV Cache 影响 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index d852a0a41d..1c0beaae95 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -1,10 +1,5 @@ # The SDK application over dsh-base. Stdout belongs exclusively to JSON-RPC. -- id: system-prompt - config: - personaPrefix: >- - You are a coding agent powered by the {{model}} model. - - id: session-title-llm disabled: true diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts index 7e28273305..e0ed9d3e57 100644 --- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -43,9 +43,14 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () = } if (text.length > token.length) { // Typing at the token boundary lands in the styled node; split the - // overflow back out so only the token itself carries the color. - const [tokenNode] = node.splitText(token.length) + // overflow back out so only the token itself carries the color. The + // split copies the token style onto the overflow; it is cleared in this + // pass because Lexical's normalization merges same-style neighbours + // before the overflow's own transform runs, which would rejoin the two + // halves and loop until Lexical aborts the update. + const [tokenNode, overflow] = node.splitText(token.length) if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE) + overflow?.setStyle('') return } if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index d3993c70df..ca03589a52 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -12,6 +12,7 @@ import './control-row-dom.ts' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { $getRoot, $isElementNode, $isTextNode } from 'lexical' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -344,6 +345,31 @@ describe('scenario H: backspace breaks the token', () => { expect(b.shell.snapshot.phase).toBe('plain') }) + it('keeps text typed at the end of the claimed token when the browser inserts it natively', async () => { + const b = await bench() + b.type('/goal') + await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) }) + fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 }) + expect(b.shell.snapshot.phase).toBe('claimed') + b.type('/goal 发布') + // A caret at the token/argument boundary sits at the end of the styled + // token node; native insertion writes there and Lexical syncs the DOM + // text with setTextContent plus a caret select. + act(() => { + b.shell.editor.update(() => { + const paragraph = $getRoot().getFirstChild() + const token = $isElementNode(paragraph) ? paragraph.getFirstChild() : null + if (!$isTextNode(token)) throw new Error('missing token node') + token.setTextContent('/goal 立即') + token.select(8, 8) + }, { discrete: true }) + }) + expect(b.shell.snapshot.draft).toBe('/goal 立即发布') + expect(b.shell.snapshot.phase).toBe('claimed') + act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ') + }) + it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() b.type('/goal') diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 0f1c0045fa..a27dfe1b78 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -17,11 +17,10 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { { name: 'conversation.view', id: 'x', key: 'k' }, (_p: ConvViewProps) => null) // 3. Component props must stay within the composed contract: an - // undeclared member cannot be required. + // undeclared member cannot be required. The call stays on one line so + // both compilers anchor the overload failure inside the directive. // @ts-expect-error component demands a prop no share supplies - slots.register( - { name: 'conversation.view', id: 'y' }, - (_p: ConvViewProps & { phantom: number }) => null) + slots.register({ name: 'conversation.view', id: 'y' }, (_p: ConvViewProps & { phantom: number }) => null) // 4. Views receive no renderSlot — the ring's entries declare no children. const renderless = (props: ConvViewProps): ReactNode => { // @ts-expect-error views receive no renderSlot — no sub-slot delegation diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index d9c4fc68fc..8319a1f448 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2d29970dcd0b2836 zh: 73f6b519546d2b6b /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: - en: b371def25a3598a2 - zh: 05fd50c186551936 + en: 620c6a3ee20848bd + zh: a9429f047a12bb4c /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/configuration-pages: en: c32019330fd9586d zh: dd22110abd10159c diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index de79ee0b1b..42a133c1e4 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -64,7 +64,7 @@ A bundle's page shows its full package name under the title. Its **Source** sect ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. Scoped preset rows show their preset target beside the declared id and their current composition state; their controls and configuration pages remain read-only, with guidance to configure them through the preset or bundle. Equal child ids in different presets retain distinct list and navigation identities; detail contributions receive the same preset identity in each row reference. A preset row with an unevaluated enablement expression displays Conditional, without an Off count or switch. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. ### Configuration pages diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 936cf2ef46..4666c61c7c 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -64,7 +64,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。限定到 preset 的行在声明 id 旁显示 preset 目标及当前组合状态;其控件和配置页面保持只读,并指引用户通过所属预设或组合包进行配置。不同 preset 中相同的子条目 id 在列表和导航中保留不同标识;详情扩展收到的行引用也携带同样的 preset 标识。启用表达式尚未求值的 preset 行显示「取决于条件」,不计入关闭数量,也不显示开关。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 ### 配置页 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index afd254073f..44660337bb 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -26,7 +26,7 @@ import type { createNavigationStore } from './navigation-store.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts' import { - asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, packageRowKey, + asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type RegistryChoice, } from './manager-store.ts' @@ -129,7 +129,7 @@ const PHASE_STATES = { /** The count line over a pack's components: the total, then only the states that occur. */ function partsSummary(rows: readonly PackageRow[], t: Translate): string { const failed = rows.filter(row => row.phase === 'failed').length - const off = rows.filter(row => !row.enabled && !row.conditional).length + const off = rows.filter(row => !row.enabled).length const running = rows.filter(row => row.enabled && row.phase === 'active').length return [ t('partsCountTotal', { count: String(rows.length) }), @@ -195,7 +195,6 @@ function RowSwitch({ row, title, t, busy, onChange }: { readonly busy: boolean readonly onChange: (enabled: boolean) => void }): ReactNode { - if (row.conditional) return null const locked = row.readOnlyReason !== undefined || row.entryId === undefined return ( ({ row, ...rowText(row, resolveText) })) const shown = query === '' ? localized : localized.filter(({ row, title, description }) => - [title, description, row.rowId, row.preset, row.moduleName].some(value => value?.toLowerCase().includes(query))) + [title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query))) return (
@@ -270,10 +268,10 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
    {shown.map(({ row, title, description }) => (
  • @@ -287,7 +285,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { ) : {title}} {description === undefined ? null : {description}} - {title === row.rowId && row.preset === undefined ? null : {row.preset === undefined ? row.rowId : `${row.preset}/${row.rowId}`}} + {title === row.rowId ? null : {row.rowId}} {title === row.moduleName ? null : {row.moduleName}}
    @@ -522,8 +520,7 @@ function ItemCard({ item, t, onOpen, renderSlot }: { /** One row as the detail slots see it. */ function rowRef(row: PackageRow): PluginRowRef { - return { rowId: row.rowId, ...row.preset === undefined ? {} : { preset: row.preset }, - moduleName: row.moduleName, enabled: row.enabled } + return { rowId: row.rowId, moduleName: row.moduleName, enabled: row.enabled } } /** One bundle as the detail slots see it. */ @@ -1402,7 +1399,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined - const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => packageRowKey(row) === view.rowId) : undefined + const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name)) const setRowEnabled = (row: PackageRow, enabled: boolean): void => { @@ -1410,8 +1407,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) } const configure = (pkg: PackageView): RowConfigure => ({ - has: row => row.preset === undefined && ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), - open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: packageRowKey(row) }) }, + has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), + open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId }) }, }) const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) || (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 79e889931f..570499ca4f 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -58,7 +58,6 @@ export const zh = { backToPackage: '返回 {name}', configureRow: '配置 {name}', rowStateIdle: '未运行', - rowStateConditional: '取决于条件', uninstall: '卸载', uninstallLabel: '卸载 {name}', installTitle: '添加插件', @@ -201,7 +200,6 @@ export const zh = { failedRowDisable: '组件停用失败:{reason}', reasonManagementRequired: '插件管理所需,不能停用或卸载', reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项', - reasonPresetManaged: '通过所属预设或组合包配置此项', reasonUnknownPlugin: '找不到该插件', reasonInvalidSpec: '请输入有效的包名或地址', reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包', @@ -273,7 +271,6 @@ export const en = { backToPackage: 'Back to {name}', configureRow: 'Configure {name}', rowStateIdle: 'Not running', - rowStateConditional: 'Conditional', uninstall: 'Uninstall', uninstallLabel: 'Uninstall {name}', installTitle: 'Add plugin', @@ -416,7 +413,6 @@ export const en = { failedRowDisable: 'Could not disable the component: {reason}', reasonManagementRequired: 'Plugin management needs it; it cannot be switched off or uninstalled.', reasonUnaddressable: 'The profile patch cannot address this one uniquely.', - reasonPresetManaged: 'Configure this item through its preset or bundle.', reasonUnknownPlugin: 'No such plugin.', reasonInvalidSpec: 'Enter a valid package name or address.', reasonAmbiguousInstall: 'Which package was installed cannot be told from the dependency change.', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 69073f37d3..9dedb29b36 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -62,18 +62,14 @@ export type ManagerNotice = export interface PackageRow { /** The Loader entry carrying the row while the bundle is on; absent for a row of a bundle that is off. */ readonly entryId?: PluginEntryId - /** Declared row id, or an internal identity for an anonymous scoped insertion. */ + /** The row id as the bundle declares it. */ readonly rowId: string - /** Outer preset row id for a scoped, read-only contribution. */ - readonly preset?: string /** The module the row names. */ readonly moduleName: string /** Local package display text and metadata diagnostics supplied by the Host. */ readonly meta?: PluginLocalizedMeta /** Whether the entry runs; false for a row without a live entry. */ readonly enabled: boolean - /** Whether a preset expression has not yet resolved this row's enablement. */ - readonly conditional?: boolean /** The entry's fiber phase, null without a live fiber. */ readonly phase: PluginInfo['fiberPhase'] /** Why the Host refuses to switch the row, when it does. */ @@ -423,14 +419,6 @@ export function rowKey(entryId: string): string { return `row:${entryId}` } -/** Identify a bundle row independently of equal child ids in other presets. - * @param row Bundle declaration with its optional preset target. - * @returns Stable identity for list and detail navigation. - */ -export function packageRowKey(row: Pick): string { - return row.preset === undefined ? row.rowId : JSON.stringify([row.preset, row.rowId]) -} - /** * One bundle as the page shows it: its rows joined with the Host's entries. * @param bundle - the Host's bundle. @@ -440,17 +428,14 @@ export function packageRowKey(row: Pick): string export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): PackageView { const rows = bundle.rows.map((row): PackageRow => { const live = row.entryId === undefined ? undefined : plugins.find(plugin => plugin.entryId === row.entryId) - const readOnlyReason = row.readOnlyReason ?? live?.readOnlyReason return { rowId: row.rowId, - ...row.preset === undefined ? {} : { preset: row.preset }, moduleName: row.moduleName, - enabled: row.composition === undefined ? live?.enabled ?? false : row.composition.enabled === true, - phase: row.composition?.fiberPhase ?? live?.fiberPhase ?? null, - ...row.composition?.enabled === 'conditional' ? { conditional: true } : {}, + enabled: live?.enabled ?? false, + phase: live?.fiberPhase ?? null, ...row.meta === undefined ? {} : { meta: row.meta }, ...row.entryId === undefined ? {} : { entryId: row.entryId }, - ...readOnlyReason === undefined ? {} : { readOnlyReason }, + ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, } }) return { diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 6c01da2e7c..33998a49ae 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -48,7 +48,6 @@ function registryHost(registry: string): string { const CODE_KEYS = { 'management-required': 'reasonManagementRequired', 'unaddressable': 'reasonUnaddressable', - 'preset-managed': 'reasonPresetManaged', 'unknown-plugin': 'reasonUnknownPlugin', 'invalid-spec': 'reasonInvalidSpec', 'ambiguous-install': 'reasonAmbiguousInstall', diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index f2dce8cc57..b7d734ae6e 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -30,10 +30,8 @@ export interface PluginConfigViewProps { /** One row of a bundle as a detail contribution sees it. */ export interface PluginRowRef { - /** Declared row id, or an internal identity for an anonymous scoped insertion. */ + /** The row id as the bundle's patch declares it. */ readonly rowId: string - /** Outer preset row id; absent for a Host row. */ - readonly preset?: string /** The module the row names. */ readonly moduleName: string /** Whether the row's entry runs. */ diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 6c872c6437..7d19866807 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -732,55 +732,6 @@ describe('PluginManagerPage', () => { } }) - it('shows distinct preset targets and keeps scoped rows read-only despite a matching root configuration page', () => { - const rows: PackageRow[] = ['preset-standard', 'preset-cordis'].map(preset => ({ - rowId: 'shared', preset, moduleName: 'shared-tool', enabled: true, phase: 'active', - readOnlyReason: 'preset-managed', meta: { title: 'shared' }, - })) - const subjects: PluginsSubject[] = [] - const { actions, setLanguage } = renderTab({ packages: [pkg({ rows })] }, { rows: new Set(['dsh-better-sidebar#shared']) }, { - 'plugins.detail.actions:': (_view, owner) => { - const subject = subjectOf(owner) - if (subject !== undefined) subjects.push(subject) - return null - }, - }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - const listed = [...document.querySelectorAll('[data-plugin-row]')] - expect(new Set(listed.map(item => item.getAttribute('data-plugin-row'))).size).toBe(2) - expect(subjects.at(-1)).toMatchObject({ kind: 'bundle', pkg: { rows: [ - { rowId: 'shared', preset: 'preset-standard' }, { rowId: 'shared', preset: 'preset-cordis' }, - ] } }) - expect(screen.getByText('preset-standard/shared')).toBeTruthy() - expect(screen.getByText('preset-cordis/shared')).toBeTruthy() - for (const item of listed) { - const toggle = within(item as HTMLElement).getByRole('switch') - expect(toggle).toHaveProperty('disabled', true) - expect(toggle.getAttribute('title')).toBe(en.reasonPresetManaged) - } - expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'shared') })).toBeNull() - expect(actions.setRowEnabled).not.toHaveBeenCalled() - setLanguage(zh) - for (const item of listed) { - expect(within(item as HTMLElement).getByRole('switch').getAttribute('title')).toBe(zh.reasonPresetManaged) - } - }) - - it('shows an unevaluated preset condition without an Off label or switch', () => { - const rows: PackageRow[] = [{ rowId: 'conditional', preset: 'preset-standard', moduleName: 'tool', - enabled: false, conditional: true, phase: null, readOnlyReason: 'preset-managed' }] - const { setLanguage } = renderTab({ packages: [pkg({ rows })] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - const item = document.querySelector('[data-plugin-row]')! - expect(within(item).getByText(en.rowStateConditional)).toBeTruthy() - expect(within(item).queryByText(en.partOff)).toBeNull() - expect(within(item).queryByRole('switch')).toBeNull() - expect(item.getAttribute('data-state')).toBeNull() - expect(screen.queryByText(en.partsCountOff.replace('{count}', '1'), { exact: false })).toBeNull() - setLanguage(zh) - expect(within(item).getByText(zh.rowStateConditional)).toBeTruthy() - }) - it('resolves row fields independently from bundle metadata and preserves subpath specifiers', () => { const rows = [ row({ moduleName: '@acme/dsh-sidebar/navigation', meta: { description: { en: 'Navigation description.' } } }), diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 90d8e7a10a..73b3c5b778 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -8,7 +8,7 @@ import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginIn import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ConfigLedger } from '../src/client/config-ledger.ts' -import { offeredRegistries, packageView, packageRowKey, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' +import { offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0' } } const ROW_ENTRY = 'include:sidebar' as PluginEntryId @@ -116,19 +116,6 @@ it('hands a custom page the shared configuration form of its entry', () => { }) describe('packageView', () => { - it('keeps scoped rows read-only with distinct identities and their observed preset state', () => { - const view = packageView({ ...BUNDLE, rows: [ - { rowId: 'shared', preset: 'preset-standard', moduleName: 'tool', readOnlyReason: 'preset-managed', composition: { enabled: true, fiberPhase: 'active' } }, - { rowId: 'shared', preset: 'preset-cordis', moduleName: 'tool', readOnlyReason: 'preset-managed', composition: { enabled: 'conditional', fiberPhase: null } }, - ] }, []) - expect(view.rows).toEqual([ - { rowId: 'shared', preset: 'preset-standard', moduleName: 'tool', enabled: true, phase: 'active', readOnlyReason: 'preset-managed' }, - { rowId: 'shared', preset: 'preset-cordis', moduleName: 'tool', enabled: false, phase: null, conditional: true, readOnlyReason: 'preset-managed' }, - ]) - expect(new Set(view.rows.map(packageRowKey)).size).toBe(2) - expect(packageRowKey({ rowId: 'ordinary' })).toBe('ordinary') - }) - it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 002e32ed58..0bbdb05d37 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -39,11 +39,11 @@ en: 2a8b93869eb5a2f9 zh: ddab8e738d188a61 /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: - en: 6bc52b8ef503eeda - zh: 3290052ebc47baf6 + en: d079cf181ece9267 + zh: 047f76fecfc5497c /deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration: - en: da056b9f0dde4ab3 - zh: ba9ce3b19c17ca22 + en: 3c5721bc00a0bbf4 + zh: 711f832343ffbb75 /deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow: en: 28939261ccc7defe zh: 9d157a321307ebd4 @@ -105,8 +105,8 @@ en: 83531f2a13cc1f0e zh: 81a780d637dd8c5d /deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work: - en: 7f93fa258b0e39ba - zh: 819075791542b01c + en: a57822fab5e6fdfd + zh: 39f97b0c5040f7c8 /deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 331a1c37d7..62dcd35a92 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -109,9 +109,11 @@ The loop deep-freezes each derived message identity on its first request and reu Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement. +Factory unload retains the inbox and turn-boundary projections through scope cleanup, including cancellation from scoped abort listeners, until every Agent teardown and startup task settles. Cleanup failures do not release those projections early. Caller unload, factory unload, and handle disposal await the same teardown. + ### Persistence integration -The loop is the production acquisition point for session write handles. When `ctx.sessionPersistence` is mounted, `create`/`createAgent` call `persistence.create(header)` — storing the durable identity and taking write ownership before publication — and append the constructor seed through the handle; `resume` calls `persistence.open(id, 'write')` first (excluding a concurrent resume of the same id), reads the physically valid log through the handle, and appends `interruptedTurnClosers` for a log crashed mid-turn as an ordinary batch — semantic crash repair is the agent layer's job, not a storage entry point. Immediately before publication, `appendUnstoredSuffix` stores any events appended during the setup window (seed markers, delegation policy records), which never re-emit through `session/event`. Once published, the mounted backend routes the session's `session/event` batches, `session/flush` barriers, and `session/disposed` retirement into the active write handle by session id; the loop touches storage only through the handle it owns. The memoized teardown closes the handle — close drains any routed buffer — after the loop commits the session's closing events, provably releasing write ownership. Without a backend, sessions are memory-only and nothing else changes. +The loop is the production acquisition point for session write handles. When `ctx.sessionPersistence` is mounted, `create`/`createAgent` call `persistence.create(header)` — storing the durable identity and taking write ownership before publication — and append the constructor seed through the handle; `resume` calls `persistence.open(id, 'write')` first (excluding a concurrent resume of the same id), reads the physically valid log through the handle, and appends `interruptedTurnClosers` for a log crashed mid-turn as an ordinary batch — semantic crash repair is the agent layer's job, not a storage entry point. Immediately before publication, `appendUnstoredSuffix` stores any events appended during the setup window (seed markers, delegation policy records), which never re-emit through `session/event`. Once published, the mounted backend routes the session's `session/event` batches, `session/flush` barriers, and `session/disposed` retirement into the active write handle by session id; the loop touches storage only through the handle it owns. With the backend still mounted, teardown closes the handle after the driver commits its closing events; close drains the routed buffer and releases write ownership. Without a backend, sessions are memory-only and nothing else changes. ### Turn and step flow @@ -211,6 +213,7 @@ Recovery results append after the existing history and preserve its reusable pre These limits define when the loop needs special care. They are current package constraints, not a task backlog. - **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)). +- **Root shutdown persistence** — shutting down the root context with an active Agent can leave its final `turn/end` in memory but absent from the persisted log. Agent teardown completing does not guarantee that this event was saved. - **Previously closed inconsistent history** — failed-step recovery does not rewrite unanswered calls in already-closed historical turns. - **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona and tool composition are available only through the programmatic `ctx.agents.create()` / `resume()` factory options. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 95bd51dde6..ee355a328d 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -109,9 +109,11 @@ const handle = await ctx.agents.create({ 创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 +工厂卸载时在整个作用域清理期间保留 inbox 投影与轮次边界投影,包括作用域内 abort 监听器发起取消的时刻,直到所有 Agent teardown 和启动任务结束。清理失败不会使这些投影提前释放。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 + ### 持久化集成 -循环是会话写句柄在生产环境中的获取点。挂载 `ctx.sessionPersistence` 后,`create`/`createAgent` 调用 `persistence.create(header)`——在发布之前存储持久身份并取得写所有权——并通过句柄追加构造 seed;`resume` 先调用 `persistence.open(id, 'write')`(排除同 id 的并发恢复),通过句柄读取物理上有效的日志,并为在轮次中途崩溃的日志把 `interruptedTurnClosers` 作为普通批次追加——语义崩溃修复是 agent 层的职责,而非存储入口。发布前的最后一刻,`appendUnstoredSuffix` 存储 setup 窗口期间追加的事件(seed 标记、委派策略记录),它们绝不会经由 `session/event` 重新发出。发布之后,挂载的后端按会话 id 把该会话的 `session/event` 批次、`session/flush` 屏障与 `session/disposed` 退役路由进活跃写句柄;循环只通过它拥有的句柄触碰存储。记忆化的 teardown 在循环提交会话的收尾事件之后关闭句柄——close 会排空任何已路由的缓冲——可证明地释放写所有权。没有后端时,会话只存在于内存中,其余一切不变。 +循环是会话写句柄在生产环境中的获取点。挂载 `ctx.sessionPersistence` 后,`create`/`createAgent` 调用 `persistence.create(header)`——在发布之前存储持久身份并取得写所有权——并通过句柄追加构造 seed;`resume` 先调用 `persistence.open(id, 'write')`(排除同 id 的并发恢复),通过句柄读取物理上有效的日志,并为在轮次中途崩溃的日志把 `interruptedTurnClosers` 作为普通批次追加——语义崩溃修复是 agent 层的职责,而非存储入口。发布前的最后一刻,`appendUnstoredSuffix` 存储 setup 窗口期间追加的事件(seed 标记、委派策略记录),它们绝不会经由 `session/event` 重新发出。发布之后,挂载的后端按会话 id 把该会话的 `session/event` 批次、`session/flush` 屏障与 `session/disposed` 退役路由进活跃写句柄;循环只通过它拥有的句柄触碰存储。在后端仍保持挂载时,teardown 会在驱动器提交收尾事件后关闭句柄;close 排空已路由的缓冲并释放写所有权。没有后端时,会话只存在于内存中,其余一切不变。 ### 轮次与步骤流程 @@ -211,6 +213,7 @@ const handle = await ctx.agents.create({ 这些限制说明循环何时需要特别留意。它们是当前包约束,不是任务积压。 - **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占([原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md))。 +- **根上下文关停的持久化**:存在活跃 Agent 时,关停根上下文可能使最终的 `turn/end` 仅留在内存中,未写入持久化日志。Agent teardown 完成不保证该事件已保存。 - **此前已关闭的不一致历史**:失败步骤恢复不会改写已关闭历史轮次中尚无结果的调用。 - **配置标签默认对应新会话**:省略 `sessionId` 时,每次启动都会创建新的 `${id}-session-`;如需确切的恢复或创建行为,必须显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。 - **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona 与工具组合。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index f8c58d32ff..ca4532af8c 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -94,6 +94,12 @@ export const turnBoundaryProjectionDefinition = { }, } satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> +/** Re-throw one collected failure as itself, or several as one `AggregateError`. */ +function throwCollectedFailures(failures: readonly unknown[], message: string): void { + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, message) +} + /** Factory-level ownership: live agent teardowns plus config startup work. */ class FactoryOwnership { private accepting = true @@ -140,10 +146,15 @@ class FactoryOwnership { this.accepting = false this.teardown.abort(new Error('agent loop is not active')) this.inactive.resolve() - await Promise.all([ + const settlements = await Promise.allSettled([ ...[...this.liveAgents].map(dispose => dispose()), ...this.startupTasks, ]) + const failures: unknown[] = [] + for (const result of settlements) { + if (result.status === 'rejected') failures.push(result.reason) + } + throwCollectedFailures(failures, 'agent loop disposal failed') } } @@ -361,11 +372,19 @@ export class AgentLoop extends Service implements AgentFactory { validateConfiguredAgents(this.config.agents) // Register only after every config validation above has passed, so a // rejected constructor leaves no projection unit behind. - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - ctx.sessionProjections.register(inboxProjectionDefinition) - this.ownership = new FactoryOwnership(ctx.fiber) + const ownership = this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } - ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') + ctx.effect(function* () { + const unregisterTurnBoundary = ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + yield unregisterTurnBoundary + const unregisterInbox = ctx.sessionProjections.register(inboxProjectionDefinition) + yield unregisterInbox + // Cordis skips later disposers after a rejection; finally still releases projections. + yield () => ownership.dispose().finally(() => { + unregisterInbox() + unregisterTurnBoundary() + }) + }, 'agentLoop.transactions()') ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) ctx.systemPrompt.variable('model', context => context.agent?.options.model) @@ -520,10 +539,12 @@ export class AgentLoop extends Service implements AgentFactory { let publication: ReturnType> | undefined const machineReady = Promise.withResolvers() // Reverse teardown, memoized so every racing owner awaits one quiescence: - // stop the machine, drain and close the session's write path, leave the - // registries, unwind the scope, release bookkeeping. - const dispose = (ownerTriggered = false): Promise => (disposing ??= (async () => { - abort.abort(new Error(`agent "${id}" lifecycle disposed`)) + // stop the machine, unwind the scope, drain and close the session's write + // path, leave the registries, release bookkeeping. + const teardown = (): Promise => (disposing ??= (async () => { + abort.abort(new Error(this.ownership.isActive() + ? `agent "${id}" lifecycle disposed` + : 'agent loop is not active')) callerSignal?.removeEventListener('abort', onCallerAbort) this.ownership.signal.removeEventListener('abort', onFactoryTeardown) // Teardown failures are collected, never swallowed: registry, scope, @@ -542,15 +563,19 @@ export class AgentLoop extends Service implements AgentFactory { if (machine !== undefined) { machine.cancel({ kind: 'disposed' }) await machine.whenIdle() - await machine.scope.dispose() } } catch (error: unknown) { failures.push(error) } - // The loop above committed its closing events synchronously into the - // session; handle close drains them durably before releasing the write - // path. The close drain can be the first operation that surfaces a - // durability failure, so its error is retained, not logged away. + try { + await machine?.scope.dispose() + } catch (error: unknown) { + failures.push(error) + } + // Close drains events already routed into the handle. The backend's + // live-event listeners must stay mounted through driver shutdown to + // receive all closing events. A failed final drain is retained here + // rather than logged away. try { await handle?.close() } catch (error: unknown) { @@ -561,26 +586,38 @@ export class AgentLoop extends Service implements AgentFactory { detachSession?.() } finally { untrack() - if (!ownerTriggered) await unfollowOwner() - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) { - throw new AggregateError(failures, `agent "${id}" disposal failed`) } + throwCollectedFailures(failures, `agent "${id}" disposal failed`) })()) - const untrack = this.ownership.track(dispose) + let unfollowScope: (() => Promise | void) | undefined let unfollowOwner: () => Promise | void + const dispose = async (): Promise => { + try { + await teardown() + } finally { + await Promise.all([unfollowOwner(), unfollowScope?.()]) + } + } + const untrack = this.ownership.track(dispose) try { unfollowOwner = ownerCtx.effect(function* () { - machine = new ReactLoopAgent(loopCtx, id, options, session) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session) machineReady.resolve() - yield machine.scope.rawDispose - yield () => { - // Owner disposal owns the same quiescence boundary. Its teardown skips - // unregistering this already-running owner effect from inside itself. - if (disposing !== undefined) return + // Adopt the scope on its original fiber so it cannot unload before teardown. + // A loop already unloading during minting owns the provisional scope itself. + if (!INACTIVE_STATES.has(loopCtx.fiber.state)) { + unfollowScope = loopCtx.effect(function* () { + yield created.scope.rawDispose + yield () => teardown() + }, `agentLoop.scope(${id})`) + } + yield async () => { abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) - return dispose(true) + try { + await teardown() + } finally { + await unfollowScope?.() + } } }, `agentLoop.lifecycle(${id})`) /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */ diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts new file mode 100644 index 0000000000..4ed14d48a4 --- /dev/null +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -0,0 +1,178 @@ +import { setImmediate } from 'node:timers/promises' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed teardowns', async (failures) => { + const ctx = new Context() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + const loop = await ctx.plugin(AgentLoop, { agents: [] }) + const failed = [] + for (let index = 0; index < failures; index++) { + const handle = await ctx.agents.create({ sessionId: SessionId(`failed-cleanup-${index}`) }) + const error = new Error(`teardown failure ${index}`) + vi.spyOn(handle.agent, 'cancel').mockImplementation(() => { throw error }) + failed.push({ handle, error }) + } + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) + const { agent } = await ownerCtx.agents.create({ sessionId: SessionId('pending-cleanup') }) + const aborted = Promise.withResolvers() + let workExited = false + let scopeDisposedBeforeWorkExit = false + let projectionPresentAtWorkExit = false + let scopeDisposed = false + let abortHandled = 0 + const controller = new AbortController() + controller.signal.addEventListener('abort', () => { + agent.cancel({ kind: 'parent' }) + agent.cancel({ kind: 'parent' }) + abortHandled++ + }, { once: true }) + agent.ctx.effect(() => () => { + controller.abort() + controller.abort() + scopeDisposed = true + scopeDisposedBeforeWorkExit = !workExited + }) + const projections = ctx.sessionProjections + const work = agent.runMaintenance(async (signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + await release.promise + projectionPresentAtWorkExit = projections.stateOf(agent.session, 'inbox') !== undefined + && projections.stateOf(agent.session, 'turnBoundary') !== undefined + workExited = true + }) + const disposal = loop.dispose() + await aborted.promise + const ownerDisposal = owner.dispose() + // Drain runnable unload callbacks while the Agent is held at an explicit barrier. + await setImmediate() + release.resolve(undefined) + await work + await Promise.all([disposal, ownerDisposal]) + for (const { handle, error } of failed) await expect(handle.dispose()).rejects.toBe(error) + expect(scopeDisposedBeforeWorkExit).toBe(false) + expect(projectionPresentAtWorkExit).toBe(true) + expect(scopeDisposed).toBe(true) + expect(abortHandled).toBe(1) + expect(projections.stateOf(agent.session, 'inbox')).toBeUndefined() + expect(projections.stateOf(agent.session, 'turnBoundary')).toBeUndefined() + expect(ctx.agents.get(agent.id)).toBeUndefined() + expect(ctx.sessions.get(agent.session.id)).toBeUndefined() +}) + +it.each(['loop', 'root'] as const)('unloads %s while a background child is stopping', async (owner) => { + const ctx = new Context() + const release = Promise.withResolvers() + const root = mkdtempSync(join(tmpdir(), 'dsh-resource-lifecycle-')) + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + rmSync(root, { recursive: true, force: true }) + }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(JsonlSessionPersistence, { root }) + const loop = await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(SubagentSpawn) + await ctx.plugin(ToolSubagent, { provider: 'spawn' }) + ctx.jobs.attachController('resource-lifecycle') + const parentId = SessionId('background-parent') + const childCancelObserved = Promise.withResolvers() + ctx.on('agent/created', ({ agent }) => { + const cancel = agent.cancel.bind(agent) + vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => { + if (agent.id !== parentId) childCancelObserved.resolve(undefined) + cancel(cause, options) + }) + }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('unused')])) + const { agent: parent } = await ctx.agents.create({ + sessionId: parentId, agentOptions: { provider: 'mock', model: 'mock' }, + }) + const requestEntered = Promise.withResolvers() + ctx.on('agent/request', async (event, next) => { + expect(event.agent).not.toBe(parent) + requestEntered.resolve(event.agent) + await release.promise + return next() + }) + const started = await ctx.tools.execute({ + agent: parent, signal: new AbortController().signal, + callId: ToolCallId('background-start'), name: 'subagent', + arguments: { description: 'child', prompt: 'wait' }, + }) + expect(started.isError).toBe(false) + const child = await requestEntered.promise + for (const target of ['next-step', 'next-turn'] as const) { + child.send(createUserMessage({ content: [{ type: 'text', text: target }], source: { kind: 'user' } }), target, false) + } + const disposal = owner === 'loop' ? loop.dispose() : ctx.fiber.dispose() + // Teardown cancels the in-flight child while it is still inside agent/request. + await childCancelObserved.promise + release.resolve(undefined) + await disposal + expect(child.status).toBe('idle') + const events = child.session.snapshotEvents() + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } } }) + expect(events.filter(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')) + .toMatchObject([ + { data: { target: 'next-step', removedCount: 1 } }, + { data: { target: 'next-turn', removedCount: 1 } }, + ]) + if (owner === 'loop') { + const verify = new Context() + onTestFinished(async () => { await verify.fiber.dispose() }) + await verify.plugin(JsonlSessionPersistence, { root }) + const reader = await verify.sessionPersistence.open(child.id, 'read') + expect((await reader.read()).events).toEqual(events) + await reader.close() + } +}) + +it.each([false, true])('releases the Agent scope after cancel failure with scope failure %s', async (failScope) => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) + const handle = await ownerCtx.agents.create({ sessionId: SessionId('failed-owner-cleanup') }) + let scopeDisposed = false + handle.agent.ctx.effect(() => () => { scopeDisposed = true }) + const failure = new Error('teardown failure') + vi.spyOn(handle.agent, 'cancel').mockImplementation(() => { throw failure }) + const scopeFailure = new Error('scope teardown failure') + if (failScope) { + const fiber = handle.agent.ctx.fiber + const rawDispose = fiber.dispose + vi.spyOn(fiber, 'dispose').mockImplementation(async () => { + await rawDispose() + throw scopeFailure + }) + } + await owner.dispose() + if (failScope) await expect(handle.dispose()).rejects.toMatchObject({ errors: [failure, scopeFailure] }) + else await expect(handle.dispose()).rejects.toBe(failure) + expect(scopeDisposed).toBe(true) +}) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 95d536ec2b..6cfd059cd3 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -903,7 +903,13 @@ describe('SurfaceManager.replaceGeneration', () => { }) describe('system/message surface node', () => { - function systemEvent(seq: number, text: string, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent { + // Narrow return types: spreading the full SessionEvent union makes every fixture an + // anonymous union, and the `as SessionEvent` casts the rejection cases need then + // compare union against union. + type SystemMessageEvent = SessionEvent<'system/message'> + type UserMessageEvent = SessionEvent<'user/message'> + + function systemEvent(seq: number, text: string, op: TestSurfaceOp = 'append', sources?: number[]): SystemMessageEvent { return { type: 'system/message', seq: SessionSeq(seq), @@ -913,7 +919,7 @@ describe('system/message surface node', () => { ...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) }, } } - function userEvent(seq: number, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent { + function userEvent(seq: number, op: TestSurfaceOp = 'append', sources?: number[]): UserMessageEvent { return { type: 'user/message', seq: SessionSeq(seq), @@ -972,9 +978,9 @@ describe('system/message surface node', () => { it('rejects a seeded system/message with a non-system role or non-system-prompt source', () => { const good = systemEvent(0, 'v1') - const badRole = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, role: 'user' } } } + const badRole = { ...good, data: { ...good.data, message: { ...good.data.message, role: 'user' } } } expect(() => Session.create(SessionId('bad-role'), [badRole as SessionEvent])).toThrow(/role "system"/) - const badSource = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, source: { kind: 'user' } } } } + const badSource = { ...good, data: { ...good.data, message: { ...good.data.message, source: { kind: 'user' } } } } expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/system-prompt source/) }) }) diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index b3bbb73b38..0259427fce 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,8 +18,8 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: fd3b2bc9066ff309 - zh: b768506203a3ec76 + en: 8ea21461f84d30ad + zh: b521c2d38a2b8eec /packages-experimental/related-documentation: en: 1cb9d3448d44e48d zh: cd326d70a746b896 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 745f958145..8c085e46aa 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -37,9 +37,9 @@ Promotion requires a named product role, documented public behavior and limitati | Package | Role | ctx key | |---|---|---| | [`tool-session-query`](tool-session-query/README.md) | Workspace-authorized model session search, trace, and event reads | — | -| [`session-search`](session-search/README.md) | Optional experimental session search in full Web presets | — | +| [`session-search`](session-search/README.md) | Optional experimental global session search tools | — | | [`ralph-bundle`](ralph-bundle/README.md) | Optional repeated delegation with an isolated workflow engine | — | -| [`terminal-bundle`](terminal-bundle/README.md) | Optional persistent terminal tools for full Web presets | — | +| [`terminal-bundle`](terminal-bundle/README.md) | Optional global persistent terminal tools | — | | [`badge-skill-bundle`](badge-skill-bundle/README.md) | Optional powered-by-dsh badge skill | — | | [`session-titles-bundle`](session-titles-bundle/README.md) | Optional titles following human prompts in a conversation | — | | [`hook-protocol`](hook-protocol/README.md) | Hook bridge wire types and durable events | — | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 1c389c899e..f182d6ad7b 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -37,9 +37,9 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| | [`tool-session-query`](tool-session-query/README.zh.md) | 按工作区授权的模型会话搜索、追踪与事件读取 | — | -| [`session-search`](session-search/README.zh.md) | 完整 Web 预设的可选实验性会话搜索 | — | +| [`session-search`](session-search/README.zh.md) | 可选实验性全局会话搜索工具 | — | | [`ralph-bundle`](ralph-bundle/README.zh.md) | 带隔离 workflow 引擎的可选重复委派 | — | -| [`terminal-bundle`](terminal-bundle/README.zh.md) | 完整 Web 预设的可选持久终端工具 | — | +| [`terminal-bundle`](terminal-bundle/README.zh.md) | 可选全局持久终端工具 | — | | [`badge-skill-bundle`](badge-skill-bundle/README.zh.md) | 可选 powered-by-dsh 徽章技能 | — | | [`session-titles-bundle`](session-titles-bundle/README.zh.md) | 随对话中人类提示更新的可选标题 | — | | [`hook-protocol`](hook-protocol/README.zh.md) | 钩子桥接的通信类型与持久事件 | — | diff --git a/packages/experimental/ralph-bundle/README.i18n.yaml b/packages/experimental/ralph-bundle/README.i18n.yaml index 1f17c25f47..1734bd20e7 100644 --- a/packages/experimental/ralph-bundle/README.i18n.yaml +++ b/packages/experimental/ralph-bundle/README.i18n.yaml @@ -9,17 +9,17 @@ en: d1c47cc0dd1ea81c zh: a0ff6438eb9ccbf4 /deepseek-ai-dsh-experimental-ralph-bundle/summary: - en: feeb3e192e4e645e - zh: 20eded07b0c9744b + en: 2d0d6090157ea5ec + zh: 55c9669c4bf44cb9 /deepseek-ai-dsh-experimental-ralph-bundle/table-of-contents: en: d152484eb41ac6b4 zh: 988e839059414d27 /deepseek-ai-dsh-experimental-ralph-bundle/use-this-package: - en: fe08392be3b531a9 - zh: 091efa0b0216e19c + en: bb91c59b7a474054 + zh: 2ae97c4106a71c74 /deepseek-ai-dsh-experimental-ralph-bundle/understand-the-implementation: - en: 9bc5140b152c2b5d - zh: 732769795b72206e + en: c796a041544eabd2 + zh: 25e5d4f4965e3f30 /deepseek-ai-dsh-experimental-ralph-bundle/further-exploration: en: 38086d7ddaa3088a zh: e23269ca4c88c496 @@ -27,8 +27,8 @@ en: bad19e0e842cc0f2 zh: 0b14cdf238f6777e /deepseek-ai-dsh-experimental-ralph-bundle/model-experience/kv-cache-effect: - en: 1b2209bbbf869b1a - zh: 5b9de62271343e4e + en: 25f20eda0e696067 + zh: 378c4a15a5704ba0 /deepseek-ai-dsh-experimental-ralph-bundle/known-limitations-and-deferred-work: en: 39918e19422a80c1 zh: 71346d112bdea43f diff --git a/packages/experimental/ralph-bundle/README.md b/packages/experimental/ralph-bundle/README.md index 98f09f0918..ad5399cbf0 100644 --- a/packages/experimental/ralph-bundle/README.md +++ b/packages/experimental/ralph-bundle/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Add the experimental Ralph tool to standard, cordis, and ptc for human-requested repeated work with fresh child agents. It ships switched off in Web and Desktop. Select it in Plugins to enable it. +Add the experimental Ralph tool to every Agent as a global tool for human-requested repeated work with fresh child agents. It ships switched off in Web and Desktop. Select it in Plugins to enable it. ## Table of Contents @@ -27,7 +27,7 @@ Add the experimental Ralph tool to standard, cordis, and ptc for human-requested In Web or Desktop, open Plugins and enable **Ralph loops** in Official. Switch it off to remove its profile layer. -New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. +Enabling or disabling the bundle registers or removes its global tools in the running Host, so every Agent sees the change on its next request, in every preset including minimal. ----- @@ -37,7 +37,7 @@ New Agents and subsequently reopened Sessions use the selected composition; live
    Implementation details — click to expand -[`cordis.patch.yml`](cordis.patch.yml) contributes `tool-ralph` through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`, inside the isolated `optional-ralph` group. Later scoped patches can replace the tool's configuration or disable it; an override alone does not select the bundle. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. +[`cordis.patch.yml`](cordis.patch.yml) inserts the Host group `optional-ralph`, which isolates its own `workflowEngine` and registers `tool-ralph` in the global tool layer. Later profile patches can replace the tool's configuration or disable it by row id; an override alone does not select the bundle. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors.
    @@ -57,7 +57,7 @@ Indirectly, through the [capability implementation](../tool-ralph/README.md), wh #### KV Cache effect -The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. Toggling the bundle changes the tool list of live Sessions, which invalidates their cached request prefix once. ## Known Limitations and Deferred Work diff --git a/packages/experimental/ralph-bundle/README.zh.md b/packages/experimental/ralph-bundle/README.zh.md index e7ea39982c..80f693fd10 100644 --- a/packages/experimental/ralph-bundle/README.zh.md +++ b/packages/experimental/ralph-bundle/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -为 standard、cordis 和 ptc 添加实验性 Ralph 工具,使用新的子智能体执行用户要求的循环工作。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 +为所有智能体添加实验性 Ralph 全局工具,使用新的子智能体执行用户要求的循环工作。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-bundle" 在 Web 或 Desktop 中打开插件页,在官方分组启用 **Ralph 循环**。关闭后移除其配置层。 -新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 +启用或关闭组合包会在运行中的 Host 注册或移除其全局工具,因此所有预设(包括 minimal)中的每个智能体都会在下一次请求时看到变化。 ----- @@ -37,7 +37,7 @@ kind: "package-bundle"
    实现细节 — 点击展开 -[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc`,在隔离的 `optional-ralph` 组中贡献 `tool-ralph`。后续作用域补丁可以替换该工具的配置或禁用它;单独的覆盖不会选中组合包。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 +[`cordis.patch.yml`](cordis.patch.yml) 插入 Host 组 `optional-ralph`,该组隔离自己的 `workflowEngine`,并在全局工具层注册 `tool-ralph`。后续配置补丁可以按条目 id 替换该工具的配置或禁用它;单独的覆盖不会选中组合包。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。
    @@ -57,7 +57,7 @@ kind: "package-bundle" #### KV Cache 影响 -此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。 ## 已知限制与暂缓工作 diff --git a/packages/experimental/ralph-bundle/cordis.patch.yml b/packages/experimental/ralph-bundle/cordis.patch.yml index f390d6f1fb..2f9e770cb0 100644 --- a/packages/experimental/ralph-bundle/cordis.patch.yml +++ b/packages/experimental/ralph-bundle/cordis.patch.yml @@ -1,41 +1,4 @@ -- preset: preset-standard - insert: - - id: optional-ralph - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: optional-ralph-workflow - name: '@deepseek-ai/dsh-workflow-ptc' - config: - provider: spawn - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - config: - subagentProvider: spawn - maxRounds: 64 - -- preset: preset-cordis - insert: - - id: optional-ralph - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: optional-ralph-workflow - name: '@deepseek-ai/dsh-workflow-ptc' - config: - provider: spawn - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - config: - subagentProvider: spawn - maxRounds: 64 - -- preset: preset-ptc - insert: +- insert: - id: optional-ralph name: cordis:group group: true diff --git a/packages/experimental/session-search/README.i18n.yaml b/packages/experimental/session-search/README.i18n.yaml index 931c2588eb..d19563170a 100644 --- a/packages/experimental/session-search/README.i18n.yaml +++ b/packages/experimental/session-search/README.i18n.yaml @@ -9,17 +9,17 @@ en: f4f9c83d1dd36580 zh: f34e5a1c0df5e469 /deepseek-ai-dsh-experimental-session-search/summary: - en: 565d4c9c763ed823 - zh: 36b12b0511b46f5e + en: 3461520fc9a6b225 + zh: aeedca620262e8ae /deepseek-ai-dsh-experimental-session-search/table-of-contents: en: d152484eb41ac6b4 zh: 988e839059414d27 /deepseek-ai-dsh-experimental-session-search/use-this-package: - en: 958c815e002afb63 - zh: 2f429ace45fb2738 + en: 29e4de34bed1ed2e + zh: 539638a62c0a99c3 /deepseek-ai-dsh-experimental-session-search/understand-the-implementation: - en: e7eb9f3b6081a532 - zh: 49a942e930a1b7f7 + en: 7c40cb34ffc89ed4 + zh: ce6ff516434ba582 /deepseek-ai-dsh-experimental-session-search/further-exploration: en: 3016959b8c0546d0 zh: a356ef50b3ce9f77 @@ -27,11 +27,11 @@ en: 00d8bdccc42b1f10 zh: 3264a962cff8127e /deepseek-ai-dsh-experimental-session-search/model-experience/kv-cache-effect: - en: 1b2209bbbf869b1a - zh: 5b9de62271343e4e + en: 25f20eda0e696067 + zh: 378c4a15a5704ba0 /deepseek-ai-dsh-experimental-session-search/known-limitations-and-deferred-work: - en: d5211fd04a6cdddd - zh: d8ee29b1a060b77d + en: 5ba4aae8ef1f62d2 + zh: a7599243880f7482 /deepseek-ai-dsh-experimental-session-search/known-limitations-and-deferred-work/dev-note: en: 279d01435256cd2a zh: 83780964e5de2f54 diff --git a/packages/experimental/session-search/README.md b/packages/experimental/session-search/README.md index 22b94aed22..4988bb838f 100644 --- a/packages/experimental/session-search/README.md +++ b/packages/experimental/session-search/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Give Agents five experimental read-only tools to search earlier sessions in standard, cordis, and ptc. The content index opens on the first search and is shared by Agents using the same preset revision. The bundle ships switched off in Web and Desktop. Select it in Plugins to enable it. +Give every Agent five experimental read-only global tools to search earlier sessions. The content index opens on the first search and is shared by all Agents. The bundle ships switched off in Web and Desktop. Select it in Plugins to enable it. ## Table of Contents @@ -27,7 +27,7 @@ Give Agents five experimental read-only tools to search earlier sessions in stan In Web or Desktop, open Plugins and enable **Session search** in Official. Switch it off to remove its profile layer. -New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. +Enabling or disabling the bundle registers or removes its global tools in the running Host, so every Agent sees the change on its next request, in every preset including minimal. This bundle enables Agent search tools. Sidebar search keeps its existing behavior. @@ -39,9 +39,9 @@ This bundle enables Agent search tools. Sidebar search keeps its existing behavi
    Implementation details — click to expand -[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. +[`cordis.patch.yml`](cordis.patch.yml) inserts the Host group `optional-session-search`, which registers the tools in the global tool layer. Later profile patches can override its rows by id. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. -Each preset revision owns an isolated `sessionQuery` provider with a lazy `:memory:` index. A retired revision releases its index when its last Agent closes. The host's metadata service keeps `openAt: never`. +The group owns an isolated `sessionQuery` provider with a lazy `:memory:` index, released when the bundle is switched off. The host's metadata service keeps `openAt: never`.
    @@ -61,13 +61,13 @@ Indirectly, through the [capability implementation](../tool-session-query/README #### KV Cache effect -The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. Toggling the bundle changes the tool list of live Sessions, which invalidates their cached request prefix once. ## Known Limitations and Deferred Work -- Each active or retained preset revision keeps its own in-memory index after the first search. Indexes are rebuilt after restarting the application. Search remains restricted to authorized workspaces. +- The bundle keeps one in-memory index after the first search. The index is rebuilt after restarting the application or switching the bundle off and on. Search remains restricted to authorized workspaces. ----- diff --git a/packages/experimental/session-search/README.zh.md b/packages/experimental/session-search/README.zh.md index f099d29b5b..5352ca2362 100644 --- a/packages/experimental/session-search/README.zh.md +++ b/packages/experimental/session-search/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -为 standard、cordis 和 ptc 中的智能体提供五个实验性只读工具,用于搜索历史会话。内容索引在首次搜索时打开,由使用同一预设版本的智能体共享。Web 和 Desktop 随附此配置包,默认关闭。在插件页选择它即可启用。 +为所有智能体提供五个实验性只读全局工具,用于搜索历史会话。内容索引在首次搜索时打开,由所有智能体共享。Web 和 Desktop 随附此配置包,默认关闭。在插件页选择它即可启用。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-bundle" 在 Web 或 Desktop 中打开插件页,在官方分组启用 **会话搜索**。关闭后移除其配置层。 -新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 +启用或关闭组合包会在运行中的 Host 注册或移除其全局工具,因此所有预设(包括 minimal)中的每个智能体都会在下一次请求时看到变化。 此配置包启用智能体的搜索工具。侧边栏搜索保留现有行为。 @@ -39,9 +39,9 @@ kind: "package-bundle"
    实现细节 — 点击展开 -[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 +[`cordis.patch.yml`](cordis.patch.yml) 插入 Host 组 `optional-session-search`,在全局工具层注册这些工具。后续配置补丁可以按 id 覆盖其条目。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 -每个预设版本拥有独立的 `sessionQuery` 提供方和惰性打开的 `:memory:` 索引。已被替换的版本在最后一个智能体关闭时释放其索引。Host 的元数据服务仍使用 `openAt: never`。 +该组拥有独立的 `sessionQuery` 提供方和惰性打开的 `:memory:` 索引,关闭组合包时释放。Host 的元数据服务仍使用 `openAt: never`。
    @@ -61,13 +61,13 @@ kind: "package-bundle" #### KV Cache 影响 -此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。 ## 已知限制与暂缓工作 -- 每个当前或保留的预设版本在首次搜索后持有自己的内存索引。索引会在应用重启后重建。搜索仍仅限获授权的工作区。 +- 组合包在首次搜索后持有一个内存索引。应用重启或重新开关组合包后索引会重建。搜索仍仅限获授权的工作区。 ----- diff --git a/packages/experimental/session-search/cordis.patch.yml b/packages/experimental/session-search/cordis.patch.yml index 07a25095d5..924cc79c67 100644 --- a/packages/experimental/session-search/cordis.patch.yml +++ b/packages/experimental/session-search/cordis.patch.yml @@ -1,37 +1,4 @@ -- preset: preset-standard - insert: - - id: optional-session-search - name: cordis:group - group: true - isolate: - sessionQuery: true - config: - - id: optional-session-query-sqlite - name: '@deepseek-ai/dsh-session-query-sqlite' - config: - path: ':memory:' - openAt: first-search - - id: optional-tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - -- preset: preset-cordis - insert: - - id: optional-session-search - name: cordis:group - group: true - isolate: - sessionQuery: true - config: - - id: optional-session-query-sqlite - name: '@deepseek-ai/dsh-session-query-sqlite' - config: - path: ':memory:' - openAt: first-search - - id: optional-tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - -- preset: preset-ptc - insert: +- insert: - id: optional-session-search name: cordis:group group: true diff --git a/packages/experimental/terminal-bundle/README.i18n.yaml b/packages/experimental/terminal-bundle/README.i18n.yaml index 3c78173531..9a4e49028e 100644 --- a/packages/experimental/terminal-bundle/README.i18n.yaml +++ b/packages/experimental/terminal-bundle/README.i18n.yaml @@ -9,17 +9,17 @@ en: cc18693529aee09c zh: fbf1c7d8388eeba8 /deepseek-ai-dsh-experimental-terminal-bundle/summary: - en: d578f2dc47931ce1 - zh: b1252306d2e6fc81 + en: 161ff8d60aa7623e + zh: 90ea4ac87b96f20a /deepseek-ai-dsh-experimental-terminal-bundle/table-of-contents: en: d152484eb41ac6b4 zh: 988e839059414d27 /deepseek-ai-dsh-experimental-terminal-bundle/use-this-package: - en: 38ef1b3bc2d4e698 - zh: 8f6b6931988dace6 + en: 924d2c0de1f247a7 + zh: 7d77aef5ceb3f097 /deepseek-ai-dsh-experimental-terminal-bundle/understand-the-implementation: - en: 878820ec8f3bab5c - zh: 51eddc38d2a7685d + en: 0da35df3f93e3b7f + zh: 481b786e161616ab /deepseek-ai-dsh-experimental-terminal-bundle/further-exploration: en: 9956186ebda37273 zh: eb622f1391f61ad4 @@ -27,11 +27,11 @@ en: 33b928765f080dbf zh: a0ed945765c3b62f /deepseek-ai-dsh-experimental-terminal-bundle/model-experience/kv-cache-effect: - en: 1b2209bbbf869b1a - zh: 5b9de62271343e4e + en: 25f20eda0e696067 + zh: 378c4a15a5704ba0 /deepseek-ai-dsh-experimental-terminal-bundle/known-limitations-and-deferred-work: - en: 4c9a835813b4ccde - zh: abf3a1b0f113a6c5 + en: 733d0915ef050bf3 + zh: 5a063b82230c73a4 /deepseek-ai-dsh-experimental-terminal-bundle/known-limitations-and-deferred-work/dev-note: en: 279d01435256cd2a zh: 83780964e5de2f54 diff --git a/packages/experimental/terminal-bundle/README.md b/packages/experimental/terminal-bundle/README.md index 648235dd74..554a71b294 100644 --- a/packages/experimental/terminal-bundle/README.md +++ b/packages/experimental/terminal-bundle/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Add six experimental terminal tools to standard, cordis, and ptc. Each preset revision owns an isolated terminal registry with the platform shell backend; terminals remain Agent-owned. It ships switched off in Web and Desktop. Select it in Plugins to enable it. +Add six experimental global terminal tools to every Agent. The bundle owns an isolated terminal registry with the platform shell backend; terminals remain Agent-owned. It ships switched off in Web and Desktop. Select it in Plugins to enable it. ## Table of Contents @@ -27,7 +27,7 @@ Add six experimental terminal tools to standard, cordis, and ptc. Each preset re In Web or Desktop, open Plugins and enable **Persistent terminals** in Official. Switch it off to remove its profile layer. -New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. +Enabling or disabling the bundle registers or removes its global tools in the running Host, so every Agent sees the change on its next request, in every preset including minimal. ----- @@ -37,7 +37,7 @@ New Agents and subsequently reopened Sessions use the selected composition; live
    Implementation details — click to expand -[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. +[`cordis.patch.yml`](cordis.patch.yml) inserts the Host group `optional-persistent-terminals`, which registers the tools in the global tool layer. Later profile patches can override its rows by id. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors.
    @@ -57,13 +57,13 @@ Indirectly, through the [capability implementation](../tool-terminal/README.md), #### KV Cache effect -The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. Toggling the bundle changes the tool list of live Sessions, which invalidates their cached request prefix once. ## Known Limitations and Deferred Work -- Terminals belong to the Agent that created them. POSIX uses Bash and Windows uses PowerShell; the bundle does not share the minimal preset's terminal registry. +- Terminals belong to the Agent that created them. POSIX uses Bash and Windows uses PowerShell; the bundle does not share another terminal registry. ----- diff --git a/packages/experimental/terminal-bundle/README.zh.md b/packages/experimental/terminal-bundle/README.zh.md index 5743708e02..2839ff1fd8 100644 --- a/packages/experimental/terminal-bundle/README.zh.md +++ b/packages/experimental/terminal-bundle/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -为 standard、cordis 和 ptc 添加六个实验性终端工具。每个预设修订拥有隔离的终端注册表和平台 shell 后端;终端仍由智能体拥有。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 +为所有智能体添加六个实验性全局终端工具。组合包拥有隔离的终端注册表和平台 shell 后端;终端仍由智能体拥有。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-bundle" 在 Web 或 Desktop 中打开插件页,在官方分组启用 **持久终端**。关闭后移除其配置层。 -新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 +启用或关闭组合包会在运行中的 Host 注册或移除其全局工具,因此所有预设(包括 minimal)中的每个智能体都会在下一次请求时看到变化。 ----- @@ -37,7 +37,7 @@ kind: "package-bundle"
    实现细节 — 点击展开 -[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 +[`cordis.patch.yml`](cordis.patch.yml) 插入 Host 组 `optional-persistent-terminals`,在全局工具层注册这些工具。后续配置补丁可以按 id 覆盖其条目。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。
    @@ -57,13 +57,13 @@ kind: "package-bundle" #### KV Cache 影响 -此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。 ## 已知限制与暂缓工作 -- 终端属于创建它们的智能体。POSIX 使用 Bash,Windows 使用 PowerShell;此 bundle 不共享 minimal 预设的终端注册表。 +- 终端属于创建它们的智能体。POSIX 使用 Bash,Windows 使用 PowerShell;此 bundle 不与其他终端注册表共享。 ----- diff --git a/packages/experimental/terminal-bundle/cordis.patch.yml b/packages/experimental/terminal-bundle/cordis.patch.yml index d49570836f..3f3c8872ed 100644 --- a/packages/experimental/terminal-bundle/cordis.patch.yml +++ b/packages/experimental/terminal-bundle/cordis.patch.yml @@ -1,47 +1,4 @@ -- preset: preset-standard - insert: - - id: optional-persistent-terminals - name: cordis:group - group: true - isolate: - terminals: true - config: - - id: optional-terminal-registry - name: '@deepseek-ai/dsh-terminal' - - id: optional-terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform === 'win32' - - id: optional-terminal-pwsh - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform !== 'win32' - config: - shellDialect: pwsh - - id: optional-tool-terminal - name: '@deepseek-ai/dsh-tool-terminal' - -- preset: preset-cordis - insert: - - id: optional-persistent-terminals - name: cordis:group - group: true - isolate: - terminals: true - config: - - id: optional-terminal-registry - name: '@deepseek-ai/dsh-terminal' - - id: optional-terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform === 'win32' - - id: optional-terminal-pwsh - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform !== 'win32' - config: - shellDialect: pwsh - - id: optional-tool-terminal - name: '@deepseek-ai/dsh-tool-terminal' - -- preset: preset-ptc - insert: +- insert: - id: optional-persistent-terminals name: cordis:group group: true diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 531fb7e275..f8aeee037e 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1897,7 +1897,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.add.actions\', () => ctx.slots.register(\n { name: \'plugins.add.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:91', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:89', }, { key: 'plugins.bundle.activation', @@ -1935,7 +1935,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:93', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:91', }, { key: 'plugins.bundle.config', @@ -1976,7 +1976,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:109', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:107', }, { key: 'plugins.detail.actions', @@ -2026,7 +2026,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:123', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:121', }, { key: 'plugins.detail.badge', @@ -2076,7 +2076,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:128', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:126', }, { key: 'plugins.detail.section', @@ -2126,7 +2126,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:135', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:133', }, { key: 'plugins.item', @@ -2181,7 +2181,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:103', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:101', }, { key: 'plugins.row.config', @@ -2219,7 +2219,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:117', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:115', }, { key: 'rightbar', diff --git a/packages/extensions/cordis-host-runner/src/index.ts b/packages/extensions/cordis-host-runner/src/index.ts index 388ee7e311..3c48a82bcf 100644 --- a/packages/extensions/cordis-host-runner/src/index.ts +++ b/packages/extensions/cordis-host-runner/src/index.ts @@ -51,7 +51,7 @@ export type { HostCordisInspectProviderRegistration } from './inspect-registry.t * @param id - opaque identifier minted by the Host registry. * @returns the branded Plugin identifier. */ -export function CordisDynamicPluginId(id: string): CordisDynamicPluginId { +function brandCordisDynamicPluginId(id: string): CordisDynamicPluginId { return id as CordisDynamicPluginId } @@ -60,7 +60,7 @@ export function CordisDynamicPluginId(id: string): CordisDynamicPluginId { * @param id - opaque identifier minted by the Host registry. * @returns the branded Package identifier. */ -export function CordisDynamicPackageId(id: string): CordisDynamicPackageId { +function brandCordisDynamicPackageId(id: string): CordisDynamicPackageId { return id as CordisDynamicPackageId } @@ -69,7 +69,7 @@ export function CordisDynamicPackageId(id: string): CordisDynamicPackageId { * @param id - opaque identifier minted by the Host registry. * @returns the branded Plugin Run identifier. */ -export function CordisDynamicPluginRunId(id: string): CordisDynamicPluginRunId { +function brandCordisDynamicPluginRunId(id: string): CordisDynamicPluginRunId { return id as CordisDynamicPluginRunId } @@ -78,10 +78,20 @@ export function CordisDynamicPluginRunId(id: string): CordisDynamicPluginRunId { * @param id - opaque identifier minted by the Host registry. * @returns the branded approval request identifier. */ -export function ApprovalRequestId(id: string): ApprovalRequestId { +function brandApprovalRequestId(id: string): ApprovalRequestId { return id as ApprovalRequestId } +// The public names are exported separately: a local declaration sharing a name +// with a type-only import is ambiguous for checkers that bind the import in +// value position. +export { + brandApprovalRequestId as ApprovalRequestId, + brandCordisDynamicPackageId as CordisDynamicPackageId, + brandCordisDynamicPluginId as CordisDynamicPluginId, + brandCordisDynamicPluginRunId as CordisDynamicPluginRunId, +} + declare module '@deepseek-ai/cordis' { interface Context { /** Process-local dynamic Plugin registry and lifecycle service. */ @@ -173,7 +183,7 @@ export class DynamicCordisRunnerService extends TypertRemoteService { if (!/^[a-z]{3,6}$/.test(prefix)) { throw new Error('cordis_define `plugin.idPrefix` must contain 3–6 lowercase English letters') } - const pluginId = CordisDynamicPluginId(this.registry.mintPluginId(prefix)) + const pluginId = brandCordisDynamicPluginId(this.registry.mintPluginId(prefix)) plugin = { pluginId, sessionId: request.sessionId, @@ -190,7 +200,7 @@ export class DynamicCordisRunnerService extends TypertRemoteService { plugin = found } - const packageId = CordisDynamicPackageId(this.registry.mintPackageId()) + const packageId = brandCordisDynamicPackageId(this.registry.mintPackageId()) const definition: DynamicCordisDefinition = { packageId, name, @@ -282,7 +292,7 @@ export class DynamicCordisRunnerService extends TypertRemoteService { return { ...started, reason: 'host-half-failed' } } - const requestId = ApprovalRequestId(this.registry.mintApprovalRequestId()) + const requestId = brandApprovalRequestId(this.registry.mintApprovalRequestId()) const requiresApproval = !plan.plugin.clientVersionUpdatesApproved && !plan.plugin.approvedClientPackages.has(packageId) attempt.approvalRequestId = requestId @@ -1179,7 +1189,7 @@ export class DynamicCordisRunnerService extends TypertRemoteService { private createAttempt(plan: ActivationPlan): DynamicCordisRunAttempt { return { - pluginRunId: CordisDynamicPluginRunId(this.registry.mintPluginRunId()), + pluginRunId: brandCordisDynamicPluginRunId(this.registry.mintPluginRunId()), packageId: plan.definition.packageId, mode: plan.mode, status: 'starting-host', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index dfe05bc5fa..0f72ad4363 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -145,13 +145,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'inspectCompositions(ctx?: Context): AgentPresetInspection[]', description: 'Inspect retained revisions, or the exact revision an Agent joined.', parameters: [{ name: 'ctx', description: 'optional Agent context; omission includes all retained revisions.' }], - returns: 'detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list.', + returns: 'detached module references and isolation diagnostics; no match returns an empty list.', }, { signature: 'async list(): Promise', description: 'Read every declared preset, including activation failures.', parameters: [], - returns: 'Display metadata, declaring Loader row identities, and loading diagnostics.', + returns: 'Display metadata and loading diagnostics.', }, { signature: '@Remote(\'list\') async remoteExportList(): Promise', @@ -715,8 +715,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'configEditor', - summary: 'Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.', - description: 'Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.', + summary: 'Persist complete raw configs and apply them through the normal Loader path.', + description: 'Persist complete raw configs and apply them through the normal Loader path.', methods: [ { signature: 'entries(): Entry[]', @@ -1691,7 +1691,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'Current runtime entries with persistent patch targets.', }, { - signature: '@Remote async listBundles(): Promise', + signature: '@Remote listBundles(): Promise', description: 'Read installed, installation-provided, and offline Official catalog bundles, plus selected non-bundle names. A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.', parameters: [], returns: 'Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional display metadata, activation selections, whether the installation offers the bundle, and removal availability.', @@ -1813,7 +1813,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'readonly overlays: readonly ProfilePatch[]', + signature: 'readonly overlays: readonly PatchOptions[]', description: 'Parsed command-line overlays, applied above profile and home patches.', parameters: [], }, @@ -2522,7 +2522,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'register(provider: SessionTitleProvider): () => Promise', - description: 'Register the sole optional title provider. Disposal aborts its pending and active work before another provider may register.', + description: 'Register the sole optional title provider. Disposal aborts its pending and active work; a replacement may register once disposal has started, and the closing provider\'s late results never commit.', parameters: [{ name: 'provider', description: 'provider identity, cadence, and generation function.' }], returns: 'exact Cordis effect disposer, which settles after active calls quiesce.', }, @@ -4614,11 +4614,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentPresetInspection', - declaration: 'export interface AgentPresetInspection {\n readonly id: string;\n readonly definitionEntryId?: string;\n readonly modules: readonly {\n readonly moduleName: string;\n readonly entryId: string;\n readonly baseUrl?: string;\n readonly useHostBase: boolean;\n }[];\n readonly leakedServices: readonly string[];\n}', - }, - { - name: 'AgentPresetPluginRow', - declaration: 'export interface AgentPresetPluginRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly meta?: PluginLocalizedMeta;\n readonly enabled: PresetPluginEnablement;\n readonly condition?: string;\n readonly fiberPhase: PluginFiberPhase;\n}', + declaration: 'export interface AgentPresetInspection {\n readonly id: string;\n readonly modules: readonly {\n readonly moduleName: string;\n readonly baseUrl?: string;\n readonly useHostBase: boolean;\n }[];\n readonly leakedServices: readonly string[];\n}', }, { name: 'AgentPresetRoster', @@ -4858,7 +4854,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleRowInfo', - declaration: 'export interface BundleRowInfo {\n rowId: string;\n preset?: string;\n readOnlyReason?: ReadOnlyReason;\n composition?: Pick;\n moduleName: string;\n meta?: PluginLocalizedMeta;\n entryId?: PluginEntryId;\n}', + declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: PluginLocalizedMeta;\n entryId?: PluginEntryId;\n}', }, { name: 'ButtonProps', @@ -6264,10 +6260,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', }, - { - name: 'PresetPluginEnablement', - declaration: 'export type PresetPluginEnablement = boolean | \'conditional\';', - }, { name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', @@ -6292,10 +6284,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProductTelemetryRecord', declaration: 'export type ProductTelemetryRecord = OTelEventRecord;', }, - { - name: 'ProfilePatch', - declaration: 'export interface ProfilePatch extends PatchOptions {\n preset?: string;\n}', - }, { name: 'ProfilePnpmInvocation', declaration: 'export interface ProfilePnpmInvocation {\n readonly command: string;\n readonly args: readonly string[];\n readonly env: Readonly>;\n}', @@ -6422,7 +6410,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ReadOnlyReason', - declaration: 'export type ReadOnlyReason = \'management-required\' | \'unaddressable\' | \'preset-managed\';', + declaration: 'export type ReadOnlyReason = \'management-required\' | \'unaddressable\';', }, { name: 'ReadResultView', diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index 73ad2e826f..19984aa48e 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -21,8 +21,8 @@ en: cd28d9b4f46c46a8 zh: 4921d853f98469fe /deepseek-ai-dsh-agent-preset-registry/understand-the-implementation: - en: a57f03b8891bddfa - zh: 91528f95aef11757 + en: 1d479971c0adc694 + zh: abc84b7220c9b5d5 /deepseek-ai-dsh-agent-preset-registry/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index f80cae7f73..9de2a41107 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -59,9 +59,7 @@ A preset service provider and its consumers must share one `cordis:group` isolat Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. -`list()` includes the current definition’s optional `definitionEntryId`, supplied by its registering Loader row, even when activation fails. The client roster omits this Host inspection field. - -`inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, the defining Host row ID when configured, detached active-module references with Loader row IDs and resolution bases, and leaked-service names. These identities remain attached to the retained revision after its definition changes or disappears. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records. +`inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, detached active-module references with their resolution bases, and leaked-service names. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records. | File | Responsibility | |---|---| diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index bbff1852ea..96ce2c18c5 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -59,9 +59,7 @@ preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔 激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。 -`list()` 包含当前定义可选的 `definitionEntryId`,由注册它的 Loader 行提供,激活失败时也保留。Client 名单不包含这个 Host 检查字段。 - -`inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、已配置时的 Host 定义行 ID、纯数据形式的活动模块引用及其 Loader 行 ID 和解析基址,以及泄漏的服务名。定义变更或消失后,这些标识仍附属于被保留的代际。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。 +`inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、纯数据形式的活动模块引用及解析基址,以及泄漏的服务名。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。 | 文件 | 职责 | |---|---| diff --git a/packages/preset/agent-preset-registry/src/composition-inventory.ts b/packages/preset/agent-preset-registry/src/composition-inventory.ts index d0a112a112..fdeac70a3c 100644 --- a/packages/preset/agent-preset-registry/src/composition-inventory.ts +++ b/packages/preset/agent-preset-registry/src/composition-inventory.ts @@ -52,14 +52,10 @@ export interface AgentPresetComposition { export interface AgentPresetInspection { /** Preset identity; different retained revisions can share it. */ readonly id: string - /** Configured Host Loader row that registered this revision, when present. */ - readonly definitionEntryId?: string /** Active modules, with the bases needed to resolve their package identities. */ readonly modules: readonly { /** Configured module specifier. */ readonly moduleName: string - /** Row id within its declaring Loader tree, including Loader-assigned ids. */ - readonly entryId: string /** Declaring configuration's URL, when available. */ readonly baseUrl?: string /** Whether bare specifiers use the application's module base. */ @@ -81,7 +77,6 @@ export function activeCompositionModules(tree: EntryTree): AgentPresetInspection const baseUrl = ownerTree.ctx.baseUrl modules.push({ moduleName: entry.options.name, - entryId: entry.options.id, ...baseUrl === undefined ? {} : { baseUrl }, useHostBase: ownerTree === tree, }) diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index d65418fe84..0500922904 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -32,7 +32,6 @@ interface Generation { scope: Scope key: ScopeKey mount: PresetMount - definitionEntryId?: string users: number retired: boolean } @@ -109,9 +108,7 @@ export class AgentPresetRegistry extends TypertRemoteService { if (problem !== undefined) throw new Error(problem) const context = scope.ctx.extend({ baseUrl: record.context.baseUrl }) const mount = await mountPreset(context, record.config.id, record.config.plugins) - const definitionEntryId = record.context.fiber.entry?.options.id - const generation: Generation = { scope, key, mount, users: 0, retired: false, - ...definitionEntryId === undefined ? {} : { definitionEntryId } } + const generation: Generation = { scope, key, mount, users: 0, retired: false } context.effect(() => { this.generations.set(key, generation) return () => { this.generations.delete(key) } @@ -162,29 +159,26 @@ export class AgentPresetRegistry extends TypertRemoteService { /** Inspect retained revisions, or the exact revision an Agent joined. * @param ctx - optional Agent context; omission includes all retained revisions. - * @returns detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list. + * @returns detached module references and isolation diagnostics; no match returns an empty list. */ inspectCompositions(ctx?: Context): AgentPresetInspection[] { const joined = ctx === undefined ? undefined : this.generationFor(ctx) const generations = ctx === undefined ? [...this.generations.values()] : joined === undefined ? [] : [joined] - return generations.map(({ mount, definitionEntryId }) => ({ + return generations.map(({ mount }) => ({ id: mount.presetId, - ...definitionEntryId === undefined ? {} : { definitionEntryId }, modules: activeCompositionModules(mount.tree), leakedServices: leakedServices(this.owner, mount.fiber), })) } /** Read every declared preset, including activation failures. - * @returns Display metadata, declaring Loader row identities, and loading diagnostics. + * @returns Display metadata and loading diagnostics. */ async list(): Promise { const rows = await Promise.all([...this.definitions.values()].map(async (record) => { const broken = await this.diagnostic(record) - const definitionEntryId = record.context.fiber.entry?.options.id return { id: record.config.id, - ...definitionEntryId === undefined ? {} : { definitionEntryId }, ...(record.config.name === undefined ? {} : { name: record.config.name }), ...(record.config.description === undefined ? {} : { description: record.config.description }), ...(record.config.order === undefined ? {} : { order: record.config.order }), @@ -200,7 +194,7 @@ export class AgentPresetRegistry extends TypertRemoteService { @Remote('list') async remoteExportList(): Promise { const defaultId = this.defaultId - return { presets: (await this.list()).map(({ definitionEntryId: _entryId, ...row }) => ({ ...row, isDefault: row.id === defaultId })) } + return { presets: (await this.list()).map(row => ({ ...row, isDefault: row.id === defaultId })) } } /** Resolve an identity without starting an Agent. diff --git a/packages/preset/agent-preset-registry/src/preset.ts b/packages/preset/agent-preset-registry/src/preset.ts index 136cd1cd56..ae405a05ad 100644 --- a/packages/preset/agent-preset-registry/src/preset.ts +++ b/packages/preset/agent-preset-registry/src/preset.ts @@ -3,8 +3,6 @@ import type { Volatile } from '@deepseek-ai/cordis' /** One declared preset and its current activation failure, if any. */ export interface AgentPreset { readonly id: string - /** Loader row that registered this current definition, when present. */ - readonly definitionEntryId?: string readonly name?: string readonly description?: string readonly order?: number diff --git a/packages/preset/agent-preset-registry/tests/mount.spec.ts b/packages/preset/agent-preset-registry/tests/mount.spec.ts index 077344df13..51960106a4 100644 --- a/packages/preset/agent-preset-registry/tests/mount.spec.ts +++ b/packages/preset/agent-preset-registry/tests/mount.spec.ts @@ -29,8 +29,8 @@ it('keeps active module order without a base URL and excludes inactive, disabled { id: 'last', name: 'cordis:last' }, ]) expect(activeCompositionModules(ctx.loader.root.tree)).toEqual([ - { moduleName: 'cordis:first', entryId: 'first', useHostBase: true }, - { moduleName: 'cordis:last', entryId: 'last', useHostBase: true }, + { moduleName: 'cordis:first', useHostBase: true }, + { moduleName: 'cordis:last', useHostBase: true }, ]) }) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 2af1d9ce09..c6305ff877 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -24,8 +24,8 @@ en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 5e754e67dff07b02 - zh: 2ba1bda464abaa45 + en: 6a19c075f826b3f7 + zh: c200277cb58062be /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index bce4d1baf5..79c5e0b213 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,7 +55,7 @@ const definition = { ### Register and read -`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. +`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. The returned disposer can be yielded into a composite `ctx.effect()` on the same fiber to keep the unit available through dependent cleanup. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. ```text const dispose = ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index f70e886192..cb44e32ef9 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,7 +55,7 @@ const definition = { ### 注册与读取 -`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 +`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。返回的 disposer 可以 yield 到同一 fiber 上的复合 `ctx.effect()` 中,使单元在依赖它的清理完成前保持可用。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 ```text const dispose = ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 6d28631ddc..ac6e63458f 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -289,7 +289,8 @@ export class SessionProjectionRegistry extends Service { if (live.refs === 0) this.registrations.delete(key) } }.bind(this), 'sessionProjections.register()') - return () => void dispose() + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. + return dispose } /** diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 0954b2daf5..de7ecdde60 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -7,7 +7,7 @@ * and effect-tied removal of registrations and change listeners (HMR safety). */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import SessionStore, { @@ -525,6 +525,22 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) + it('keeps a yielded registration live until the composite finishes using it', async () => { + const { ctx, session } = await harness() + onTestFinished(async () => { await ctx.fiber.dispose() }) + let observed: MarksState | undefined + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.effect(function* () { + yield inner.sessionProjections.register(marksUnit()) + yield () => { observed = inner.sessionProjections.stateOf(session, 'test/marks') } + }) + }, { inject: ['sessionProjections'] })) + mark(session, ['live']) + await fiber.dispose() + expect(observed).toEqual({ marks: ['live'] }) + expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined() + }) + it('snapshot serves client views and excludes host-only state', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index cfff372123..e184d12dc1 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -24,8 +24,8 @@ en: f72239b38a91783b zh: 07e22375c09906a6 /deepseek-ai-dsh-session-title/use-this-package/adding-a-provider: - en: 909209d339538963 - zh: fb6350642ec30cc1 + en: 2ed8217f95490c73 + zh: e3456cb027ce3fcc /deepseek-ai-dsh-session-title/use-this-package/reading-titles: en: 34148c23c20ee6f4 zh: b0d10677722f3f63 diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index 4af53add67..42a11a345a 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Adding a provider -One optional asynchronous provider may be registered through `ctx.sessionTitle.register(provider)`; a second registration throws. The shipped model-backed providers are [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../../experimental/session-title-all-prompts-llm/README.md), both using the shared [LLM generation policy](../session-title-llm/README.md). A provider starts only after a marked loop-built request's exact route matches the logged `request/header`, and a newer revision supersedes and aborts older work. For unchanged routes, an active marked request starts pending automatic generation without a new header event; accepted human messages may follow the current `step/start`. +One optional asynchronous provider may be registered through `ctx.sessionTitle.register(provider)`; a second registration throws until the first provider's disposal starts, so a replacement can activate while the previous provider drains, and the previous provider's late results never commit. The shipped model-backed providers are [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../../experimental/session-title-all-prompts-llm/README.md), both using the shared [LLM generation policy](../session-title-llm/README.md). A provider starts only after a marked loop-built request's exact route matches the logged `request/header`, and a newer revision supersedes and aborts older work. For unchanged routes, an active marked request starts pending automatic generation without a new header event; accepted human messages may follow the current `step/start`. ### Reading titles diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 6798e510d8..95a5c81f01 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 添加提供方 -可选异步提供方可通过 `ctx.sessionTitle.register(provider)` 注册一个;第二次注册会立即抛出。随附的模型支持提供方是[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md),两者都使用共享的 [LLM(大语言模型)生成策略](../session-title-llm/README.zh.md)。提供方只有在带标记、由循环构建的请求的确切路由与已记录 `request/header` 匹配时才启动,较新的修订会取代并中止旧工作。路由不变时,活跃步骤中带标记的请求会启动待处理的自动生成,无需新的 header 事件;已接受的人类消息可以位于当前 `step/start` 之后。 +可选异步提供方可通过 `ctx.sessionTitle.register(provider)` 注册一个;在前一个提供方开始释放之前,第二次注册会立即抛出。因此替代提供方可以在前一个提供方排空期间激活,前一个提供方迟到的结果不会提交。随附的模型支持提供方是[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md),两者都使用共享的 [LLM(大语言模型)生成策略](../session-title-llm/README.zh.md)。提供方只有在带标记、由循环构建的请求的确切路由与已记录 `request/header` 匹配时才启动,较新的修订会取代并中止旧工作。路由不变时,活跃步骤中带标记的请求会启动待处理的自动生成,无需新的 header 事件;已接受的人类消息可以位于当前 `step/start` 之后。 ### 读取标题 diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 45ca83a19d..1bdfda9d76 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -464,13 +464,14 @@ export class SessionTitleService extends Service { /** * Register the sole optional title provider. Disposal aborts its pending and - * active work before another provider may register. + * active work; a replacement may register once disposal has started, and the + * closing provider's late results never commit. * @param provider - provider identity, cadence, and generation function. * @returns exact Cordis effect disposer, which settles after active calls quiesce. */ register(provider: SessionTitleProvider): () => Promise { this.validateProvider(provider) - if (this.registration !== undefined) { + if (this.registration !== undefined && !this.registration.closing) { throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`) } const registration: ProviderRegistration = { diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 1f740e38b8..6658c8f4aa 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -289,6 +289,41 @@ describe('SessionTitleService Provider lifecycle', () => { await disposeReplacement() }) + it('accepts a replacement while the previous provider drains and discards its late result', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionTitleService, CONFIG) + const pending = deferred() + const dispose = ctx.sessionTitle.register({ + id: SessionTitleProviderId('retiring'), + automatic: 'all-prompts', + generate: () => pending.promise, + }) + const session = ctx.sessions.create(SessionId('replace-while-draining')) + session.append('turn/start', { turn: 1 }) + const message = appendHumanPrompt(session, 'Generate this title') + await settle() + appendRoute(session) + await settle() + + const disposal = dispose() + const disposeReplacement = ctx.sessionTitle.register({ + id: SessionTitleProviderId('replacement'), + automatic: 'all-prompts', + generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }), + }) + expect(() => ctx.sessionTitle.register({ + id: SessionTitleProviderId('duplicate'), + automatic: 'first-prompt', + generate: async () => ({ title: 'duplicate', messageSeqs: [message.seq] }), + })).toThrow(/"replacement" is already registered/) + pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] }) + await disposal + expect(ctx.sessionTitle.get(session)?.title).not.toBe('stale provider result') + await disposeReplacement() + }) + it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/settings/settings/tests/configuration-inheritance.spec.ts b/packages/settings/settings/tests/configuration-inheritance.spec.ts index 287b7d089f..b55c1aad6d 100644 --- a/packages/settings/settings/tests/configuration-inheritance.spec.ts +++ b/packages/settings/settings/tests/configuration-inheritance.spec.ts @@ -5,7 +5,6 @@ import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { expect, it, vi } from 'vitest' import * as appBoot from '@deepseek-ai/dsh-app-boot' -import { parse } from 'yaml' import { configurationFixture } from './configuration-fixture.ts' it('shares composition for entries without config overrides while retaining group and insert inheritance', async () => { @@ -66,34 +65,6 @@ it('recomposes unoverridden entries after bundle changes and returns empty confi expect(read().find(row => row.entry.options.id === 'second')!.inherited).toEqual({ ordinary: 'new bundle value' }) }) -it.each([false, true])('preserves same-ID preset patches across Host config reads, edits and resets with a Host override: %s', async (hostOverride) => { - const { ctx, profile, start } = await configurationFixture({ hmr: false }) - await ctx.fiber.dispose() - const declaration = { insert: [{ id: 'preset-standard', name: 'cordis:probe', disabled: true, - config: { ordinary: 'preset', plugins: [{ id: 'first', name: 'cordis:probe', config: { ordinary: 'child' } }] }, - }] } - const preset = { preset: 'preset-standard', id: 'first', name: 'cordis:probe', disabled: true, - config: { ordinary: 'child override', count: 99 }, - } - const host = { id: 'first', name: 'cordis:probe', config: { ordinary: 'fixed', token: 'private', count: 6 } } - writeFileSync(profile.patchPath, JSON.stringify([declaration, ...hostOverride ? [host] : [], preset])) - const restored = await start() - const configuration = () => restored.configEditor.configuration().find(row => row.entry.options.id === 'first')! - expect(configuration().inherited).toEqual({ ordinary: 'fixed', token: 'private' }) - expect(configuration().override).toEqual(hostOverride ? host.config : {}) - for (const count of [7, 8]) { - await restored.settings.update('first', { count }) - const updated = { ...host, config: { ...host.config, count } } - expect(parse(readFileSync(profile.patchPath, 'utf8'))).toEqual(hostOverride ? [declaration, updated, preset] : [declaration, preset, updated]) - expect(configuration().override).toEqual(updated.config) - expect(restored.settings.describe().find(row => row.ns === 'first')!.value).toMatchObject({ count }) - } - await restored.settings.replace('first', {}) - expect(parse(readFileSync(profile.patchPath, 'utf8'))).toEqual([declaration, preset]) - expect(configuration().inherited).toEqual({ ordinary: 'fixed', token: 'private' }) - expect(configuration().override).toEqual({}) -}) - it.each([0, 13, 190])('bounds composition work for 190 entries with %i config overrides', async (overrideCount) => { const { ctx, profile, start } = await configurationFixture({ hmr: false }) await ctx.fiber.dispose() diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index e13da3226f..fc2c3345d0 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -18,14 +18,14 @@ en: 8e614ec7785cbaae zh: dd5b2ffff8d513c7 /deepseek-ai-dsh-subagent-claude-code/use-this-package/installing-the-bundle: - en: 46f060b9e7726652 - zh: 369efcd13728d28a + en: cf4a7e48ca796066 + zh: d9140d0fda7b7029 /deepseek-ai-dsh-subagent-claude-code/use-this-package/configuration: en: fbee5e4ab396c184 zh: dcf94832918d5010 /deepseek-ai-dsh-subagent-claude-code/use-this-package/exposing-the-tool: - en: 3aaf657b4447f12a - zh: dca5660f53750c66 + en: ebf6ba7fef393ebb + zh: fad30b829e2e4188 /deepseek-ai-dsh-subagent-claude-code/use-this-package/what-you-get: en: a731eabea54893d5 zh: e370dba101b3a7e8 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 6affb549de..0c3f584048 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,11 +29,11 @@ Enable **Claude Code subagent** in the Web or Desktop Plugins page when a task n ### Installing the Bundle -The Official entry is visible offline. Enabling it installs the [target for the running DSH installation](../../boot/plugin-manager/README.md#use-this-package) through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_claude_code` to `standard`, `cordis`, and `ptc`; it starts no native process until delegation. Restart when the installer reports that one is required. +The Official entry is visible offline. Enabling it installs the [target for the running DSH installation](../../boot/plugin-manager/README.md#use-this-package) through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_claude_code` to every Agent as a global tool; it starts no native process until delegation. Restart when the installer reports that one is required. -Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. New Agents and subsequently reopened Sessions use the selected tool composition; live Agents retain their existing composition. +Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. Enabling or switching Off registers or removes the global tool in the running Host, so live Agents see the change on their next request. -Profiles without the full Web presets, including the shipped headless, SDK, and ACP profiles, must install this package as a profile dependency without selecting its bundle layer, then mount the provider and delegation tool explicitly. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). +The layer inserts Host rows only, so any profile can select it, including the shipped headless, SDK, and ACP profiles. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). ### Configuration @@ -58,13 +58,13 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Exposing the tool -The bundle adds `tool-subagent-claude-code` to each full preset through scoped profile patches. Later user patches can configure or disable that row; a complete preset replacement keeps its own child list. Avoid declaring the same model-facing tool twice. The minimal preset and host tool catalog remain unchanged. For a profile without these preset targets, keep the package installed without selecting its bundle layer, then mount the provider and tool explicitly in `cordis.patch.yml`: +The bundle inserts `tool-subagent-claude-code` as a Host row, so `subagent_claude_code` is a global tool visible to every preset, including minimal. Later user patches can configure or disable that row by id. A preset that registers its own tool with the same name shadows the global tool for its Agents. To mount the provider and tool without selecting the bundle layer, insert the same rows in `cordis.patch.yml`: ```yaml - insert: - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' - - id: tool-subagent-claude + - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' config: provider: claude-code diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index cf5dd02472..f1fd54db4b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,11 +29,11 @@ kind: "package-bundle" ### 安装 Bundle -官方条目可离线显示。启用时,普通 bundle 安装器会安装[当前 DSH 安装对应的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择其配置层。此层注册提供方,并为 `standard`、`cordis` 和 `ptc` 添加 `subagent_claude_code`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 +官方条目可离线显示。启用时,普通 bundle 安装器会安装[当前 DSH 安装对应的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择其配置层。此层注册提供方,并为所有智能体添加全局工具 `subagent_claude_code`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 -关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。新智能体和随后重新打开的会话使用所选工具组合;正在运行的智能体保留已有组合。 +关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。启用或关闭会在运行中的 Host 注册或移除该全局工具,正在运行的智能体会在下一次请求时看到变化。 -不包含完整 Web 预设的配置(包括随附的 headless、SDK 和 ACP)必须将此包安装为配置依赖,而不选择其 bundle 层,然后显式挂载提供方和委派工具。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 +此层只插入 Host 条目,因此任何配置(包括随附的 headless、SDK 和 ACP)都可以选择它。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 ### 配置 @@ -58,13 +58,13 @@ kind: "package-bundle" ### 暴露工具 -Bundle 通过作用于预设的配置补丁为每个完整预设添加 `tool-subagent-claude-code`。后续用户补丁可以配置或禁用该行;完整的预设替换保留自己的子列表。不要重复声明同一个面向模型的工具。minimal 预设和 Host 工具目录保持不变。对于不包含这些预设目标的 profile,保留已安装的包但不选择其 bundle 层,然后在 `cordis.patch.yml` 中显式挂载提供方和工具: +Bundle 将 `tool-subagent-claude-code` 作为 Host 条目插入,因此 `subagent_claude_code` 是所有预设(包括 minimal)都可见的全局工具。后续用户补丁可以按 id 配置或禁用该行。预设自行注册的同名工具会为其智能体遮蔽该全局工具。若要不选择 bundle 层而挂载提供方和工具,在 `cordis.patch.yml` 中插入相同的条目: ```yaml - insert: - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' - - id: tool-subagent-claude + - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' config: provider: claude-code diff --git a/packages/subagent/subagent-claude-code/cordis.patch.yml b/packages/subagent/subagent-claude-code/cordis.patch.yml index 9640c3b375..869362cfda 100644 --- a/packages/subagent/subagent-claude-code/cordis.patch.yml +++ b/packages/subagent/subagent-claude-code/cordis.patch.yml @@ -1,27 +1,6 @@ - insert: - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' - -- preset: preset-standard - insert: - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: claude-code - toolName: subagent_claude_code - maxDepth: provider-managed - -- preset: preset-cordis - insert: - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: claude-code - toolName: subagent_claude_code - maxDepth: provider-managed - -- preset: preset-ptc - insert: - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 77e0e053cd..9f9bc27d81 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -343,7 +343,7 @@ afterEach(() => { }) describe('task admission and package contracts', () => { - it('ships the provider with scoped delegation tools for all full Web presets', () => { + it('ships the provider with a global delegation tool', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record @@ -393,15 +393,12 @@ describe('task admission and package contracts', () => { } const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) - expect(parsed).toEqual([ - { insert: [{ id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' }] }, - ...['standard', 'cordis', 'ptc'].map(preset => ({ - preset: `preset-${preset}`, - insert: [{ id: 'tool-subagent-claude-code', name: '@deepseek-ai/dsh-tool-subagent', config: { - provider: 'claude-code', toolName: 'subagent_claude_code', maxDepth: 'provider-managed', - } }], - })), - ]) + expect(parsed).toEqual([{ insert: [ + { id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' }, + { id: 'tool-subagent-claude-code', name: '@deepseek-ai/dsh-tool-subagent', config: { + provider: 'claude-code', toolName: 'subagent_claude_code', maxDepth: 'provider-managed', + } }, + ] }]) expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-tool-subagent', 'workspace:*') }) diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 3c9bdca4cd..552670d211 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -9,8 +9,8 @@ en: c3f2c833cb91228d zh: 3267d24936c5973f /deepseek-ai-dsh-subagent-codex/summary: - en: b5420b9efbea49e9 - zh: 9e839aa4424f6cc1 + en: 6eade95bb21eae52 + zh: 72de7d6fcdc0c31a /deepseek-ai-dsh-subagent-codex/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 2ac37ff256c2e329 zh: 95be2a38e7c3d094 /deepseek-ai-dsh-subagent-codex/use-this-package/installing-the-bundle: - en: f214bf25661f9c1d - zh: c3431bacf95a7ce6 + en: 7740af6d6cb86814 + zh: e5eb3546e160bac8 /deepseek-ai-dsh-subagent-codex/use-this-package/configuration: en: b8163849d388478e zh: cf74fcaeef62c7ae /deepseek-ai-dsh-subagent-codex/use-this-package/exposing-the-tool: - en: 0bc2524b094bf6e8 - zh: 4349dd71e147834a + en: a36399c7ac98724d + zh: d43815e08689da6b /deepseek-ai-dsh-subagent-codex/use-this-package/what-you-get: en: 4b271f17f16fb162 zh: 154c909023f1366b diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index fe5932ebf3..e56699e7c6 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Install `@deepseek-ai/dsh-subagent-codex` into a Profile when delegated work should run in a genuine, unattended Codex session in the parent Session's workspace. Each delegation uses a fresh isolated Codex thread for one self-contained text task and returns only its final answer or a safe failure diagnostic. Native Codex configuration and authentication remain authoritative, while `permissionMode` selects the non-interactive approval and sandbox behavior. The Bundle supplies a compatible native Codex payload and grants its delegation tool to the full Web presets. +Install `@deepseek-ai/dsh-subagent-codex` into a Profile when delegated work should run in a genuine, unattended Codex session in the parent Session's workspace. Each delegation uses a fresh isolated Codex thread for one self-contained text task and returns only its final answer or a safe failure diagnostic. Native Codex configuration and authentication remain authoritative, while `permissionMode` selects the non-interactive approval and sandbox behavior. The Bundle supplies a compatible native Codex payload and adds its delegation tool as a global tool. ## Table of Contents @@ -29,11 +29,11 @@ Enable **Codex subagent** in the Web or Desktop Plugins page when a task needs a ### Installing the Bundle -The Official entry is visible offline. Enabling it installs the [target for the running DSH installation](../../boot/plugin-manager/README.md#use-this-package) through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_codex` to `standard`, `cordis`, and `ptc`; it starts no native process until delegation. Restart when the installer reports that one is required. +The Official entry is visible offline. Enabling it installs the [target for the running DSH installation](../../boot/plugin-manager/README.md#use-this-package) through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_codex` to every Agent as a global tool; it starts no native process until delegation. Restart when the installer reports that one is required. -Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. New Agents and subsequently reopened Sessions use the selected tool composition; live Agents retain their existing composition. +Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. Enabling or switching Off registers or removes the global tool in the running Host, so live Agents see the change on their next request. -Profiles without the full Web presets, including the shipped headless, SDK, and ACP profiles, must install this package as a profile dependency without selecting its bundle layer, then mount the provider and delegation tool explicitly. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). +The layer inserts Host rows only, so any profile can select it, including the shipped headless, SDK, and ACP profiles. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). ### Configuration @@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Exposing the tool -The bundle adds `tool-subagent-codex` to each full preset through scoped profile patches. Later user patches can configure or disable that row; a complete preset replacement keeps its own child list. Avoid declaring the same model-facing tool twice. The minimal preset and host tool catalog remain unchanged. For a profile without these preset targets, keep the package installed without selecting its bundle layer, then mount the provider and tool explicitly in `cordis.patch.yml`: +The bundle inserts `tool-subagent-codex` as a Host row, so `subagent_codex` is a global tool visible to every preset, including minimal. Later user patches can configure or disable that row by id. A preset that registers its own tool with the same name shadows the global tool for its Agents. To mount the provider and tool without selecting the bundle layer, insert the same rows in `cordis.patch.yml`: ```yaml - insert: diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1ec4d35a7b..0b13f4c7e1 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当委派工作需要在父会话工作区中的真实无人值守 Codex 会话内运行时,把 `@deepseek-ai/dsh-subagent-codex` 安装进 Profile。每次委派都会为一个自包含文本任务使用全新且隔离的 Codex 线程,并且只返回其最终答案或安全失败诊断。原生 Codex 配置和身份验证继续作为权威来源,而 `permissionMode` 选择非交互式审批和沙箱行为。Bundle 提供兼容的原生 Codex 载荷,并为完整 Web 预设添加委派工具。 +当委派工作需要在父会话工作区中的真实无人值守 Codex 会话内运行时,把 `@deepseek-ai/dsh-subagent-codex` 安装进 Profile。每次委派都会为一个自包含文本任务使用全新且隔离的 Codex 线程,并且只返回其最终答案或安全失败诊断。原生 Codex 配置和身份验证继续作为权威来源,而 `permissionMode` 选择非交互式审批和沙箱行为。Bundle 提供兼容的原生 Codex 载荷,并以全局工具形式添加委派工具。 ## 目录 @@ -29,11 +29,11 @@ kind: "package-bundle" ### 安装 Bundle -官方条目可离线显示。启用时,普通 bundle 安装器会安装[当前 DSH 安装对应的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择其配置层。此层注册提供方,并为 `standard`、`cordis` 和 `ptc` 添加 `subagent_codex`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 +官方条目可离线显示。启用时,普通 bundle 安装器会安装[当前 DSH 安装对应的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择其配置层。此层注册提供方,并为所有智能体添加全局工具 `subagent_codex`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 -关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。新智能体和随后重新打开的会话使用所选工具组合;正在运行的智能体保留已有组合。 +关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。启用或关闭会在运行中的 Host 注册或移除该全局工具,正在运行的智能体会在下一次请求时看到变化。 -不包含完整 Web 预设的配置(包括随附的 headless、SDK 和 ACP)必须将此包安装为配置依赖,而不选择其 bundle 层,然后显式挂载提供方和委派工具。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 +此层只插入 Host 条目,因此任何配置(包括随附的 headless、SDK 和 ACP)都可以选择它。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 ### 配置 @@ -56,7 +56,7 @@ kind: "package-bundle" ### 暴露工具 -Bundle 通过作用于预设的配置补丁为每个完整预设添加 `tool-subagent-codex`。后续用户补丁可以配置或禁用该行;完整的预设替换保留自己的子列表。不要重复声明同一个面向模型的工具。minimal 预设和 Host 工具目录保持不变。对于不包含这些预设目标的 profile,保留已安装的包但不选择其 bundle 层,然后在 `cordis.patch.yml` 中显式挂载提供方和工具: +Bundle 将 `tool-subagent-codex` 作为 Host 条目插入,因此 `subagent_codex` 是所有预设(包括 minimal)都可见的全局工具。后续用户补丁可以按 id 配置或禁用该行。预设自行注册的同名工具会为其智能体遮蔽该全局工具。若要不选择 bundle 层而挂载提供方和工具,在 `cordis.patch.yml` 中插入相同的条目: ```yaml - insert: diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml index 18b41f9cbf..195ba10a2b 100644 --- a/packages/subagent/subagent-codex/cordis.patch.yml +++ b/packages/subagent/subagent-codex/cordis.patch.yml @@ -1,27 +1,6 @@ - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' - -- preset: preset-standard - insert: - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: codex - toolName: subagent_codex - maxDepth: provider-managed - -- preset: preset-cordis - insert: - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: codex - toolName: subagent_codex - maxDepth: provider-managed - -- preset: preset-ptc - insert: - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 2c6189d5a6..b1f39bedf2 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -359,7 +359,7 @@ function expectedFailureDiagnostic( } describe('task admission and package contracts', () => { - it('ships the provider with scoped delegation tools for all full Web presets', () => { + it('ships the provider with a global delegation tool', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record @@ -406,15 +406,12 @@ describe('task admission and package contracts', () => { } const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) - expect(parsed).toEqual([ - { insert: [{ id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' }] }, - ...['standard', 'cordis', 'ptc'].map(preset => ({ - preset: `preset-${preset}`, - insert: [{ id: 'tool-subagent-codex', name: '@deepseek-ai/dsh-tool-subagent', config: { - provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed', - } }], - })), - ]) + expect(parsed).toEqual([{ insert: [ + { id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' }, + { id: 'tool-subagent-codex', name: '@deepseek-ai/dsh-tool-subagent', config: { + provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed', + } }, + ] }]) expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-tool-subagent', 'workspace:*') }) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 500e4e38a7..215788dd06 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -18,8 +18,8 @@ en: 594ad1f3e6f02054 zh: 7ca4f565976704be /deepseek-ai-dsh-session-snapshot/use-this-package/writing-a-snapshot-suite: - en: 5673d407e97582fd - zh: 6faebb01f2904fe2 + en: 57ff7c89e7696ee8 + zh: b885ac1b4edc8c56 /deepseek-ai-dsh-session-snapshot/use-this-package/record-replay-and-refresh: en: 2bea67c686715289 zh: aecc6ea7b4ae5623 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 14bb6debe7..76c5776f1a 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -78,8 +78,6 @@ Retained historical scenarios keep their canonical Session files unchanged and s Headless/ACP and SDK adapters compare raw catalog child-creation times with harvested child headers before normalization. Refresh preserves the chosen child header time in every matching catalog entry, including inherited copies, so retained header clocks cannot diverge from freshly written catalog clocks. Committed historical inconsistencies remain unchanged migration-refusal evidence; normalizing their clocks does not establish successful migration. -`materializeProfilePatch` keeps an authored patch usable after moving it into an isolated launch directory. It resolves inserted relative module names beside the source patch and links their resolvable bare packages into the temporary profile, including nested groups, preset-scoped insertions, and inserted complete preset definitions. Preset target ids and `!!js` expressions remain literal. - ### Record, replay, and refresh `pnpm run test:snapshot:record` calls the live LLM and writes the harvested current generation under its canonical versioned filename. Record and refresh never rename or delete a completed generation, including generations of a child role absent from a later run; reviewed source-tree curation removes a predecessor only after the same role has a verified current replacement. Scenarios with an explicit `sessionFormat` remain read-only in record mode. `pnpm run test:snapshot:refresh` stays keyless, runs the selected highest replay input, and writes stdout, owned prompt and tool-schema sidecars, and a fresh current-generation comparable Session output; retained historical scenarios write the separate writer-output oracles instead of a canonical current-format replay generation. Headless and SDK scenarios may add a local `runtime.cordis.yml` patch for execution fixtures. Each composition owner keeps its replay patch beside its live patch; top-level `snapshots/` owns Session-driven scenarios, while other expected outputs stay beside their owning package. [`dsh-llm-replay`](../llm-replay/README.md) serves the recorded streams selected through `DSH_SNAPSHOT_*` environment values. diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index e615f87f6f..cf476dee7f 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -78,8 +78,6 @@ spill 场景通过真实本地提供方保存到私有临时根目录。fixture Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创建时间与采集到的子 header 比较。刷新会在每条匹配的目录记录中保留选定的子 header 时间,包括继承副本,防止保留的 header 时钟与新写入的目录时钟分歧。已提交的历史不一致数据保持不变,作为迁移拒绝证据;将其时钟规范化不能证明迁移成功。 -`materializeProfilePatch` 让编写好的 patch 移入隔离启动目录后仍可使用。它以源 patch 所在目录解析插入条目的相对模块名,并把可解析的裸包链接到临时 profile,涵盖嵌套分组、限定到 preset 的插入,以及插入的完整 preset 定义。preset 目标 id 和 `!!js` 表达式保持字面量。 - ### 录制、回放与刷新 `pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并在规范具名版本文件下写入收集到的当前 generation。record 与 refresh 绝不重命名或删除已完成的 generation,即使后续运行不再产生某个 child 角色也一样;受审阅的源树整理只有在同角色存在已验证的当前替代文件后才移除前代。显式声明 `sessionFormat` 的场景在录制模式下保持只读。`pnpm run test:snapshot:refresh` 保持无密钥,运行选定的最高 replay 输入,并写入 stdout、各 pin 自有的提示词与工具 schema 伴随文件,以及新鲜当前 generation 的可比较 Session 输出;保留历史输入的场景写入单独的 writer 输出比较基准,而非规范当前格式 replay 代际。Headless 与 SDK 场景可通过本地 `runtime.cordis.yml` patch 添加执行 fixture。每个组合 owner 把 replay patch 放在 live patch 旁;顶层 `snapshots/` 拥有 Session 驱动场景,其他预期输出留在其 package owner 旁。[`dsh-llm-replay`](../llm-replay/README.zh.md) 提供通过 `DSH_SNAPSHOT_*` 环境值选择的已记录流。 diff --git a/packages/test-support/session-snapshot/src/launcher.ts b/packages/test-support/session-snapshot/src/launcher.ts index c0b3faa8fe..a99ecac2c0 100644 --- a/packages/test-support/session-snapshot/src/launcher.ts +++ b/packages/test-support/session-snapshot/src/launcher.ts @@ -407,7 +407,6 @@ function linkProfilePackage(source: string, cwd: string, profile: string, packag /** * Copy one authored patch into the launch cwd with relative plugin names made absolute. - * Scoped insertions and inserted preset definitions retain package lookup and nested module paths. * @param source - authored profile patch path. * @param cwd - isolated process cwd whose profile receives package links. * @param profile - profile whose local package lookup receives the test links. @@ -434,10 +433,6 @@ export function materializeProfilePatch( if (entry.group === true && Array.isArray(entry.config)) { for (const child of entry.config as ProfilePatchEntry[]) visitEntry(child) } - const config = entry.config as { plugins?: unknown } | undefined - if (entry.name === '@deepseek-ai/dsh-agent-preset' && Array.isArray(config?.plugins)) { - for (const child of config.plugins as ProfilePatchEntry[]) visitEntry(child) - } } for (const patch of patches) { if (typeof patch.name === 'string') patch.name = resolveName(patch.name) diff --git a/packages/test-support/session-snapshot/tests/profile-patches.spec.ts b/packages/test-support/session-snapshot/tests/profile-patches.spec.ts deleted file mode 100644 index 50763c2e89..0000000000 --- a/packages/test-support/session-snapshot/tests/profile-patches.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** Materialized profile patches retain scoped declaration paths and package ownership. */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { load } from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { expect, it, onTestFinished } from 'vitest' -import { materializeProfilePatch } from '../src/launcher.ts' - -it('discovers relocated packages and relative modules in scoped and complete preset insertions', () => { - const dir = mkdtempSync(join(tmpdir(), 'snapshot-preset-patch-')) - onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) - const sourceDir = join(dir, 'authored') - const runDir = join(dir, 'run') - const targetDir = join(runDir, 'patches') - mkdirSync(sourceDir) - mkdirSync(targetDir, { recursive: true }) - const packages = ['@deepseek-ai/dsh-tool-terminal', '@deepseek-ai/dsh-agent-preset'] - for (const name of packages) { - const packageDir = join(sourceDir, 'node_modules', name) - mkdirSync(packageDir, { recursive: true }) - writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version: '1.0.0' })) - } - const source = join(sourceDir, 'cordis.patch.yml') - writeFileSync(source, [ - '- preset: preset-ptc', - ' insert:', - ' - id: scoped-group', - ' name: cordis:group', - ' group: true', - ' config:', - ' - id: relocated-terminal', - " name: '@deepseek-ai/dsh-tool-terminal'", - ' - id: relative', - ' name: ./relative.mjs', - ' disabled: !!js process.platform === "win32"', - '- insert:', - ' - id: preset-added', - " name: '@deepseek-ai/dsh-agent-preset'", - ' config:', - ' id: added', - ' plugins:', - ' - id: nested', - ' name: cordis:group', - ' group: true', - ' config:', - ' - id: child', - ' name: ../child.mjs', - ' - id: terminal', - " name: '@deepseek-ai/dsh-tool-terminal'", - '- preset: preset-ptc', - ' id: relative', - ' disabled: true', - '', - ].join('\n')) - const output = materializeProfilePatch(source, runDir, 'web', targetDir, 0) - expect(load(readFileSync(output, 'utf8'), { schema: entryListSchema })).toMatchObject([ - { preset: 'preset-ptc', insert: [{ config: [ - { name: packages[0] }, - { name: pathToFileURL(join(sourceDir, 'relative.mjs')).href, disabled: { __jsExpr: 'process.platform === "win32"' } }, - ] }] }, - { insert: [{ config: { plugins: [{ config: [ - { name: pathToFileURL(join(dir, 'child.mjs')).href }, { name: packages[0] }, - ] }] } }] }, - { preset: 'preset-ptc', id: 'relative', disabled: true }, - ]) - for (const name of packages) { - expect(realpathSync(join(runDir, '.dsh', 'profiles', 'web', 'node_modules', name))) - .toBe(realpathSync(join(sourceDir, 'node_modules', name))) - } -}) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index a61391f860..45acbbc609 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -632,6 +632,9 @@ class FaceAnalyzer { private readonly nodes = new Map() private readonly exportsByPackage = new Map() private readonly nodeOrdinals = new Map() + /** Registration and package lookups per source path; the registration set is fixed per analyzer. */ + private readonly registrationByFile = new Map() + private readonly packageNameByFile = new Map() private staticLookups: readonly StaticLookupDeclaration[] | undefined private staticContexts: ReadonlyMap | undefined @@ -2659,14 +2662,22 @@ class FaceAnalyzer { } private registrationForFile(file: string): PackageRegistration | undefined { + if (this.registrationByFile.has(file)) return this.registrationByFile.get(file) const path = realPath(file) - return this.allRegistrations + const registration = this.allRegistrations .find(registration => registration.face === this.face && isWithin(path, registration.root)) + this.registrationByFile.set(file, registration) + return registration } private packageNameForFile(file: string): string { + const cached = this.packageNameByFile.get(file) + if (cached !== undefined) return cached const path = realPath(file) - return this.allRegistrations.find(registration => isWithin(path, registration.root))?.name ?? '' + const name = this.allRegistrations + .find(registration => isWithin(path, registration.root))?.name ?? '' + this.packageNameByFile.set(file, name) + return name } private allocateNodeId(site: ts.Node): TypeNodeId { @@ -3247,9 +3258,15 @@ function formatProgramDiagnostic(root: string, face: TypertFace, diagnostic: ts. } const realPathCache = new Map() +/** Normalized form per requested path; `resolve` reads only the process working directory. */ +const absolutePathCache = new Map() function realPath(path: string): string { - const absolute = resolve(path) + let absolute = absolutePathCache.get(path) + if (absolute === undefined) { + absolute = resolve(path) + absolutePathCache.set(path, absolute) + } const cached = realPathCache.get(absolute) if (cached !== undefined) return cached // Only existing paths are memoized: a path can come into existence later, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5228fdbd7..4f2348de17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1950,12 +1950,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent - '@deepseek-ai/dsh-agent-preset': - specifier: workspace:* - version: link:../../preset/agent-preset - '@deepseek-ai/dsh-agent-preset-registry': - specifier: workspace:* - version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -1974,18 +1968,12 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:* version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-scope': - specifier: workspace:* - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection - '@deepseek-ai/dsh-session-title': - specifier: workspace:* - version: link:../../session/session-title '@deepseek-ai/dsh-subprocess': specifier: workspace:* version: link:../../subprocess/subprocess diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index 5ad22777de..8fe85a7f47 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -67,49 +67,26 @@ describe.each(['web', 'desktop'] as const)('%s optional bundle overrides', (surf const warnings: string[] = [] const entries = composeEntries([...shipped, ...selectedNames.map(name => bundle(name).patches), [ { id: 'skill-badge', disabled: true }, - ...['preset-standard', 'preset-cordis', 'preset-ptc'].map(preset => ({ preset, id: 'tool-ralph', config, disabled: true })), + { id: 'tool-ralph', config, disabled: true }, ]], warning => warnings.push(warning)) expect(warnings).toEqual([]) expect(entries.find(row => row.id === 'skill-badge')).toMatchObject({ name: '@deepseek-ai/dsh-skill-badge', disabled: true }) - for (const preset of ['preset-standard', 'preset-cordis', 'preset-ptc']) { - const roots = presetRows(entries, preset) - expect(flattenRows(roots).find(row => row.id === 'tool-ralph')) - .toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph', config, disabled: true }) - expect(roots.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true }) - } - }) - - it('keeps a later complete preset replacement and its manually declared disabled tool', () => { - const shipped = profileLayers(surface) - const baseline = composeEntries(shipped) - const preset = baseline.find(row => row.id === 'preset-standard')! - const config = { ...preset.config as object, plugins: [...presetRows(baseline, 'preset-standard'), { - id: 'tool-ralph', name: '@deepseek-ai/dsh-tool-ralph', disabled: true, - config: { subagentProvider: 'spawn', maxRounds: 2 }, - }] } - const warnings: string[] = [] - const entries = composeEntries([...shipped, ...selectedNames.map(name => bundle(name).patches), [ - { id: 'preset-standard', config }, - ]], warning => warnings.push(warning)) - expect(warnings).toEqual([]) - expect(entries.find(row => row.id === 'preset-standard')?.config).toEqual(config) - expect(presetRows(entries, 'preset-standard').some(row => row.id === 'optional-ralph')).toBe(false) - const inherited = flattenRows(presetRows(entries, 'preset-cordis')).find(row => row.id === 'tool-ralph') - expect(inherited).toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph' }) - expect(inherited?.disabled).not.toBe(true) + expect(flattenRows(entries).find(row => row.id === 'tool-ralph')) + .toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph', config, disabled: true }) + expect(entries.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true }) }) it('does not select absent bundles through enable-only row overrides', () => { const warnings: string[] = [] const entries = composeEntries([...profileLayers(surface), [ { id: 'skill-badge', disabled: false }, - { preset: 'preset-standard', id: 'tool-ralph', disabled: false }, + { id: 'tool-ralph', disabled: false }, ]], warning => warnings.push(warning)) expect(warnings).toHaveLength(2) expect(warnings.some(warning => warning.includes('skill-badge'))).toBe(true) expect(warnings.some(warning => warning.includes('tool-ralph'))).toBe(true) expect(entries.some(row => row.id === 'skill-badge')).toBe(false) - expect(flattenRows(presetRows(entries, 'preset-standard')).some(row => row.id === 'tool-ralph')).toBe(false) + expect(flattenRows(entries).some(row => row.id === 'tool-ralph')).toBe(false) }) }) @@ -129,10 +106,9 @@ describe('optional bundles', () => { const warnings: string[] = [] const composed = composeEntries([...shipped, patches], message => warnings.push(message)) expect(warnings).toEqual([]) - // Inserted ids remain addressable inside their selected profile or preset tree. + // Inserted ids remain addressable in the composed profile tree. + const ids = new Set(flattenRows(composed).map(entry => entry.id)) for (const patch of patches) { - const rows = typeof patch.preset === 'string' ? presetRows(composed, patch.preset) : composed - const ids = new Set(flattenRows(rows).map(entry => entry.id)) for (const row of patch.insert ?? []) { expect(typeof row.id).toBe('string') expect(ids.has(row.id)).toBe(true) @@ -146,7 +122,7 @@ describe('optional bundles', () => { // row, and the override keeps the package the shipped layer declared on it. const shippedComposed = composeEntries([...shipped]) for (const patch of patches) { - if (patch.insert !== undefined || typeof patch.id !== 'string' || patch.preset !== undefined) continue + if (patch.insert !== undefined || typeof patch.id !== 'string') continue const matches = composed.filter(entry => entry.id === patch.id) expect(matches).toHaveLength(1) expect(matches[0]?.name).toBe(shippedComposed.find(entry => entry.id === patch.id)?.name) @@ -172,7 +148,7 @@ describe('optional bundles', () => { } }) - it('composes every lightweight selection without changing minimal or adding host tools', () => { + it('composes every lightweight selection as Host rows without changing any preset', () => { const baseline = composeEntries(shipped) const layers = lightweight.map(name => bundle(name).patches) for (let mask = 0; mask < 2 ** lightweight.length; mask += 1) { @@ -180,35 +156,29 @@ describe('optional bundles', () => { const warnings: string[] = [] const composed = composeEntries([...shipped, ...selected], warning => warnings.push(warning)) expect(warnings).toEqual([]) - expect(presetRows(composed, 'preset-minimal')).toEqual(presetRows(baseline, 'preset-minimal')) - const addedHost = composed.filter(row => !baseline.some(existing => existing.id === row.id)) - expect(addedHost.every(row => ['skill-badge', 'optional-session-title-all-prompts'].includes(row.id ?? ''))).toBe(true) - for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) { - const rows = flattenRows(presetRows(composed, id)) - const ids = rows.map(row => row.id) - expect(new Set(ids).size).toBe(ids.length) + for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc', 'preset-minimal']) { + expect(presetRows(composed, id)).toEqual(presetRows(baseline, id)) } + const ids = flattenRows(composed).map(row => row.id) + expect(new Set(ids).size).toBe(ids.length) } }) - it('contributes tools inside all full presets with independently isolated services', () => { + it('contributes global tools from Host rows with independently isolated services', () => { const composed = composeEntries([...shipped, ...lightweight.map(name => bundle(name).patches)]) - for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) { - const roots = presetRows(composed, id) - const names = flattenRows(roots).map(row => row.name) - for (const name of [ - '@deepseek-ai/dsh-tool-session-query', - '@deepseek-ai/dsh-tool-ralph', '@deepseek-ai/dsh-tool-terminal', - ]) expect(names).toContain(name) - expect(roots.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true }) - expect(roots.find(row => row.id === 'optional-persistent-terminals')?.isolate).toEqual({ terminals: true }) - const search = roots.find(row => row.id === 'optional-session-search') - expect(search?.isolate).toEqual({ sessionQuery: true }) - expect(search?.config).toEqual([ - { id: 'optional-session-query-sqlite', name: '@deepseek-ai/dsh-session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } }, - { id: 'optional-tool-session-query', name: '@deepseek-ai/dsh-tool-session-query' }, - ]) - } + const names = flattenRows(composed).map(row => row.name) + for (const name of [ + '@deepseek-ai/dsh-tool-session-query', + '@deepseek-ai/dsh-tool-ralph', '@deepseek-ai/dsh-tool-terminal', + ]) expect(names).toContain(name) + expect(composed.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true }) + expect(composed.find(row => row.id === 'optional-persistent-terminals')?.isolate).toEqual({ terminals: true }) + const search = composed.find(row => row.id === 'optional-session-search') + expect(search?.isolate).toEqual({ sessionQuery: true }) + expect(search?.config).toEqual([ + { id: 'optional-session-query-sqlite', name: '@deepseek-ai/dsh-session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } }, + { id: 'optional-tool-session-query', name: '@deepseek-ai/dsh-tool-session-query' }, + ]) expect(composed.find(row => row.id === 'session-query-sqlite')?.config).toEqual({ path: ':memory:', openAt: 'never' }) expect(composed.find(row => row.id === 'session-title-llm')?.disabled).toBe(true) expect(composed.find(row => row.id === 'optional-session-title-all-prompts')?.name) diff --git a/scripts/verify-product-use.spec.ts b/scripts/verify-product-use.spec.ts index 62ce5d36e7..002af7c3b2 100644 --- a/scripts/verify-product-use.spec.ts +++ b/scripts/verify-product-use.spec.ts @@ -243,14 +243,6 @@ describe('product package use', () => { expect(failures(root, declarations())).toContain(`${candidate} must declare dsh.bundle.patch`) }) - it('rejects an on-demand contribution whose preset target is missing', () => { - const root = fixture() - pkg(root, candidateDir, candidate, { dsh: { bundle: { patch: './cordis.patch.yml' } } }) - write(root, `${candidateDir}/cordis.patch.yml`, [{ preset: 'missing-preset', insert: [{ id: 'optional', name: core }] }]) - profiles(root, [], [candidate]) - expect(() => verifyProductUse(root, {})).toThrow(/missing-preset/) - }) - it('rejects a missing workspace source mapping instead of consulting built exports', () => { const root = fixture() pkg(root, candidateDir, candidate) diff --git a/snapshots/sdk/bash-tool/system-prompt.expected.md b/snapshots/sdk/bash-tool/system-prompt.expected.md index 9d0e57d8e6..e19aa74327 100644 --- a/snapshots/sdk/bash-tool/system-prompt.expected.md +++ b/snapshots/sdk/bash-tool/system-prompt.expected.md @@ -1,7 +1,5 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding agent powered by the deepseek-v4-flash model. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files. diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md b/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md index 9f3f3971e2..baf1ec181d 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md @@ -1,7 +1,5 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding agent powered by the deepseek-v4-flash model. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files. diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md index 091159e913..e19aa74327 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md @@ -1,7 +1,5 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding agent powered by the mock-delegate model. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files. diff --git a/snapshots/sdk/text-turn/system-prompt.expected.md b/snapshots/sdk/text-turn/system-prompt.expected.md index 9d0e57d8e6..e19aa74327 100644 --- a/snapshots/sdk/text-turn/system-prompt.expected.md +++ b/snapshots/sdk/text-turn/system-prompt.expected.md @@ -1,7 +1,5 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding agent powered by the deepseek-v4-flash model. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files.