revert(plugin): drop preset-scoped profile patches; ship optional tools as global Host rows
Remove the `{ preset, insert }` / `{ preset, id, ...override }` profile patch
operation added in #5773, together with the app-boot compiler and reload drain,
config-editor filtering, plugin-manager preset row inventory and retained-module
removal checks, the read-only preset rows on the Plugins page, and the
agent-preset-registry row identities that served them.
Session search, Ralph, persistent terminals, Codex, and Claude Code bundles now
insert ordinary Host rows whose tools register in the global tool layer, like the
earlier optional bundles. Every preset, including minimal, sees them, and toggling
a bundle updates live Agents without new composition generations.
This commit is contained in:
1 parent
a2501d0779
commit
b173f3e2a2
117 files changed
+465
-2398
No files matched your search
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: e6ffc783859d7509
|
||||
zh: 8678f323985a24f4
|
||||
en: 701af5a3854f9e0a
|
||||
zh: 68ee2d70e1c65515
|
||||
/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
|
||||
+4
-2
@@ -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. Title replacement waits for disposal of the previous provider.
|
||||
|
||||
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.
|
||||
+4
-2
@@ -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 覆盖激活和模型可见行为。
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: a590bcef6c9760c0
|
||||
zh: 19e6f61c2c7d8d3b
|
||||
/agent-note-discover-official-bundles-without-installing-their-runtimes/decision:
|
||||
en: 31e3c52841f31a11
|
||||
zh: ac2ad6e54ef9e150
|
||||
en: 3a3dbad398a418be
|
||||
zh: 9ef707e746cdfd94
|
||||
/agent-note-discover-official-bundles-without-installing-their-runtimes/alternatives-considered:
|
||||
en: 42cb0f808a145871
|
||||
zh: 225012615d1ecdf5
|
||||
|
||||
@@ -12,7 +12,7 @@ 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.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ 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)拥有操作细节。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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 仍处于稳定前阶段,新增输出元信息保持必填,除非实际消费者需要适配。
|
||||
|
||||
## 暂缓的命名清理
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Context> {
|
||||
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<Context> {
|
||||
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<PresetId, Product[]> = {
|
||||
'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<string[]> {
|
||||
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<Context> {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
- preset: preset-ptc
|
||||
insert:
|
||||
- insert:
|
||||
- id: optional-tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
@@ -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. */
|
||||
@@ -2352,7 +2352,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. */
|
||||
|
||||
@@ -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. */
|
||||
@@ -2354,7 +2354,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. */
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 962b4398c9052959
|
||||
zh: 14471c340a9f6e60
|
||||
/profile-management/management-records:
|
||||
en: a10d3f358e9b7c8e
|
||||
zh: 28b0f44589a5020d
|
||||
en: a1412da0219c2a09
|
||||
zh: b6869fd548f4c693
|
||||
@@ -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<BundleInfo[]>
|
||||
@Remote listBundles(): Promise<BundleInfo[]>
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -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<BundleInfo[]>
|
||||
@Remote listBundles(): Promise<BundleInfo[]>
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -497,12 +497,12 @@ async register(definition: PresetDefinition): Promise<() => Promise<void>>
|
||||
|
||||
/** 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<AgentPreset[]>
|
||||
|
||||
|
||||
@@ -507,12 +507,12 @@ async register(definition: PresetDefinition): Promise<() => Promise<void>>
|
||||
|
||||
/** 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<AgentPreset[]>
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md
|
||||
/:
|
||||
en: a77928d6130fe601
|
||||
zh: da46d612268c3ced
|
||||
/native-subagent-bundles-include-preset-tools:
|
||||
en: b5c79b41fc34574a
|
||||
zh: d784adcf343b0817
|
||||
/native-subagent-bundles-include-preset-tools/change:
|
||||
en: 7d4af3e4ab91ef72
|
||||
zh: 837377c96d5eee12
|
||||
/native-subagent-bundles-include-preset-tools/migration:
|
||||
en: 3257a5d18d07cada
|
||||
zh: b663a7ae020bbfee
|
||||
en: 4e1c7b1bfb2ba5ca
|
||||
zh: c3f5ac60d10a215f
|
||||
/native-subagent-bundles-include-global-tools:
|
||||
en: 8f785a4b502f1f78
|
||||
zh: 527952686410adba
|
||||
/native-subagent-bundles-include-global-tools/change:
|
||||
en: 3432d6ba2ccf6492
|
||||
zh: 41cdf90f9d5b330d
|
||||
/native-subagent-bundles-include-global-tools/migration:
|
||||
en: 304dc5243b2f8159
|
||||
zh: 38cd9aa136dc8009
|
||||
@@ -1,32 +1,31 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Claude Code and Codex bundles add delegation tools to full Web presets while retaining their package and row identities."
|
||||
description: "Claude Code and Codex bundles add global delegation tools while retaining their package and row identities."
|
||||
---
|
||||
|
||||
# Native subagent bundles include preset tools
|
||||
# Native subagent bundles include global tools
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` and `@deepseek-ai/dsh-subagent-codex` keep their package names, bundle selections, provider row IDs, configuration, and native authentication. In Web and Desktop, a selected bundle now adds enabled delegation tools to `preset-standard`, `preset-cordis`, and `preset-ptc`; previously the bundle registered only its provider. Existing Agents retain their composition; new Agents and subsequently reopened Sessions receive the selected tools.
|
||||
`@deepseek-ai/dsh-subagent-claude-code` and `@deepseek-ai/dsh-subagent-codex` keep their package names, bundle selections, provider row IDs, configuration, and native authentication. A selected bundle now also registers its delegation tool as a Host global tool, visible to every preset including minimal; previously the bundle registered only its provider. Live Agents see the tool from their next request after the bundle is enabled, and lose it after the bundle is switched off.
|
||||
|
||||
The contributed rows retain `tool-subagent-claude-code` and `tool-subagent-codex`. Later user layers can override their configuration or disabled state, and a complete preset configuration replacement keeps its own child list. An override does not select an unselected bundle. Duplicate declarations of the same model-facing tool still fail.
|
||||
The contributed Host rows are `tool-subagent-claude-code` and `tool-subagent-codex`. Later user layers can override their configuration or disabled state by row id. An override does not select an unselected bundle. A preset that registers its own tool with the same name shadows the global tool for its Agents.
|
||||
|
||||
Delegation follows the shared managed-activation API: the tool returns a child ID, and completion arrives as a logged notice to the parent Agent. Remove obsolete `backgroundMode` settings; `maxDepth: provider-managed` remains supported for these native providers.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Preserve selected bundle names, provider configuration, and authentication. Existing provider-only packages need **Update** in Plugins → Official to receive the tool-contributing bundle; restart when requested. To adopt a new provider, enable its card. [Installation targets](../../../../packages/boot/plugin-manager/README.md#use-this-package) follow the published DSH version or source checkout; equal version strings do not make an older registry package equivalent to a development link. Off only deselects; Remove remains separate.
|
||||
2. Configure the familiar tool row ID with a scoped patch. Configuration overrides still replace the complete configuration. For example, this keeps Codex disabled in standard while other full presets use their selected composition:
|
||||
2. Configure the tool with an ordinary row patch. Configuration overrides still replace the complete configuration. For example, this keeps the Codex tool disabled while the provider stays registered:
|
||||
|
||||
```yaml
|
||||
- preset: preset-standard
|
||||
id: tool-subagent-codex
|
||||
- id: tool-subagent-codex
|
||||
disabled: true
|
||||
```
|
||||
|
||||
3. If an additional manual row registers the same tool in a preset, remove that redundant row or disable the bundle's contribution explicitly. A copied complete preset can retain its existing tool declarations and disabled state. Verify the effective tool catalog in a new Session; minimal keeps its current shell and working-directory tools.
|
||||
3. If a manual Host row registers the same tool name, remove that redundant row or disable the bundle's row. Verify the effective tool catalog in a Session.
|
||||
4. Plugin clients receive required `BundleInfo.official` and `BundleInfo.availability` fields; detail-slot `PluginPackageRef` receives them too. `official` identifies project maintenance, `availability` reports `installation`, `profile`, or `missing`, and `installed` still means a profile dependency declaration. Detail-slot readers that only display existing fields need no changes; constructors and exact validators must include the new fields. Optional `installTarget` identifies the offered version and registry or local-link spec.
|
||||
|
||||
**Other profiles.** Headless, SDK, ACP, and custom profiles without the three full preset targets are outside this compatibility scope. Selecting these bundle layers fails for missing preset targets and can retain the saved selection; there is no automatic rollback. Such profiles can keep the installed provider package without selecting its bundle and explicitly compose provider/tool rows using the [Codex](../../../../packages/subagent/subagent-codex/README.md#exposing-the-tool) or [Claude Code](../../../../packages/subagent/subagent-claude-code/README.md#exposing-the-tool) example.
|
||||
**Other profiles.** The bundle layers insert Host rows only, so headless, SDK, ACP, and custom profiles can select them as well. Profiles that keep the provider package without selecting its bundle can compose the same rows using the [Codex](../../../../packages/subagent/subagent-codex/README.md#exposing-the-tool) or [Claude Code](../../../../packages/subagent/subagent-claude-code/README.md#exposing-the-tool) example.
|
||||
@@ -1,32 +1,31 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Claude Code 与 Codex bundle 为完整 Web 预设添加委派工具,同时保留包名和行标识。"
|
||||
description: "Claude Code 与 Codex bundle 添加全局委派工具,同时保留包名和行标识。"
|
||||
---
|
||||
|
||||
# 原生子代理 bundle 包含预设工具
|
||||
# 原生子代理 bundle 包含全局工具
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` 和 `@deepseek-ai/dsh-subagent-codex` 保留包名、bundle 选择、提供方行 ID、配置及原生认证。在 Web 和 Desktop 中,已选中的 bundle 现在会为 `preset-standard`、`preset-cordis` 和 `preset-ptc` 添加启用的委派工具;此前 bundle 只注册提供方。已有 Agent 保留其组合;新 Agent 和之后重新打开的 Session 使用选中的工具。
|
||||
`@deepseek-ai/dsh-subagent-claude-code` 和 `@deepseek-ai/dsh-subagent-codex` 保留包名、bundle 选择、提供方行 ID、配置及原生认证。已选中的 bundle 现在还会把委派工具注册为 Host 全局工具,包括 minimal 在内的所有预设都可见;此前 bundle 只注册提供方。启用 bundle 后,运行中的 Agent 从下一次请求起即可看到该工具;关闭后则不再可见。
|
||||
|
||||
贡献行保留 `tool-subagent-claude-code` 和 `tool-subagent-codex`。后续用户层可以覆盖其配置或禁用状态,完整的预设配置替换保留自己的子列表。覆盖不会选中未选中的 bundle。重复声明同一个面向模型的工具仍会失败。
|
||||
贡献的 Host 行为 `tool-subagent-claude-code` 和 `tool-subagent-codex`。后续用户层可以按行 ID 覆盖其配置或禁用状态。覆盖不会选中未选中的 bundle。预设自行注册的同名工具会为其 Agent 遮蔽该全局工具。
|
||||
|
||||
委派遵循共享的托管 activation API:工具返回子级 ID,完成结果通过已记录的通知发送给父 Agent。移除已废弃的 `backgroundMode` 设置;这些原生提供方仍支持 `maxDepth: provider-managed`。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 保留已选中的 bundle 名称、提供方配置和认证。已有仅提供方的包需要在插件 → 官方中**更新**才能获得贡献工具的 bundle;按提示重启。要使用新的提供方,启用其卡片。[安装目标](../../../../packages/boot/plugin-manager/README.zh.md#use-this-package)对应已发布的 DSH 版本或源码 checkout;版本字符串相同不代表旧注册表包等同于开发链接。关闭只取消选择;删除仍为独立操作。
|
||||
2. 使用作用域补丁配置熟悉的工具行 ID。配置覆盖仍会替换整份配置。例如,以下补丁在 standard 中保持 Codex 禁用,其他完整预设则使用其选中的组合:
|
||||
2. 使用普通行补丁配置该工具。配置覆盖仍会替换整份配置。例如,以下补丁在保留提供方注册的同时禁用 Codex 工具:
|
||||
|
||||
```yaml
|
||||
- preset: preset-standard
|
||||
id: tool-subagent-codex
|
||||
- id: tool-subagent-codex
|
||||
disabled: true
|
||||
```
|
||||
|
||||
3. 如果额外的手动行在预设中注册了同一个工具,移除该重复行,或显式禁用 bundle 的贡献行。复制的完整预设可以保留其现有工具声明和禁用状态。在新 Session 中验证有效工具目录;minimal 保留当前的 shell 和工作目录工具。
|
||||
3. 如果手动的 Host 行注册了同名工具,移除该重复行,或禁用 bundle 的贡献行。在 Session 中验证有效工具目录。
|
||||
4. 插件客户端收到必填的 `BundleInfo.official` 和 `BundleInfo.availability` 字段;详情 slot 的 `PluginPackageRef` 也会收到它们。`official` 标识项目维护,`availability` 表示 `installation`、`profile` 或 `missing`,`installed` 仍表示 profile 的依赖声明。只展示已有字段的详情 slot 读取方无需修改;构造方和精确校验器必须包含新增字段。可选的 `installTarget` 标识所提供的版本及注册表或本地链接 spec。
|
||||
|
||||
**其他 profile。** Headless、SDK、ACP 和缺少三个完整预设目标的自定义 profile 不在此次兼容范围内。选择这些 bundle 层会因缺少预设目标而失败,并可能保留已保存的选择;没有自动回滚。这些 profile 可以保留已安装的提供方包而不选择其 bundle,并参考 [Codex](../../../../packages/subagent/subagent-codex/README.zh.md#exposing-the-tool) 或 [Claude Code](../../../../packages/subagent/subagent-claude-code/README.zh.md#exposing-the-tool) 示例显式组合提供方和工具行。
|
||||
**其他 profile。** 这些 bundle 层只插入 Host 行,因此 headless、SDK、ACP 和自定义 profile 也可以选择它们。保留提供方包而不选择其 bundle 的 profile,可以参考 [Codex](../../../../packages/subagent/subagent-codex/README.zh.md#exposing-the-tool) 或 [Claude Code](../../../../packages/subagent/subagent-claude-code/README.zh.md#exposing-the-tool) 示例组合相同的行。
|
||||
@@ -1,16 +0,0 @@
|
||||
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.md
|
||||
/:
|
||||
en: 25de822f0f884eb9
|
||||
zh: c910706695f91c76
|
||||
/identify-bundle-rows-by-their-preset:
|
||||
en: 95cdaba0de8fe664
|
||||
zh: f7382ff6b9f65a49
|
||||
/identify-bundle-rows-by-their-preset/change:
|
||||
en: e43402c18f6b6de8
|
||||
zh: 32f3e46546e9d1f8
|
||||
/identify-bundle-rows-by-their-preset/migration:
|
||||
en: 89cead6b9cd199bd
|
||||
zh: 8a13a234d9feacbb
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Plugin-manager bundle inventories include preset-scoped rows whose row IDs are unique only within their preset."
|
||||
---
|
||||
|
||||
# Identify bundle rows by their preset
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The next release adds preset-scoped declarations to `PluginManager.listBundles().rows`. A bundle can declare the same `rowId` in several presets. `PluginRowRef` values passed to detail slots carry the same optional `preset` identity. These rows carry `preset`, optional `composition` state, and `readOnlyReason: 'preset-managed'`; they do not carry an editable root `entryId`.
|
||||
|
||||
Clients that key rows only by bundle name and `rowId`, or treat every row as a root plugin switch, must distinguish preset declarations. Existing root rows retain their current identity and controls.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Include `preset` in row identity, alongside the bundle name and `rowId`. Keep the absent-preset case distinct from every named preset.
|
||||
2. Render `preset-managed` rows read-only in direct row controls. Use the bundle switch or an explicit scoped profile patch to configure them. Only pass a returned root `entryId` to `setPluginEnabled`.
|
||||
3. Read preset row state from `composition`, instead of looking up a root Loader entry with the same `rowId`.
|
||||
4. For YAML edits, use `preset` to address the outer declaration and `id` to address its child:
|
||||
|
||||
```yaml
|
||||
- preset: preset-standard
|
||||
id: optional-tool-str-replace-editor
|
||||
disabled: true
|
||||
```
|
||||
|
||||
5. Verify that a bundle contributing to standard, cordis, and ptc shows three distinct rows and that root-row switches continue to work. The [profile patch reference](../../../../packages/boot/app-boot/README.md) describes ordering and validation.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "插件管理器的组合包清单包含预设作用域行,其行 ID 仅在所属预设内唯一。"
|
||||
---
|
||||
|
||||
# 按所属预设区分组合包行
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
下一个版本在 `PluginManager.listBundles().rows` 中加入预设作用域声明。一个组合包可以在多个预设中声明相同的 `rowId`。详情插槽中的 `PluginRowRef` 也携带可选的 `preset` 标识。这些行包含 `preset`、可选的 `composition` 状态和 `readOnlyReason: 'preset-managed'`,不包含可编辑的根级 `entryId`。
|
||||
|
||||
仅用组合包名和 `rowId` 作为行标识,或将所有行都视为根插件开关的客户端,必须区分预设声明。已有根行保留当前标识和控制方式。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 将 `preset` 与组合包名、`rowId` 一起纳入行标识。区分没有预设的情况与每个具名预设。
|
||||
2. 在直接行控制中只读展示 `preset-managed` 行。通过组合包开关或显式的预设作用域 profile patch 配置这些行。只将实际返回的根级 `entryId` 传给 `setPluginEnabled`。
|
||||
3. 从 `composition` 读取预设行状态,不要按同一个 `rowId` 查找根 Loader 条目。
|
||||
4. 编辑 YAML 时,用 `preset` 定位外层声明,用 `id` 定位其子行:
|
||||
|
||||
```yaml
|
||||
- preset: preset-standard
|
||||
id: optional-tool-str-replace-editor
|
||||
disabled: true
|
||||
```
|
||||
|
||||
5. 确认同时向 standard、cordis 和 ptc 贡献行的组合包显示三个不同的行,且根行开关仍可使用。[Profile patch 参考](../../../../packages/boot/app-boot/README.zh.md)说明顺序与校验规则。
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 1099528bde0e2aee
|
||||
zh: b68772bdb9bb7856
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
|
||||
en: 1db121100677e45f
|
||||
zh: c6a8c6316020eb1b
|
||||
en: d50b0fdaf62353dd
|
||||
zh: 0fccb6968602e5de
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
|
||||
en: 646a0ccdd7881ad1
|
||||
zh: e3696acb1573dd7b
|
||||
@@ -33,11 +33,11 @@
|
||||
en: c4a301652b851ce9
|
||||
zh: 784871e19cd9db0a
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/reading-plugin-display-metadata:
|
||||
en: ebf6cb3960a5b378
|
||||
zh: 41ba129a0f86c168
|
||||
en: a9afb263b03d112c
|
||||
zh: 31f5e797cd953c15
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures:
|
||||
en: b2e5cfdf8847d214
|
||||
zh: 8d65771a3bd8a7c9
|
||||
en: 09890e218ab49c84
|
||||
zh: e3d86597e7fb1adb
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives:
|
||||
en: 07c459f26f4f8517
|
||||
zh: 210f1e4894731b1a
|
||||
|
||||
@@ -62,21 +62,7 @@ Your machine-local preferences also live in the Harness home:
|
||||
|
||||
The enabled `dsh-hmr` plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) configuration writes; package operations run outside its queue. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart.
|
||||
|
||||
Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows, their nested groups, and inserted preset definitions; existing-entry name assertions and replacement `config` values remain literal.
|
||||
|
||||
A profile patch may set `preset` to an outer row id and apply the remaining ordinary patch fields inside that row's literal `config.plugins` list. Any bundle or user layer can contribute these operations. Each operation sees the current child list; later user operations take precedence, and a later whole-preset `config` replacement replaces every earlier child contribution. Ordinary outer patches retain Include's single-index lookup: children introduced by replacing a group's `config` are not addressable outer targets. Missing or malformed outer preset targets and duplicate child ids introduced by an insertion fail composition. An unmatched inner `id` warns and skips, so disabling a bundle does not invalidate an old user override of its child.
|
||||
|
||||
```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` extends the native patch fields with `preset`. `applyProfilePatches` composes these operations; `compileProfilePatches` returns ordinary Include patches. Startup, profile reload, effective-config dumps, schema inspection, and compatibility preflight share this compiler. Scoped `insert` module paths resolve beside their declaring patch, including nested groups; `!!js` remains unevaluated until each child activates. Native included-file patch lists keep their ordinary Include syntax. Loaded scoped insertions without IDs receive stable internal IDs derived from the canonical declaring file and entry position, including nested groups; ordinary anonymous root rows keep Loader’s existing behavior. Generated IDs identify contributions across reloads and retained revisions, and are implementation details rather than names to use in authored patches. Compiler failures name the declaring patch file and operation, including after profile layers are combined.
|
||||
Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
|
||||
|
||||
Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links.
|
||||
|
||||
@@ -98,8 +84,6 @@ The projector preserves native omission behavior by checking literal defaults ag
|
||||
|
||||
`resolvePluginResource(specifier, parentURL)` resolves a plugin module or exported resource to its local filesystem path through the active Node ESM resolver without evaluating it. It throws when the resolver is unavailable or the resource cannot resolve to a local file.
|
||||
|
||||
`realModuleFile(path)` canonicalizes an existing module resource in Node and packaged executables. It follows physical file symlinks and preserves archive-backed resources through their canonical containing directory. Missing or inaccessible resources throw.
|
||||
|
||||
Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. For a package-root specifier, missing locale fields fall back to the accessible `package.json`; a subpath specifier never reads a `package.json`. Malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads an image data URL, even when locale text is complete: a package root uses its manifest `icon`, or `<package>/icon` when that field is omitted; a subpath uses `<specifier>/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
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### 启动与重载失败
|
||||
|
||||
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 启动审计,也不会回滚整个更新。
|
||||
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ConfigSchemaDump> {
|
||||
const diagnostics: ConfigSchemaDiagnostic[] = []
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<void> {
|
||||
const rows = applyProfilePatches(initial, patches)
|
||||
const next = new Map<string, boolean>()
|
||||
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<typeof applyEntryPatches>[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<typeof applyEntryPatches> => {
|
||||
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<Entry | undefined> {
|
||||
@@ -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> | void,
|
||||
bareModuleBaseUrl?: string,
|
||||
): Promise<Context> {
|
||||
|
||||
@@ -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)) ?? []),
|
||||
|
||||
@@ -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<ProfilePatch, PatchSource>()
|
||||
|
||||
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<string>()
|
||||
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<string, unknown> & { 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)
|
||||
}
|
||||
@@ -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++])))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof import('node:fs')>()
|
||||
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')
|
||||
})
|
||||
@@ -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<EntryOptions, 'config'> & {
|
||||
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,
|
||||
}] }] })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
|
||||
@@ -42,8 +42,6 @@ kind: "package-reference"
|
||||
|
||||
读取配置时,没有 profile config 覆盖项的条目共用一次组合结果。有覆盖项的条目分别组合,仅移除自身的覆盖项;profile 插入的条目和其他条目的覆盖项仍然生效。返回的配置为独立副本,组合结果不跨读取缓存。
|
||||
|
||||
Host 配置读取、编辑和重置均忽略预设作用域操作,包括子项 ID 与 Host 条目 ID 相同的操作。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
|
||||
@@ -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<string, EntryOptions>()
|
||||
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<string, unknown>),
|
||||
override: structuredClone((hostPatches.findLast(
|
||||
override: structuredClone((loaded.patches.findLast(
|
||||
row => row.id === entry.options.id && row.config !== undefined,
|
||||
)?.config ?? {}) as Record<string, unknown>),
|
||||
}))
|
||||
@@ -72,7 +71,7 @@ export class ConfigEditor extends Service {
|
||||
|
||||
private inherited(entry: Entry, loaded: ReturnType<typeof loadProfileDirectory>): Record<string, unknown> {
|
||||
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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 负责。
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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<PluginPackages, 'packageOf'>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
@@ -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<BundleInfo[]> {
|
||||
const inventory = await readPluginInventory(this.ctx)
|
||||
const definitions = await this.ctx.get('agentPresets')?.list() ?? []
|
||||
listBundles(): Promise<BundleInfo[]> {
|
||||
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,38 @@ 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<BundleInfo, 'rows' | 'overrides'> {
|
||||
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<BundleInfo, 'rows' | 'overrides'> {
|
||||
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<string, { entryId: PluginEntryId; baseUrl: string | undefined }>()
|
||||
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)) {
|
||||
for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
|
||||
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
|
||||
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 +781,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 +815,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<string[]> {
|
||||
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<string[]> {
|
||||
if (this.ownerContext.get('hmr') === undefined) return []
|
||||
return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
|
||||
}
|
||||
|
||||
private async change(
|
||||
|
||||
@@ -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<string>()
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<AgentPresetPluginRow, 'enabled' | 'fiberPhase'>
|
||||
/** The module the row names. */
|
||||
moduleName: string
|
||||
/** Local package display metadata, including rows whose bundle is disabled. */
|
||||
|
||||
@@ -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')))
|
||||
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<typeof Promise.withResolvers<SessionTitleProviderResult>>
|
||||
}[] = []
|
||||
const entered: { current: ReturnType<typeof Promise.withResolvers<undefined>> } = { current: Promise.withResolvers<undefined>() }
|
||||
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<SessionTitleProviderResult>()
|
||||
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<void> {
|
||||
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<void>((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<undefined>()
|
||||
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<undefined>()
|
||||
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<undefined>()
|
||||
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) })
|
||||
})
|
||||
@@ -64,9 +64,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-preset-registry"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 和模块名筛选的输入框。
|
||||
|
||||
### 配置页
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Switch
|
||||
@@ -208,9 +207,8 @@ function RowSwitch({ row, title, t, busy, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** What a row's state line says: conditional, off, or its active fiber phase. */
|
||||
/** What a row's state line says: off, or the phase its fiber is in. */
|
||||
function rowStateText(row: PackageRow, t: Translate): string {
|
||||
if (row.conditional) return t('rowStateConditional')
|
||||
if (!row.enabled) return t('partOff')
|
||||
return row.phase === null ? t('rowStateIdle') : t(PHASE_KEYS[row.phase])
|
||||
}
|
||||
@@ -243,7 +241,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
const query = filter.trim().toLowerCase()
|
||||
const localized = rows.map(row => ({ 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 (
|
||||
<section className={css.detailSection} data-plugin-rows>
|
||||
<div className={css.sectionHead}>
|
||||
@@ -270,10 +268,10 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
<ul className={css.rows}>
|
||||
{shown.map(({ row, title, description }) => (
|
||||
<li
|
||||
key={packageRowKey(row)}
|
||||
key={row.rowId}
|
||||
className={css.row}
|
||||
data-plugin-row={row.entryId ?? packageRowKey(row)}
|
||||
{...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled || row.conditional ? {} : { 'data-state': 'off' }}
|
||||
data-plugin-row={row.entryId ?? row.rowId}
|
||||
{...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
|
||||
>
|
||||
<div className={css.rowLine}>
|
||||
<span className={css.rowIcon} aria-hidden="true"><PackageArtwork key={row.meta?.icon} src={row.meta?.icon} row /></span>
|
||||
@@ -287,7 +285,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
)
|
||||
: <span className={css.rowId}>{title}</span>}
|
||||
{description === undefined ? null : <span className={css.rowModule}>{description}</span>}
|
||||
{title === row.rowId && row.preset === undefined ? null : <code className={css.rowModule}>{row.preset === undefined ? row.rowId : `${row.preset}/${row.rowId}`}</code>}
|
||||
{title === row.rowId ? null : <code className={css.rowModule}>{row.rowId}</code>}
|
||||
{title === row.moduleName ? null : <code className={css.rowModule}>{row.moduleName}</code>}
|
||||
</div>
|
||||
<span className={css.rowState}>
|
||||
@@ -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. */
|
||||
@@ -1401,7 +1398,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 => {
|
||||
@@ -1409,8 +1406,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'))
|
||||
|
||||
@@ -58,7 +58,6 @@ export const zh = {
|
||||
backToPackage: '返回 {name}',
|
||||
configureRow: '配置 {name}',
|
||||
rowStateIdle: '未运行',
|
||||
rowStateConditional: '取决于条件',
|
||||
uninstall: '卸载',
|
||||
uninstallLabel: '卸载 {name}',
|
||||
installTitle: '添加插件',
|
||||
@@ -200,7 +199,6 @@ export const zh = {
|
||||
failedRowDisable: '组件停用失败:{reason}',
|
||||
reasonManagementRequired: '插件管理所需,不能停用或卸载',
|
||||
reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项',
|
||||
reasonPresetManaged: '通过所属预设或组合包配置此项',
|
||||
reasonUnknownPlugin: '找不到该插件',
|
||||
reasonInvalidSpec: '请输入有效的包名或地址',
|
||||
reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包',
|
||||
@@ -272,7 +270,6 @@ export const en = {
|
||||
backToPackage: 'Back to {name}',
|
||||
configureRow: 'Configure {name}',
|
||||
rowStateIdle: 'Not running',
|
||||
rowStateConditional: 'Conditional',
|
||||
uninstall: 'Uninstall',
|
||||
uninstallLabel: 'Uninstall {name}',
|
||||
installTitle: 'Add plugin',
|
||||
@@ -414,7 +411,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.',
|
||||
|
||||
@@ -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<PackageRow, 'rowId' | 'preset'>): 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<PackageRow, 'rowId' | 'preset'>): 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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLElement>('[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.' } } }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: b55aeb927737b90d
|
||||
zh: 0b215fcb5c2c7908
|
||||
/packages-experimental/packages:
|
||||
en: 8d4c5d32901b2ce6
|
||||
zh: 0240d4c81489edaa
|
||||
en: 38112ac6abac1ada
|
||||
zh: 862fe02205e2f4ae
|
||||
/packages-experimental/related-documentation:
|
||||
en: 1cb9d3448d44e48d
|
||||
zh: cd326d70a746b896
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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) | 钩子桥接的通信类型与持久事件 | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<details>
|
||||
<summary>Implementation details — click to expand</summary>
|
||||
|
||||
[`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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
[`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) 负责补丁顺序和错误处理。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -57,7 +57,7 @@ kind: "package-bundle"
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
<details>
|
||||
<summary>Implementation details — click to expand</summary>
|
||||
|
||||
[`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`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
[`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`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -61,13 +61,13 @@ kind: "package-bundle"
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 每个当前或保留的预设版本在首次搜索后持有自己的内存索引。索引会在应用重启后重建。搜索仍仅限获授权的工作区。
|
||||
- 组合包在首次搜索后持有一个内存索引。应用重启或重新开关组合包后索引会重建。搜索仍仅限获授权的工作区。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
<details>
|
||||
<summary>Implementation details — click to expand</summary>
|
||||
|
||||
[`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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
[`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) 负责补丁顺序和错误处理。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -57,13 +57,13 @@ kind: "package-bundle"
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。
|
||||
此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。切换组合包会改变运行中会话的工具列表,使其已缓存的请求前缀失效一次。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 终端属于创建它们的智能体。POSIX 使用 Bash,Windows 使用 PowerShell;此 bundle 不共享 minimal 预设的终端注册表。
|
||||
- 终端属于创建它们的智能体。POSIX 使用 Bash,Windows 使用 PowerShell;此 bundle 不与其他终端注册表共享。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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',
|
||||
|
||||
@@ -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<AgentPreset[]>',
|
||||
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<AgentPresetRoster>',
|
||||
@@ -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<BundleInfo[]>',
|
||||
signature: '@Remote listBundles(): Promise<BundleInfo[]>',
|
||||
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: [],
|
||||
},
|
||||
@@ -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<AgentPresetPluginRow, \'enabled\' | \'fiberPhase\'>;\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',
|
||||
@@ -6260,10 +6256,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}',
|
||||
@@ -6288,10 +6280,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<Record<string, string>>;\n}',
|
||||
@@ -6418,7 +6406,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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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) 中。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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<AgentPreset[]> {
|
||||
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<AgentPresetRoster> {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user