From 438c862feae59cebcc3b4c2ee43e2cbd101dd42e Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 14 Sep 2026 23:08:45 +0800 Subject: [PATCH] refactor: keep profile launch context data-only and HMR backend-scoped --- ...urrent-profile-plugin-management.i18n.yaml | 4 +- ...09-14-current-profile-plugin-management.md | 6 +- ...14-current-profile-plugin-management.zh.md | 6 +- apps/cli/src/profile-boot.ts | 69 +++------------- apps/cli/tests/telemetry-switch.spec.ts | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 8 +- docs/capability-seams.zh.md | 8 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 30 +------ docs/subsystems/boot.zh.md | 30 +------ docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 2 +- docs/subsystems/client-modules.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 1 + packages/boot/app-boot/README.zh.md | 1 + packages/boot/app-boot/src/index.ts | 2 +- packages/boot/app-boot/src/profile-context.ts | 66 +++++++++++++++ packages/boot/app-boot/src/profile-runtime.ts | 38 --------- packages/boot/app-boot/tests/profile.spec.ts | 24 ++++++ packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 10 ++- packages/boot/plugin-manager/README.zh.md | 10 ++- packages/boot/plugin-manager/package.json | 10 ++- packages/boot/plugin-manager/src/index.ts | 75 +++++++++-------- .../boot/plugin-manager/tests/manager.spec.ts | 58 ++++++++------ packages/boot/plugin-manager/tsconfig.json | 6 ++ packages/bundle/base/cordis.patch.yml | 4 +- packages/bundle/web-app/cordis.patch.yml | 5 +- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 10 +-- packages/client/hmr/README.zh.md | 10 +-- packages/client/hmr/package.json | 7 +- packages/client/hmr/src/client/index.ts | 51 ++---------- packages/client/hmr/src/index.ts | 5 -- .../client/hmr/tests/graph.client.spec.ts | 64 --------------- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 - packages/client/modules/README.zh.md | 2 - .../client/modules/src/client/manifest.ts | 5 -- packages/client/modules/src/client/system.ts | 13 +-- .../extensions/tool-cordis/src/api-catalog.ts | 39 ++------- .../presets/cordis/agent.cordis.yml | 2 +- .../presets/ptc/agent.cordis.yml | 2 +- .../presets/standard/agent.cordis.yml | 2 +- pnpm-lock.yaml | 6 ++ scripts/gen-cordis-catalog.ts | 2 +- scripts/gen-doc-graphs.ts | 6 +- .../tool-schemas.expected.json | 80 ------------------- .../tool-schemas.expected.json | 40 ---------- .../both-mode-turn/system-prompt.expected.md | 14 ---- .../both-mode-turn/tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 80 ------------------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../system-prompt.expected.md | 14 ---- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../lsp-definition/tool-schemas.expected.json | 40 ---------- .../system-prompt.expected.md | 14 ---- .../mcp-resources/tool-schemas.expected.json | 40 ---------- .../plugin-manager/cordis.snapshot.yml | 3 + snapshots/session/plugin-manager/cordis.yml | 3 +- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../provider-cwd/tool-schemas.expected.json | 40 ---------- .../system-prompt.expected.md | 14 ---- .../system-prompt.expected.md | 14 ---- .../ptc-python-turn/system-prompt.expected.md | 15 ---- .../ptc-read-image/system-prompt.expected.md | 14 ---- .../ptc-turn/system-prompt.expected.md | 14 ---- .../tool-schemas.expected.json | 40 ---------- .../ralph-loop/tool-schemas.1.expected.json | 40 ---------- .../ralph-loop/tool-schemas.2.expected.json | 40 ---------- .../ralph-loop/tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../tool-schemas.1.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- .../text-turn/tool-schemas.expected.json | 40 ---------- .../web-fetch/tool-schemas.expected.json | 40 ---------- .../tool-schemas.expected.json | 40 ---------- 94 files changed, 300 insertions(+), 1922 deletions(-) create mode 100644 packages/boot/app-boot/src/profile-context.ts delete mode 100644 packages/boot/app-boot/src/profile-runtime.ts delete mode 100644 packages/client/hmr/tests/graph.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml index 3a45286f46..f9d8e4df42 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md -2026-09-14-current-profile-plugin-management.md: 11f1dd1a269a1213dde27ce529276e1369564c21 -2026-09-14-current-profile-plugin-management.zh.md: 524f165215a0bd6a38f60062ad84c8fe22bcb993 +2026-09-14-current-profile-plugin-management.md: 316fb57a7826ae937f8f6225a2b2425563476bb1 +2026-09-14-current-profile-plugin-management.zh.md: c2b6a0c5420f8b028ff6e3db6839b09e4672a65a diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md index 11f1dd1a26..316fb57a78 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md @@ -10,13 +10,11 @@ Web and agent controls need to change a running profile without creating an inde ## Decision -[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies the current profile and resolution locations. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. The launcher registers the `hmr/before-reload` file-lock wrapper before plugin startup. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence. +[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles, reload policy and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. The launcher registers the `hmr/before-reload` file-lock wrapper before plugin startup. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence. Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path. -This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and agent tools call the same service, whose batched durable notices inform live Agents without waking them. - -[Client HMR](../../../../packages/client/hmr/README.md) applies Host module-graph changes to the connected browser: removed entries drain their effect disposers before cached factories and styles are removed; added entries load through the existing Client module system. Plugin-owned aggregate interfaces follow their declared client entry, so disabling an internal Host child does not imply removing part of an aggregate client. +This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md index 524f165215..c2b6a0c542 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md @@ -10,13 +10,11 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安 ## 决策 -[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 提供当前 profile 和解析位置。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。launcher 在插件启动前注册 `hmr/before-reload` 文件锁包装。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。 +[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包、重载策略和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。launcher 在插件启动前注册 `hmr/before-reload` 文件锁包装。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。 profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。 -这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。 - -[Client HMR](../../../../packages/client/hmr/README.zh.md) 将 Host 模块图变化应用到已连接的浏览器:移除条目时,先等待其 effect disposer 完成,再清除缓存工厂和样式;新增条目通过现有 Client 模块系统加载。插件提供的聚合界面跟随其声明的客户端条目,因此停用内部 Host 子插件不意味着移除聚合客户端的一部分。 +这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。 ## 考虑过的替代方案 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 290aaed239..647f860773 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -20,19 +20,17 @@ import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { boot, - composeEntries, + readProfilePatches, healProfilesModuleFallback, initProfile, installFailLoud, - loadOptionalPatches, loadOverlayPatches, loadProfile, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, resolveProfileDir, - loadProfileDirectory, reconcileProfilePatches, - type ProfileRuntime, + type ProfileContext, type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' @@ -80,9 +78,6 @@ export function homePatchPath(): string { /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url)) -/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ -const TELEMETRY_ROW_ID = 'session-telemetry-otel' - /** The empty root entry list every profile tree patches over. */ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches: # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any @@ -155,23 +150,6 @@ export function initializeProfileFromDefault( throw error } } - -/** - * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty - * value (including `'0'`/`'false'`) disables: a privacy switch prefers - * off-by-mistake over on-by-mistake. A composition without the telemetry row - * exports nothing, so the switch is then trivially satisfied and no patch is - * generated — custom profiles need not mount telemetry to run with the - * switch set. - * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). - * @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): PatchOptions | undefined { - if ((disabledEnv ?? '') === '' || !hasRow) return undefined - return { id: TELEMETRY_ROW_ID, disabled: true } -} - /** * Load a resolved profile for `name` and (re)write the empty root config. The * root is always rewritten: the whole composition is patch layers, and the @@ -304,42 +282,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } })) let lastInputs = readInputs() - const composeLive = (): PatchOptions[] => { - const profile = loadProfileDirectory(NAME, composed.profile.dir, INSTALL_ANCHOR) - const patches = structuredClone([ - ...profile.layers.flatMap(layer => layer.patches), - ...profile.patches, - ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlays, - ]) - const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, - composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) patches.push(telemetryPatch) - return patches - } - const runtime: ProfileRuntime = { - name: options.profile, dir: composed.profile.dir, installAnchor: INSTALL_ANCHOR, + const profileContext: ProfileContext = { + name: options.profile, + dir: composed.profile.dir, patchPath: composed.profile.patchPath, installAnchor: INSTALL_ANCHOR, startedBundles: composed.profile.layers.map(layer => layer.packageName), cwd: process.cwd(), home: resolveDshHome(), patchReload: composed.profile.patchReload, - read: () => loadProfileDirectory(NAME, composed.profile.dir, INSTALL_ANCHOR), - entries: () => composeEntries([composeLive()]), - mutate: (operation, waitMs) => { - const locked = () => withFileLock(manifestPath, operation, waitMs === undefined ? undefined : { waitMs }) - const hmr = app.current?.get('hmr') - return hmr === undefined ? locked() : hmr.runExclusive(locked) - }, - async reload() { - if (composed.profile.patchReload === 'startup') return - if (app.current === undefined) throw new Error('dsh: profile is not running') - lastInputs = readInputs() - await reconcileProfilePatches(app.current, composeLive(), NAME) - }, + overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED, } - // Cloned for the same insert-aliasing reason as composeLive: the boot - // application must not mutate the objects later reloads recompose from. - const ctx = await boot(NAME, rootConfig, composeLive(), (hostCtx) => { + const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext), (hostCtx) => { app.current = hostCtx - hostCtx.provide('profileRuntime', runtime) + hostCtx.provide('profileContext', profileContext) hostCtx.on('hmr/before-reload', next => withFileLock(manifestPath, next)) // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable launch snapshot. @@ -378,7 +330,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } const refresh = async (): Promise => { if (readInputs() === lastInputs) return - await runtime.reload() + lastInputs = readInputs() + await reconcileProfilePatches(ctx, readProfilePatches(NAME, profileContext), NAME) } const watching = ctx.inject(['hmr'], async (owner) => { for (const filename of watchedFiles) { @@ -387,7 +340,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con }) await watching.await() // Include writes that finished while the file watchers were registering. - await runtime.mutate(refresh) + await ctx.hmr.runExclusive(() => withFileLock(manifestPath, refresh)) } catch (error) { suppressShutdownError(ctx, signalShutdown.signal, error) } diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts index 66362d2a3e..0397ffaf35 100644 --- a/apps/cli/tests/telemetry-switch.spec.ts +++ b/apps/cli/tests/telemetry-switch.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveTelemetryPatch } from '../src/profile-boot.ts' +import { resolveTelemetryPatch } from '@deepseek-ai/dsh-app-boot' describe('resolveTelemetryPatch', () => { it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => { diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 300b2b5362..5273440f8a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: aa6e1e0fe7cb14441d27fc325647953c86d6936c -capability-seams.zh.md: be961e9233ff3f22f98144174bb53efd93773a84 +capability-seams.md: 5db5d11195355330b3c0bf3f58c928bd3533cf85 +capability-seams.zh.md: bfcd60916f615f173bc167623462841e25eca2a2 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index aa6e1e0fe7..5db5d11195 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -13,7 +13,7 @@ flowchart LR pkg_plugin_manager["plugin-manager"] svc_pluginManager["ctx.pluginManager
Current-profile plugin and bundle management"] pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"] - svc_profileRuntime["ctx.profileRuntime
Launcher-owned profile reload coordination"] + svc_profileContext["ctx.profileContext
Launcher-owned profile data"] pkg_mcp_resources["mcp-resources"] svc_mcpResources["ctx.mcpResources
Scoped MCP resource access"] pkg_mcp_client["mcp-client"] @@ -260,7 +260,7 @@ flowchart LR pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles - pkg_app_boot --> svc_profileRuntime + pkg_app_boot --> svc_profileContext pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -428,7 +428,7 @@ flowchart LR svc_mcpResources --> pkg_mcp_resources svc_pluginManager --> pkg_plugin_manager svc_pluginManager --> pkg_ui_settings_plugin_inventory - svc_profileRuntime --> pkg_plugin_manager + svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc svc_sandbox --> pkg_bash_sandbox @@ -524,7 +524,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock. | | `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. | -| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies profile locations and composition; reload scheduling belongs to dsh-hmr. | +| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index be961e9233..bfcd60916f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -15,7 +15,7 @@ flowchart LR pkg_plugin_manager["plugin-manager"] svc_pluginManager["ctx.pluginManager
Current-profile plugin and bundle management"] pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"] - svc_profileRuntime["ctx.profileRuntime
Launcher-owned profile reload coordination"] + svc_profileContext["ctx.profileContext
Launcher-owned profile data"] pkg_mcp_resources["mcp-resources"] svc_mcpResources["ctx.mcpResources
Scoped MCP resource access"] pkg_mcp_client["mcp-client"] @@ -262,7 +262,7 @@ flowchart LR pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles - pkg_app_boot --> svc_profileRuntime + pkg_app_boot --> svc_profileContext pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -430,7 +430,7 @@ flowchart LR svc_mcpResources --> pkg_mcp_resources svc_pluginManager --> pkg_plugin_manager svc_pluginManager --> pkg_ui_settings_plugin_inventory - svc_profileRuntime --> pkg_plugin_manager + svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc svc_sandbox --> pkg_bash_sandbox @@ -526,7 +526,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | 负责模块和精确配置监听;应用修改共用其队列,自动重载等待应用文件锁。 | | `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 | -| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供 profile 位置和组合逻辑;重载调度由 dsh-hmr 负责。 | +| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 83fcb99465..2be4a7a27e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b71b3754280d8e0afd4352cec906b1826e8cdad5 -config-catalog.zh.md: 51afc49c5f03d30627767facf3987f612528da59 +config-catalog.md: ea8fc7a3805454a0e28755228a38d298ad54ee8f +config-catalog.zh.md: 022914e440521598238ef8c9da441b27dc5b7733 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b71b375428..ea8fc7a380 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1801,7 +1801,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/s ## `@deepseek-ai/dsh-plugin-manager` -Requires: `loader` · `profileRuntime` +Requires: `loader` · `profileContext` ```ts config-catalog /** Limits for package diagnostics and change notifications. */ @@ -1815,7 +1815,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:19`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:21`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 51afc49c5f..022914e440 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1803,7 +1803,7 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` -依赖: `loader` · `profileRuntime` +依赖: `loader` · `profileContext` ```ts config-catalog /** Limits for package diagnostics and change notifications. */ diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index b7d6b8d326..c8d16f56bd 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 09d0dc5a70ea0fcb1e269e4621768cf489ba9a20 -boot.zh.md: 1bc74a5b46afc8d4e10924822a9cc65ae0fa8d57 +boot.md: 63240fdb2001095a475ed13e270340527a485d08 +boot.zh.md: 509d41cc4de46d64b9ebab4a23a8f9a09cd5551d diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 09d0dc5a70..63240fdb20 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -100,35 +100,13 @@ Manage profile files and apply their declared reload lifecycle. Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts) - + -### `ctx.profileRuntime` — `ProfileRuntime` +### `ctx.profileContext` — `ProfileContext` -Current-process profile operations; callbacks run under the shared profile write lock. +Current profile facts; scheduling and mutation belong to their callers. -```ts cordis-catalog -/** Read the current manifest and bundle patch layers without initializing a profile. - * @returns Resolved disk configuration. - */ -read(): Profile - -/** Compose disk configuration with the invocation's higher-priority layers. - * @returns Effective entry options in composition order. - */ -entries(): EntryOptions[] - -/** Serialize a mutation with file watching and other profile writers. - * @param operation Work performed while holding the profile manifest lock. - * @param waitMs Maximum lock acquisition time; omission uses the file writer default. - * @returns The operation's result. - */ -mutate(operation: () => Promise, waitMs?: number): Promise - -/** Apply the current disk configuration; call only inside mutate. */ -reload(): Promise -``` - -Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) +Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/app-boot/src/profile-context.ts) diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1bc74a5b46..509d41cc4d 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -100,35 +100,13 @@ Manage profile files and apply their declared reload lifecycle. Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts) - + -### `ctx.profileRuntime` — `ProfileRuntime` +### `ctx.profileContext` — `ProfileContext` -Current-process profile operations; callbacks run under the shared profile write lock. +Current profile facts; scheduling and mutation belong to their callers. -```ts cordis-catalog -/** Read the current manifest and bundle patch layers without initializing a profile. - * @returns Resolved disk configuration. - */ -read(): Profile - -/** Compose disk configuration with the invocation's higher-priority layers. - * @returns Effective entry options in composition order. - */ -entries(): EntryOptions[] - -/** Serialize a mutation with file watching and other profile writers. - * @param operation Work performed while holding the profile manifest lock. - * @param waitMs Maximum lock acquisition time; omission uses the file writer default. - * @returns The operation's result. - */ -mutate(operation: () => Promise, waitMs?: number): Promise - -/** Apply the current disk configuration; call only inside mutate. */ -reload(): Promise -``` - -Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) +Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/app-boot/src/profile-context.ts) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index f3ee6dfbae..33e35b5377 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md -client-modules.md: eec15c30a88b0d3e73225bcb4a20b31b781269d1 -client-modules.zh.md: 0befa113f0e394b9d135840b96d62307751695ee +client-modules.md: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0 +client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index eec15c30a8..12925fa8dd 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -100,7 +100,7 @@ interface ClientArtifactBaseline { `ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush. -The Web profile mounts [dsh-client-hmr](../../packages/client/hmr/README.md) to broadcast graph membership and rebuilt revisions through SSE. The browser adds and removes Loader entries from validated graphs; bundle stat polling supplies development rebuild notifications. The module host itself does not watch files. +In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files. diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index 0befa113f0..513391af83 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -100,7 +100,7 @@ interface ClientArtifactBaseline { `ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。 -Web profile 加载 [dsh-client-hmr](../../packages/client/hmr/README.zh.md),通过 SSE 广播模块图成员变化和重建 revision。浏览器根据校验后的模块图增删 Loader 条目;bundle stat 轮询提供开发构建通知。模块宿主自身不监听文件。 +开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 76b06fc675..7d6a2fce6d 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 9162384bdacb11250a286f12676e044e36be73a3 -README.zh.md: 832b44749fa98fe328e49bae2edfb8f7f4e04574 +README.md: 0d776c5deb4a7158e795cc3341b79935ec0f06d6 +README.zh.md: 042d4f68e52e865961804e6262cf4f31ce7a09b9 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9162384bda..0d776c5deb 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -103,6 +103,7 @@ This section explains how the outcomes above are realized and points at the code ### Design notes +- **Profile launch data.** `ctx.profileContext` contains only profile locations, reload policy, startup bundle names, parsed invocation overlays and the telemetry opt-out value. `readProfilePatches()` reads and composes current files from those inputs; callers schedule and apply the result. - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution. - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 832b44749f..042d4f68e5 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -103,6 +103,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 ### 设计说明 +- **Profile 启动数据。** `ctx.profileContext` 只包含 profile 位置、重载策略、启动时组合包名称、已解析的调用级 overlay 与遥测退出值。`readProfilePatches()` 根据这些输入读取并组合当前文件;调用方负责调度和应用结果。 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 - **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 39e7bd913c..e56bd700f9 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -18,7 +18,7 @@ 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' import type {} from '@deepseek-ai/dsh-hmr' -export type { ProfileRuntime } from './profile-runtime.ts' +export { readProfilePatches, resolveTelemetryPatch, type ProfileContext } from './profile-context.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts new file mode 100644 index 0000000000..f07a62ec3b --- /dev/null +++ b/packages/boot/app-boot/src/profile-context.ts @@ -0,0 +1,66 @@ +/** Launcher-owned profile locations and composition inputs. */ +import { join } from 'node:path' +import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME } from './profile.ts' +import { loadOptionalPatches } from './index.ts' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' + +/** Current profile facts; scheduling and mutation belong to their callers. */ +export interface ProfileContext { + readonly name: string + readonly dir: string + readonly patchPath: string + readonly installAnchor: string + readonly cwd: string + readonly home: string + /** Bundle packages used to start this process, before any persisted edits. */ + readonly startedBundles: readonly string[] + readonly patchReload: 'live' | 'startup' + /** Parsed command-line overlays, applied above profile and home patches. */ + readonly overlays: readonly PatchOptions[] + /** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */ + readonly telemetryDisabledEnv: string | undefined +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Present only in a profile launched by dsh. */ + profileContext: ProfileContext + } +} + +const TELEMETRY_ROW_ID = 'session-telemetry-otel' + +/** + * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty + * value (including `'0'`/`'false'`) disables: a privacy switch prefers + * off-by-mistake over on-by-mistake. A composition without the telemetry row + * exports nothing, so the switch is then trivially satisfied and no patch is + * generated — custom profiles need not mount telemetry to run with the + * switch set. + * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). + * @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): PatchOptions | undefined { + if ((disabledEnv ?? '') === '' || !hasRow) return undefined + return { id: TELEMETRY_ROW_ID, disabled: true } +} + +/** Read current bundle and user layers with the launch-time overlays. + * @param binName Diagnostic prefix for malformed or missing configuration. + * @param context Data supplied by the profile launcher. + * @returns Detached ordered patches; this function does not update the Loader. + */ +export function readProfilePatches(binName: string, context: ProfileContext): PatchOptions[] { + const profile = loadProfileDirectory(binName, context.dir, context.installAnchor) + const patches = structuredClone([ + ...profile.layers.flatMap(layer => layer.patches), + ...profile.patches, + ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), + ...context.overlays, + ]) + const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv, + composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID)) + if (telemetryPatch !== undefined) patches.push(telemetryPatch) + return patches +} diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts deleted file mode 100644 index 6617f3b6e1..0000000000 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Launcher-provided access to the current profile's serialized configuration lifecycle. */ -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import type { Profile } from './profile.ts' - -/** Current-process profile operations; callbacks run under the shared profile write lock. */ -export interface ProfileRuntime { - readonly name: string - readonly dir: string - readonly installAnchor: string - readonly cwd: string - readonly home: string - /** Bundle packages used to start this process, before any persisted edits. */ - readonly startedBundles: readonly string[] - readonly patchReload: 'live' | 'startup' - /** Read the current manifest and bundle patch layers without initializing a profile. - * @returns Resolved disk configuration. - */ - read(): Profile - /** Compose disk configuration with the invocation's higher-priority layers. - * @returns Effective entry options in composition order. - */ - entries(): EntryOptions[] - /** Serialize a mutation with file watching and other profile writers. - * @param operation Work performed while holding the profile manifest lock. - * @param waitMs Maximum lock acquisition time; omission uses the file writer default. - * @returns The operation's result. - */ - mutate(operation: () => Promise, waitMs?: number): Promise - /** Apply the current disk configuration; call only inside mutate. */ - reload(): Promise -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Present only in a profile launched by dsh. */ - profileRuntime: ProfileRuntime - } -} diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 3fa0cf97b4..d1ec1e8bbf 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -21,6 +21,7 @@ import { PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, + readProfilePatches, resolveBundleDir, resolveProfileDir, writeProfileManifest, @@ -99,6 +100,29 @@ describe('resolveProfileDir', () => { }) }) +it('composes current files from profile data and retains launch overlay and telemetry precedence', () => { + const home = tmp() + const installAnchor = stageInstallation({ base: { patch: '- insert:\n - id: session-telemetry-otel\n name: telemetry\n' } }) + const dir = resolveProfileDir('test', home) + initProfile(dir, ['base']) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + writeFileSync(patchPath, '- id: session-telemetry-otel\n disabled: true\n') + writeFileSync(join(home, PROFILE_PATCH_FILENAME), '- id: session-telemetry-otel\n disabled: false\n') + const context = { + name: 'test', dir, patchPath, installAnchor, home, cwd: home, + startedBundles: ['base'], patchReload: 'live' as const, + overlays: [{ id: 'session-telemetry-otel', disabled: false }], telemetryDisabledEnv: 'false', + } + expect(composeEntries([readProfilePatches('test', context)])[0]?.disabled).toBe(true) + const enabled = { ...context, telemetryDisabledEnv: undefined } + expect(composeEntries([readProfilePatches('test', enabled)])[0]?.disabled).toBe(false) + const patches = readProfilePatches('test', enabled) + patches.at(-1)!.disabled = true + expect(context.overlays[0]?.disabled).toBe(false) + writeFileSync(join(home, PROFILE_PATCH_FILENAME), '- id: session-telemetry-otel\n disabled: true\n') + expect(composeEntries([readProfilePatches('test', { ...enabled, overlays: [] })])[0]?.disabled).toBe(true) +}) + describe('initProfile', () => { it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => { const home = tmp() diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 083a03fe76..86e7b7365b 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: e2808920b211c020c62efa1265e96127bd5f5047 -README.zh.md: d330f3cdbbf7dff55e25d363c017224af663d017 +README.md: 36f470902d011b21b146600218a2a3258b025873 +README.zh.md: 0d63084eee76cd2d15e60e382e738a80504022bb diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index e2808920b2..36f470902d 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -25,7 +25,14 @@ Manage the current profile's plugins without editing configuration by hand. Enab ## Use this package -Base-backed profiles provide the manager. In Web Settings, open Plugins and select Plugin list to manage bundles and uniquely addressable global plugin entries. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations. +Base-backed profiles provide the manager. In Web Settings, open Plugins and select Plugin list to manage bundles and uniquely addressable global plugin entries. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default. + +Enable the tool explicitly in the profile patch; agents using a preset also need its `tool-plugin-manager` entry enabled. + +```yaml +- id: tool-plugin-manager + disabled: false +``` A plugin toggle writes only its `disabled` override in the profile's `cordis.patch.yml`. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. @@ -99,6 +106,7 @@ Notices append context; they do not rewrite earlier messages. - 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 edit an agent preset's composition. - Package failures may leave dependencies partially changed. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. +- Browser bundle changes require a page refresh to load the current Client module graph. Management results describe Host activation. - Desktop package operations remain owned by the Desktop shell. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index d330f3cdbb..0d63084eee 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -25,7 +25,14 @@ kind: "package-reference" ## 使用本包 -基于 base 的 profile 提供管理服务。在 Web 设置中打开插件并选择插件列表,即可管理组合包和能唯一定位的全局插件条目。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作。 +基于 base 的 profile 提供管理服务。在 Web 设置中打开插件并选择插件列表,即可管理组合包和能唯一定位的全局插件条目。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。 + +在 profile patch 中显式启用工具;使用预设的 Agent 还需要启用该预设中的 `tool-plugin-manager` 条目。 + +```yaml +- id: tool-plugin-manager + disabled: false +``` 插件开关只写入 profile 的 `cordis.patch.yml` 中的 `disabled` 覆盖项。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 @@ -99,6 +106,7 @@ kind: "package-reference" - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 - 包管理失败可能留下部分依赖改动。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 +- 浏览器组合包变化需要刷新页面后才能加载当前 Client 模块图。管理结果描述 Host 激活状态。 - Desktop 包管理操作仍由 Desktop shell 负责。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 5762e5cdb0..4c3f111672 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -68,11 +68,15 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-hmr": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-agent": { "optional": true + }, + "@deepseek-ai/dsh-hmr": { + "optional": true } }, "devDependencies": { @@ -85,6 +89,8 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^" } } diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 03b8157371..16a79ac9d8 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -1,13 +1,15 @@ /** Current-profile plugin and bundle management over shared dsh plugin operations. */ import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' -import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries } from '@deepseek-ai/dsh-app-boot' -import type { ProfileRuntime } from '@deepseek-ai/dsh-app-boot' +import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-hmr' +import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts' @@ -32,9 +34,7 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', - '@deepseek-ai/dsh-plugin-manager/tools', '@deepseek-ai/dsh-hmr', - '@deepseek-ai/dsh-client-hmr', ]) /** Flatten only the groups addressable by the profile's patch composer. */ @@ -54,7 +54,7 @@ declare module '@deepseek-ai/cordis' { /** Manage profile files and apply their declared reload lifecycle. */ export class PluginManager extends TypertRemoteService { - static inject = ['loader', 'profileRuntime'] + static inject = ['loader', 'profileContext'] static Config: z = z.object({ outputBytes: z.number().step(1).min(1).default(16384), notificationDelayMs: z.number().step(1).min(0).default(250), @@ -62,7 +62,7 @@ export class PluginManager extends TypertRemoteService { }) private readonly ownerEntryId: string | undefined private readonly packageOperations = new Set>() - private readonly runtime: ProfileRuntime + private readonly profile: ProfileContext private readonly outputBytes: number private readonly notificationDelayMs: number private readonly lockWaitMs: number @@ -78,7 +78,7 @@ export class PluginManager extends TypertRemoteService { super(ctx, 'pluginManager') this.ownerEntryId = ctx.fiber.entry?.id this.ownerContext = ctx - this.runtime = ctx.profileRuntime + this.profile = ctx.profileContext this.outputBytes = (config as Required).outputBytes this.notificationDelayMs = (config as Required).notificationDelayMs this.lockWaitMs = (config as Required).lockWaitMs @@ -95,7 +95,7 @@ export class PluginManager extends TypertRemoteService { */ @Remote async listPlugins(): Promise { - const rows = flatten(this.runtime.entries()) + const rows = flatten(composeEntries([readProfilePatches('dsh', this.profile)])) const snapshot = await readPluginInventory(this.ctx) return snapshot.entries.map((entry) => { const actual = [...this.ctx.loader.entries()].find(row => row.id === entry.entryId) @@ -118,16 +118,16 @@ export class PluginManager extends TypertRemoteService { */ @Remote listBundles(): Promise { - const manifest = readProfileManifest('dsh', this.runtime.dir) + const manifest = readProfileManifest('dsh', this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] const dependencies = Object.keys(manifest.dependencies ?? {}) - const installation = JSON.parse(readFileSync(this.runtime.installAnchor, 'utf8')) as { dependencies?: Record } + const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record } const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])] const bundles: BundleInfo[] = [] for (const name of names) { const removable = dependencies.includes(name) && !Object.hasOwn(installation.dependencies ?? {}, name) try { - const info = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor) + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { if (selected.includes(name)) bundles.push({ name, enabled: true, removable, error: `Not a bundle: ${name}` }) continue @@ -156,10 +156,10 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new Error(`Unknown plugin entry: ${id}`) if (row.readOnlyReason !== undefined || row.patchId === undefined) throw new Error(row.readOnlyReason) - await writePluginEnabled(this.runtime.read().patchPath, row.patchId, enabled) - await this.runtime.reload() + await writePluginEnabled(this.profile.patchPath, row.patchId, enabled) + await this.reload() const current = (await this.listPlugins()).find(item => item.entryId === id) - return current?.enabled !== enabled && this.runtime.patchReload === 'live' ? 'overridden' : undefined + return current?.enabled !== enabled && this.profile.patchReload === 'live' ? 'overridden' : undefined }, `Plugin ${id}: ${enabled ? 'enabled' : 'disabled'}.`) } @@ -172,7 +172,7 @@ export class PluginManager extends TypertRemoteService { setBundleEnabled(name: string, enabled: boolean): Promise { return this.change(async () => { await this.selectBundle(name, enabled) - await this.runtime.reload() + await this.reload() }, `Bundle ${name}: ${enabled ? 'enabled' : 'disabled'}.`) } @@ -186,22 +186,22 @@ export class PluginManager extends TypertRemoteService { let packageResult: PackageResult | undefined return this.change(async () => { if (spec.trim() === '' || spec.startsWith('-')) throw new Error('A package spec is required') - const before = readProfileManifest('dsh', this.runtime.dir).dependencies ?? {} + const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} packageResult = await this.runPnpm(['add', spec]) if (packageResult.exitCode !== 0) throw new Error(packageResult.output) - const after = readProfileManifest('dsh', this.runtime.dir).dependencies ?? {} + const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} const installed = Object.keys(after).filter(name => before[name] !== after[name]) // Registry retries can retain the saved range after a partial installation. if (installed.length === 0) installed.push(...Object.keys(after).filter(name => spec === name || spec.startsWith(`${name}@`))) const name = installed[0] if (installed.length !== 1 || name === undefined) throw new Error('Cannot identify one installed bundle from the dependency change') - const dir = resolveBundleDir('dsh', name, this.runtime.installAnchor, this.runtime.dir) - const manifest = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor) + const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) + const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (manifest?.dsh?.bundle?.patch === undefined) throw new Error(`${name} declares no dsh.bundle.patch`) loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch)) if (options?.enabled !== false) await this.selectBundle(name, true) if (Object.hasOwn(before, name)) return 'restart-required' - await this.runtime.reload() + await this.reload() }, `Bundle installation: ${spec}.`, () => packageResult) } @@ -215,19 +215,19 @@ export class PluginManager extends TypertRemoteService { return this.change(async () => { const bundle = (await this.listBundles()).find(item => item.name === name) if (bundle === undefined || !bundle.removable) throw new Error(`Bundle is not a profile-owned dependency: ${name}`) - if (this.runtime.patchReload === 'startup' && this.runtime.startedBundles.includes(name)) { + if (this.profile.patchReload === 'startup' && this.profile.startedBundles.includes(name)) { throw new Error('Stop this startup-only profile and remove the bundle with dsh plugin') } await this.selectBundle(name, false) - await this.runtime.reload() + await this.reload() packageResult = await this.runPnpm(['remove', name]) if (packageResult.exitCode !== 0) throw new Error(packageResult.output) - await this.runtime.reload() + await this.reload() }, `Bundle removed: ${name}.`, () => packageResult) } private async runPnpm(args: readonly string[]): Promise { - const task = runProfilePnpm({ ...this.runtime, profile: this.runtime.name }, args, { + const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { signal: this.abort.signal, outputBytes: this.outputBytes, activateNewBundles: false, }) this.packageOperations.add(task) @@ -236,9 +236,9 @@ export class PluginManager extends TypertRemoteService { } private async selectBundle(name: string, enabled: boolean): Promise { - const manifest = readProfileManifest('dsh', this.runtime.dir) + const manifest = readProfileManifest('dsh', this.profile.dir) const previous = manifest.dsh?.profile?.bundles ?? [] - if ((enabled || !previous.includes(name)) && bundleManifest(name, this.runtime.dir, this.runtime.installAnchor) === undefined) { + if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) { throw new Error(`Not a bundle: ${name}`) } if (!enabled && previous.includes(name)) { @@ -247,25 +247,30 @@ export class PluginManager extends TypertRemoteService { const bundles = enabled ? [...previous, ...previous.includes(name) ? [] : [name]] : previous.filter(item => item !== name) if (JSON.stringify(previous) === JSON.stringify(bundles)) return manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } - await saveManifest(this.runtime.dir, manifest) + await saveManifest(this.profile.dir, manifest) } private protectsManager(name: string): boolean { - const info = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor) + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info?.dsh?.bundle === undefined) return false - const dir = resolveBundleDir('dsh', name, this.runtime.installAnchor, this.runtime.dir) + const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) const rows = flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))])) return rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId) } + private async reload(): Promise { + if (this.profile.patchReload === 'startup') return + await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh') + } + private async change( operation: () => Promise, message: string, packageResult?: () => PackageResult | undefined, ): Promise { let notice: Promise | undefined - const result = await this.runtime.mutate(async () => { + const locked = () => withFileLock(join(this.profile.dir, 'package.json'), async () => { this.abort.signal.throwIfAborted() const before = this.diskState() - let application: ChangeResult['application'] = this.runtime.patchReload === 'live' ? 'applied' : 'restart-required' + let application: ChangeResult['application'] = this.profile.patchReload === 'live' ? 'applied' : 'restart-required' try { application = await operation() ?? application } catch (error) { @@ -277,14 +282,16 @@ export class PluginManager extends TypertRemoteService { if (packages !== undefined) result.packageResult = packages notice = this.notify(result) return result - }, this.lockWaitMs) + }, { waitMs: this.lockWaitMs }) + const hmr = this.ownerContext.get('hmr') + const result = await (hmr === undefined ? locked() : hmr.runExclusive(locked)) await notice return result } private diskState(): string { return ['package.json', 'cordis.patch.yml'].map((file) => { - try { return readFileSync(join(this.runtime.dir, file), 'utf8') } + try { return readFileSync(join(this.profile.dir, file), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' throw error @@ -298,7 +305,7 @@ export class PluginManager extends TypertRemoteService { for (const agent of agents) this.noticeAgents.add(agent) this.noticeDelivered ??= Promise.withResolvers() const delivered = this.noticeDelivered.promise - const notice = `Profile ${this.runtime.name}: ${result.message} (${result.application})` + const notice = `Profile ${this.profile.name}: ${result.message} (${result.application})` if (Buffer.byteLength(this.pendingNotice + notice) > this.outputBytes) { this.omittedNotices += 1 } else { diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 70bb5c1385..81db29b94e 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -7,12 +7,13 @@ import type AgentRegistry from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-llm' import { expect, it, onTestFinished, vi } from 'vitest' -import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { - boot, composeEntries, initProfile, loadProfileDirectory, readProfileManifest, reconcileProfilePatches, - type ProfileRuntime, + boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, + type ProfileContext, } from '@deepseek-ai/dsh-app-boot' import PluginManager, { type Config } from '../src/index.ts' +import Hmr from '@deepseek-ai/dsh-hmr' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { Group } from '@deepseek-ai/cordis-plugin-loader' import * as operations from '../src/operations.ts' @@ -34,24 +35,20 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre const manifest = readProfileManifest('test', dir) manifest.dependencies = { extra: '1.0.0' } writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - const read = () => loadProfileDirectory('test', dir, anchor) - const patches = () => [...read().layers.flatMap(layer => layer.patches), ...read().patches, - ...overlay ? [{ id: 'managed', disabled: true }] : []] writeFileSync(join(dir, 'cordis.yml'), '[]\n') - const runtime: ProfileRuntime = { + const profile: ProfileContext = { + name: 'test', startedBundles: ['core', 'extra'], - name: 'test', dir, installAnchor: anchor, cwd: home, home, patchReload: reload, read, - entries: () => composeEntries([patches()]), - mutate: operation => withFileLock(join(dir, 'package.json'), operation), - reload: async () => { if (reload === 'live') await reconcileProfilePatches(ctx, patches(), 'test') }, + dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home, patchReload: reload, + overlays: overlay ? [{ id: 'managed', disabled: true }] : [], telemetryDisabledEnv: undefined, } - const ctx = await boot('test', join(dir, 'cordis.yml'), patches(), (ctx) => { + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => { prepare?.(ctx) - ctx.provide('profileRuntime', runtime) + ctx.provide('profileContext', profile) ctx.loader.builtins.manager = PluginManager }) onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) - return { ctx, dir, manager: ctx.pluginManager, bundle, runtime } + return { ctx, dir, manager: ctx.pluginManager, bundle, profile } } it('lists bundle versions and current-profile plugin targets', async () => { @@ -201,8 +198,8 @@ it('combines concurrent changes into durable notices without waking Agents', asy it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => { - const { manager, dir, runtime } = await fixture() - writeFileSync(runtime.installAnchor, '{}') + const { manager, dir, profile } = await fixture() + writeFileSync(profile.installAnchor, '{}') writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}') expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: 'Not a bundle: extra' }) expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' }) @@ -213,7 +210,7 @@ it('reports plain dependencies, missing versions and invalid selected bundles di writeFileSync(join(dir, 'package.json'), '{}') expect(await manager.listBundles()).toEqual([]) expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' }) - writeFileSync(runtime.installAnchor, '{"dependencies":{"missing-builtin":"1"}}') + writeFileSync(profile.installAnchor, '{"dependencies":{"missing-builtin":"1"}}') expect(await manager.listBundles()).toEqual([]) }) @@ -224,15 +221,14 @@ it('refuses management bundle disablement and permits repeated bundle selections }) it('addresses children inside profile groups and marks ambiguous ids read-only', async () => { - const { manager, bundle, runtime } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group }) + const { manager, bundle, profile } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group }) bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }]) expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' }) expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined() - const entries = runtime.entries() + const entries = composeEntries([readProfilePatches('test', profile)]) const duplicate = entries.find(row => row.id === 'managed')! - const read = vi.spyOn(runtime, 'entries').mockReturnValue([...entries, duplicate]) - onTestFinished(() => { read.mockRestore() }) + writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }])) expect((await manager.listPlugins()).find(row => row.entryId === 'include:managed')?.readOnlyReason).toContain('not uniquely addressable') }) @@ -270,13 +266,14 @@ it('reports repeated installs as requiring restart and ambiguous package changes expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' }) }) -it('handles missing patch files and retains non-Error Loader diagnostics', async () => { - const { manager, dir, runtime } = await fixture() +it('handles missing patch files and retains non-Error package diagnostics', async () => { + const { manager, dir } = await fixture() rmSync(join(dir, 'cordis.patch.yml')) const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId - const reload = vi.spyOn(runtime, 'reload').mockRejectedValueOnce('loader rejected generation') - onTestFinished(() => { reload.mockRestore() }) - expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'failed', message: 'loader rejected generation' }) + expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' }) + const install = vi.spyOn(operations, 'runProfilePnpm').mockRejectedValueOnce('pnpm rejected operation') + onTestFinished(() => { install.mockRestore() }) + expect(await manager.installBundle('new')).toMatchObject({ changed: false, application: 'failed', message: 'pnpm rejected operation' }) rmSync(join(dir, 'cordis.patch.yml')) mkdirSync(join(dir, 'cordis.patch.yml')) await expect(manager.setPluginEnabled(id, true)).rejects.toThrow() @@ -290,3 +287,12 @@ it('bounds batched notices and discloses omitted operation results', async () => await manager.setBundleEnabled('extra', false) expect(JSON.stringify(messages)).toContain('1 additional operations omitted') }) + +it('applies a manager change through the active HMR service', async () => { + const { ctx, manager } = await fixture() + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId + expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' }) + expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false) +}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index 091f2a97ef..e74b867881 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -43,6 +43,12 @@ }, { "path": "../../util/values" + }, + { + "path": "../hmr" + }, + { + "path": "../../../vendor/timer" } ] } diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 19a8d14312..6111867ca1 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -15,11 +15,11 @@ - insert: - id: tool-plugin-manager name: '@deepseek-ai/dsh-plugin-manager/tools' - disabled: !!js "!ctx.get('profileRuntime')" + disabled: true - id: plugin-manager name: '@deepseek-ai/dsh-plugin-manager' - disabled: !!js "!ctx.get('profileRuntime')" + disabled: !!js "!ctx.get('profileContext')" - id: timer name: '@deepseek-ai/cordis-plugin-timer' diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 98be44c067..edcb06a906 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -159,7 +159,10 @@ surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts - # Synchronize browser plugin membership and development bundle rebuilds. + # The client-plugin reload chain, always mounted: it is idle until a + # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It + # is a row rather than a child of web-runtime because its node half is a + # client-side package, which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index ca44a6cbcb..49e7266545 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/hmr/README.md -README.md: c35100c31e5e8ccb78a9e3d63864c55481b1bf29 -README.zh.md: 96e5c9f19321053cba12da9532a5a1d029b0698b +README.md: 26bc640c6cdd213fbc78de920e12444589901990 +README.zh.md: 72d941f678e7e29a56bca678a79c4740667e7e6b diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index c35100c31e..26bc640c6c 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -1,5 +1,5 @@ --- -description: "Synchronize browser plugins with Host composition and reload rebuilt client bundles." +description: "Development-only hot reload for browser client plugins: rebuilding a plugin bundle swaps the running plugin in place, for developers iterating on the web GUI." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-hmr` keeps browser plugins synchronized with the Host module graph. Enabling or disabling a bundle adds or removes its browser contributions without a page reload. Development rebuilds replace existing plugins with fresh component state; retained plugins and Session state remain available. +`dsh-client-hmr` reloads a browser client plugin in place when its bundle is rebuilt, so a developer editing plugin source sees the change without a full page reload. The reload chain stays idle without a rebuild watcher: only a `pnpm run dev:web`-style process rewriting client bundles produces the rebuilds it reacts to. Each reload swaps one plugin with fresh component state while the data layer (connection, runtime, and Session objects) stays untouched. Everything here is development machinery in the browser; the model never sees it. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -The Web profile loads this package automatically. Bundle changes use the existing `/plugins/events` channel. For source development, enable a process that rebuilds the edited plugin’s `lib/client.js`. +Enable the rebuild watcher for the plugin you are editing, then save: the browser picks up the rebuilt bundle from the dev server and swaps the plugin without reloading the page. Use it during client development; nothing observable happens in a production build, where no watcher rewrites bundles. ### Starting the reload chain @@ -65,8 +65,6 @@ The chain is two halves with one contract: the node half owns bundle detection a On a `rebuilt` frame the revision makes `invalidate` select that plugin's immutable one-resource combo URL instead of its initial multi-resource URL. `prefetch` loads and registers the new factory while the old fiber still serves. The remaining order is registry-first teardown (`registry.delete` before the fiber's disposer emits `internal/plugin`, or the vendored Loader flags the entry disabled), drain the old fiber's unload, delete `entry.fiber`, remove owned `