# 审批提示音插件 · DSH 契约调研(任务 t1 · api-scout) > 交付物性质:只读侦察报告。**本次未修改任何 DSH 文件、未修改 profile、未安装任何 npm 包。** > 唯一写入:本文件(工作区内新建目录 `dsh-approval-chime/docs/`)。 > 所有结论都带 `文件:行号` + 原文引用;查不到的一律标注「未证实」。 ## 0. 取证环境(复核 captain 给的事实) | 事实 | 复核结果 | 证据 | | --- | --- | --- | | DSH 包目录 | 存在,只读使用 | `\node_modules\@deepseek-ai\*` | | 正在运行的 `dsh` 就是这份 npx 安装 | **确认** | `Get-Command dsh` → `ExternalScript \node_modules\.bin\dsh.ps1` | | profile | `$DSH_HOME\profiles\web`(只读) | `package.json` / `cordis.patch.yml` / `cordis.yml` / `node_modules` | | node / shell | node v24.21.0(PowerShell 5.1) | `node -v` | | 设置文档 | `$DSH_HOME\settings.yaml`,**已含第三方命名空间** `ui-background`(deepseek-harness-background) | 见 §F | | 工作区插件样例 | `dsh-quorum` / `dsh-quorum-panel` / `dsh-team-probe`(后两者含 `lib/client.js` 浏览器侧区) | 见 §D/§E | DSH 的「不发布伴生入口」原则(`dsh-api-gateway/README.md:90`:*“No companion is published.”*)意味着下面所有 API 都只能通过 cordis 服务/事件拿到,不能靠 `import` DSH 内部私有模块。 --- ## A. 审批触发点:能不能在 `ctx.remote.$on('approval/request')` 上插队? ### A.1 结论 | 问题 | 结论 | | --- | --- | | `ctx.remote.$on('approval/request', fn, {prepend:true})` 支持 prepend 吗? | **不支持。`$on` 只有两个形参,第三个参数被静默丢弃。** | | waterfall 顺序是注册序还是 prepend 优先? | **两者都生效:带 `prepend` 的监听器插到最前,其余严格按注册顺序(先注册先执行)。** | | 内置审批面板会不会把请求让给后面的监听器? | **正常路径不会**——它 `return await pending.result`,不调用 `next()`,因此**在它之后注册的 `$on('approval/request')` 监听器永远不会被调用**。 | | 推荐方案 | **不要用 `$on`。订阅 `ctx.uiSession.pendingInteractions`(HostObservable),按 `kind === 'approval'` 检测新出现的待审批条目来播放提示音。** | ### A.2 证据:`$on` 不接受 options `@deepseek-ai/dsh-api-gateway/lib/client.js:1472-1474`: ```js $on(event, listener) { return this.events.subscribe(this.ctx, event, listener); } ``` `@deepseek-ai/dsh-api-gateway/lib/types/client/remote-events.js:32-35`: ```js subscribe(callerCtx, event, listener) { const dispose = privateEvents(callerCtx).on(this.eventKey(event), listener); return () => { dispose(); }; } ``` → 调用链里**没有任何地方**接收或转交第三个参数;`ctx.on` 的 `EventOptions`(`{prepend, global}`)到不了这里。类型面上也只有两个参数:`@deepseek-ai/dsh-api-gateway/lib/types/client/remote-events.d.ts:28` ```ts subscribe(callerCtx: Context, event: Event, listener: TypertClientEventListener): () => void; ``` ### A.3 证据:cordis 事件语义(prepend 优先,否则注册序) `@deepseek-ai/cordis/src/events.ts:112-117`: ```ts export interface EventOptions { /** Add the listener before existing listeners for the same event. */ prepend?: boolean /** Receive the event regardless of context filter checks. */ global?: boolean } ``` `@deepseek-ai/cordis/src/events.ts:254-260`(注册即 append / unshift): ```ts register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void { const method = options.prepend ? 'unshift' : 'push' return this.ctx.fiber.effect(() => { hooks[method]({ ctx: this.ctx, callback, ...options }) ``` `@deepseek-ai/cordis/src/events.ts:234-243`(waterfall 逐个 `shift`): ```ts waterfall(...args: any[]) { const cbs = this.dispatch('waterfall', args) const inner = args.pop() const next = () => { const cb = cbs.shift() ?? inner return cb(...args) } ``` **实测(本次运行,见 §I 探针 2/3,用 DSH 自带的 cordis 包真跑)**: ``` order: C(prepend) -> A(reg1) -> B(reg2) | return: B-answered order2: builtin(uiapproval): never calls next -> (chime(late) 根本未被调用) | return: allowed-once ``` 第二条正是本插件的风险现场:先注册、且不调用 `next()` 的监听器会把整条瀑布切断。 ### A.4 证据:内置审批面板注册在「瀑布链前面」,且正常路径不让位 注册形态 `@deepseek-ai/dsh-client-ui-approval/lib/client.js:282-284`: ```js ctx.remote.$on("approval/request", function(request, next) { return answerApproval(ctx, this, request, next, registerPendingInteraction); }); ``` 正常路径不让位 `@deepseek-ai/dsh-client-ui-approval/lib/client.js:236-261`(节选,行号连续): ```js async function answerApproval(ctx, owner, request, next, registerPendingInteraction) { const sessionId = ctx.sessions.scopeOf(owner); if (sessionId === void 0) return next(); // 局部退让之一(无 session) ... try { try { return await pending.result; // 正常路径:永不 next() } catch (error) { if (pending.isDelegation(error)) return await next(); // 局部退让之二(显式 delegate) throw error; } ``` **它的注册为什么一定在我们前面?** 行序由 bundle 层顺序决定: 1. `dsh-web-app` 的 patch 层里就插入了 `ui-approval` 行:`@deepseek-ai/dsh-web-app/cordis.patch.yml` 中 ```yaml - id: ui-approval name: '@deepseek-ai/dsh-client-ui-approval' ``` 2. 层按 `dsh.profile.bundles` 顺序叠加,`insert` 一律 **push 到列表尾部**: `@deepseek-ai/dsh-app-boot/lib/index.js:291-299`(模块文档 *“the tree is composed by applying each bundle's patch list in `dsh.profile.bundles` order over an empty entry list”*)+ `:70-86` ```js if (insert) { ... if (!Array.isArray(target.config)) target.config = []; target.config.push(...insert); } else data.push(...insert); ``` 而现 profile 的 bundles 顺序是 `[…] "@deepseek-ai/dsh-web-app", … 第三方 …`(`profiles/web/package.json`),即我们的行排在 `ui-approval` 之后。 3. 浏览器端 cordis 插件按 entry 树顺序激活(**推断**:客户端 boot 用同一份 loader entry 图构图,`dsh-client-modules/lib/index.js:586-593` 的 `compose()` 保持 entry 顺序;本任务无法启动浏览器实测激活次序)。 4. **更致命的一点**:即使把我们的 bundle 挪到 `dsh-web-app` 之前,我们的 `apply` 也要等 `inject` 的服务(`remote`/`uiSession`/…)就绪才执行,而这些服务由 `dsh-web-app` 里更靠后的行提供——注册时机并不由行序独占决定。**所以「靠行序抢在审批插件前注册」是不可靠的。** ### A.5 备选方案(推荐):`ctx.uiSession.pendingInteractions` 类型定义 `@deepseek-ai/dsh-client-ui-session/lib/types/client/index.d.ts:96`: ```ts /** Root source of pending UI interactions, independent from Controller snapshots. */ readonly pendingInteractions: HostObservable; ``` `:29`:`export type SessionPendingInteractionSnapshot = ReadonlyMap;` `:117`:`registerPendingInteraction(precedence: (interaction: T) => number): PendingInteractionPublisher;` **运行时形状(真实实现,可照抄)** `@deepseek-ai/dsh-client-ui-session/lib/client.js:80-90`: ```js pendingSnapshot = /* @__PURE__ */ new Map(); pendingListeners = /* @__PURE__ */ new Set(); /** Root source of pending UI interactions, independent from Controller snapshots. */ pendingInteractions = { getSnapshot: () => this.pendingSnapshot, subscribe: (listener) => { this.pendingListeners.add(listener); return () => { this.pendingListeners.delete(listener); }; } }; ``` → 只需要 `getSnapshot()` / `subscribe(listener) → disposer` 两个方法,**不需要 React**。 `` 的**approval 条目如何判别**:内置审批面板发布的对象带 `kind`、`key`、`sessionId`(`@deepseek-ai/dsh-client-ui-approval/lib/client.js:114-142`): ```js var PendingApproval = class { sessionId; /** Domain discriminator used by Session pending-interaction consumers. */ kind; /** Opaque render identity and one-shot remount axis. */ key; ... constructor(sessionId, request) { this.sessionId = sessionId; this.kind = "approval"; // ← 判别依据(line 140) nextApprovalKey += 1; this.key = `approval:${String(nextApprovalKey)}`; // ← 去重依据(line 142) this.toolName = request.toolName; ``` 发布时机(`@deepseek-ai/dsh-client-ui-session/lib/client.js:213-227`): ```js publishPendingInteractions() { const next = /* @__PURE__ */ new Map(); for (const domain of this.pendingDomains) for (const interaction of domain.valuesSnapshot()) { const precedence = domain.precedence(interaction); const previous = next.get(interaction.sessionId); if (previous === void 0 || precedence >= previous.precedence) next.set(interaction.sessionId, { interaction, precedence }); } const projected = new Map([...next].map(([sessionId, value]) => [sessionId, value.interaction])); if (samePendingInteractions(this.pendingSnapshot, projected)) return; this.pendingSnapshot = projected; (0, _deepseek_ai_dsh_client_store.notifySubscribers)(this.pendingListeners, "[ui-session] pending interactions"); } ``` 注意两个语义(写实现时必须照顾): * **每个 session 只暴露一个** pending interaction(按 `precedence` 取大者)。同一会话同时有待审批和待回答问题(`dsh-client-ui-user-questions`)时,只有一个会出现在 Map 里。 * Map 每次变化是**整体替换**,元素引用不变(`value.interaction`)。所以要用 `key`/引用做增量 diff,不能「变了就响」。 **为什么推荐它**(相比 `$on`): 1. 与注册顺序完全无关——不受 §A.4 的行序/inject 时机问题影响。 2. **绝不触碰审批瀑布**:不需要调用 `next()`,不可能因为我们的异常或忘记 next 而卡死审批流程(这是 `$on` 方案的首要风险)。 3. 语义更准确:它在「审批面板真的被呈现给用户」那一刻触发;`$on` 在没有 session 的场合(`answerApproval` 第 238 行 `return next()`)也会触发,而那种情况用户根本看不到面板。 4. 只用公开的服务 + 两个方法(`getSnapshot`/`subscribe`),无 React 依赖。 **退路**(若 `pendingInteractions` 在实测中不可用/不可订阅): 按优先级依次尝试——(a) `ctx.uiSession` 的服务名/属性若改变,改用 `ctx.remote.$on('approval/request')` 并把本 bundle 排在 `dsh-web-app` **之前**,同时在监听器里**无条件先 `next()` 再播音**(fire-and-forget),接受「可能不触发」而不是「可能卡死审批」;(b) 兜底改为监听 DOM 中审批面板出现(脆弱,不建议)。 --- ## B. 设置命名空间:`ctx.settings.register` 与 schemastery 的获取 ### B.1 结论 | 问题 | 结论 | | --- | --- | | 最小可用调用 | `ctx.settings.register('approval-chime', Schema, { applies: 'live' })`,`inject = ['settings']` | | 命名空间命名约束 | 必须匹配 `/^[a-z][a-z0-9-]*$/` → `approval-chime` ✅ | | schema 形状 | schemastery `z` 的任意 schema;值**必须是对象**(客户端 decode 强制 `typeof value === 'object'`) | | link: 包能否裸导入 `@deepseek-ai/schemastery`? | **不能,默认必失败**(实测:Node 把符号链接解析到 realpath,再从 `D:\...` 向上找 `node_modules`) | | 可行路径 | ① 在插件目录内建 `node_modules/@deepseek-ai/schemastery` junction(**推荐,已实测**);② 用 `ctx.baseUrl` + `createRequire` 在运行时解析(无链接,证据充分但未在真实插件里实测) | ### B.2 注册 API `@deepseek-ai/dsh-settings/lib/types/index.d.ts:216`: ```ts register(ns: Namespace & SettingsNamespaceInput, schema: z, options?: SettingsRegisterOptions): SettingsScope; ``` `:23-48` options: ```ts export interface SettingsRegisterOptions { /** Composition-layer values resolved below the user layer (entry-config subset). */ base?: Partial; /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies; validate?: (value: T) => void; } ``` `:84-110` DSH 端返回的 `SettingsScope`:`get()` / `watch(cb)` / `update(patch)` / `replace(section)`。 `@deepseek-ai/dsh-settings/lib/types/index.d.ts:2-5`(解析顺序):*“plugins register a namespace schema and read the resolved value, which layers schema defaults, the registrant's composition `base`, and the user document section, in that order.”* 命名空间校验 `@deepseek-ai/dsh-settings/lib/index.js:82-86`: ```js const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/; function parseSettingsNamespace(value) { if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`); ``` 重复注册直接抛错(`:283`):`if (this.registrations.has(parsedNs)) throw new Error('settings namespace "..." is already registered');` → 同一命名空间只能注册一次,重载时用 `ctx.settings.describe().some(d => d.ns === NS)` 先探测(第三方插件现成写法见 `deepseek-harness-background/lib/index.js:595`)。 **schema 必须真会「被调用」**:`@deepseek-ai/dsh-settings/lib/index.js:509-513` ```js resolve(schema, base, section, validate) { const value = schema(mergeLayers(base, section)); ``` `describe()` 另外要求 `schema.toJSON()`(`:363`)并做 `redactSecrets(schema, ...)` 的 `type`/`dict`/`inner` 遍历(`:30-53`)。**所以「自己手搓一个假 schema」风险很高,不推荐。** ### B.3 DSH/第三方插件的标准写法(可照抄的风格) * `@deepseek-ai/dsh-bash-local/lib/index.js:1`:`import z from "@deepseek-ai/schemastery";` * `deepseek-harness-background/lib/index.js:1`:`import z from "@deepseek-ai/schemastery";`(`profiles/web/node_modules` 里已装好的第三方插件,`package.json` 里 `"dependencies": {"@deepseek-ai/schemastery": "^3.18.1"}`) * `deepseek-harness-background/lib/index.js:595`: ```js if (!hostCtx.settings.describe().some((descriptor) => descriptor.ns === "ui-background")) hostCtx.settings.register(BACKGROUND_SETTINGS_NAMESPACE, BackgroundSettingsSchema, { applies: "live" }); ``` ### B.4 加载器到底怎么 import 一个插件行 `@deepseek-ai/cordis-plugin-loader/src/config/entry.ts:280`: ```ts plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) ``` `@deepseek-ai/cordis-plugin-loader/src/config/tree.ts:144-162`: ```ts import(name: string, getOuterStack?: () => string[]) { if (name.startsWith('cordis:')) { return this.ctx.loader.builtins[name.slice(7)] } return composeError(async (info) => { ... if (this.ctx.loader.internal) { return await this.ctx.loader.internal.import(name, this.ctx.baseUrl!, {}) } else if (name.startsWith('.')) { return await import(/* @vite-ignore */new URL(name, this.ctx.baseUrl).href) } else { return await import(/* @vite-ignore */name) } }, getOuterStack) ``` * `baseUrl` 是 **profile 目录**的 file URL:`@deepseek-ai/dsh-app-boot/lib/index.js:1529` `ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/";`(`absoluteConfigPath` 即 `…\profiles\web\cordis.yml`) * 行名 `dsh-approval-chime` 先按 **安装(dsh 本体)** 解析、再按 **profile 目录** 解析(`@deepseek-ai/dsh-app-boot/lib/index.js:301-308` 模块文档 + `:801-822` 的 `resolvePackageDir`): > *“Module resolution is two-anchor by construction: a bundle name resolves first from the dsh installation (the launcher's own package), then from the profile directory.”* * **但插件体内部的裸导入按「插件文件自己的真实路径」解析**,与行从哪儿解析无关。 ### B.5 实测(本次运行,命令与输出见 §I 探针 1) 复现装置:`_probe-symlink/pkgs/fake-plugin`(模拟我们工作区里的 link: 包,实体位于 `D:\…\`),`_probe-symlink/profile`(模拟 profile 目录,`node_modules/fake-plugin` 是指向 `pkgs/fake-plugin` 的 **Junction**,`node_modules/@deepseek-ai/schemastery` 指向 DSH 副本),从 `profile/boot.mjs` 执行 `await import('fake-plugin')`: **CASE 1(只在 profile/node_modules 有链接)→ 失败**: ```json { "url": "file:///%20Harness%20Work%20area/DeepSeek%20Harness/_probe-symlink/pkgs/fake-plugin/lib/index.js", "schemaError": "Cannot find package '@deepseek-ai/schemastery' imported from \\_probe-symlink\\pkgs\\fake-plugin\\" } ``` → **Node 把 junction 解析到 realpath**(`import.meta.url` 显示的是 `D:\...`),随后从 `D:\...` 向上找 `node_modules` 而不是从 profile 目录找。**「profile/node_modules 里已有 schemastery」帮不上忙。** **CASE 2(在插件目录内再建一个 `node_modules/@deepseek-ai/schemastery` junction)→ 成功**: ```json { "url": "file:///%20Harness%20Work%20area/DeepSeek%20Harness/_probe-symlink/pkgs/fake-plugin/lib/index.js", "schemaType": "function" } ``` 同一个 node 进程里对真实 schemastery 做的表面验证: ``` type= object keys= [ 'type', 'meta', 'toString', 'dict' ] toJSON= {"uid":6,"refs":{"1":{"type":"boolean",...},"6":{"type":"object","meta":{"default":{}},"dict":{"enabled":1,"volume":5}}}} resolve= {"enabled":true,"volume":0.3} ``` → 与 `dsh-settings` 需要的 `schema()` 调用、`toJSON()`、`type`/`dict` 遍历完全吻合。 ### B.6 两条可行路径 + 推荐 **路径 ①(推荐,已实测):在插件目录里建 schemastery junction** ```powershell # 目标选「DSH 自愈的安装依赖闭包」——它每次启动都会被 dsh-app-boot 重建(见下), # 因此比 npx 缓存里的私有路径更稳定。 $ns = '\dsh-approval-chime\node_modules\@deepseek-ai' New-Item -ItemType Directory -Force -Path $ns | Out-Null New-Item -ItemType Junction -Path "$ns\schemastery" ` -Target '$DSH_HOME\profiles\node_modules\@deepseek-ai\schemastery' ``` * 该目标是**DSH 自己维护**的:`@deepseek-ai/dsh-app-boot/lib/index.js:645-676` > *“The shared `$DSH_HOME/profiles/node_modules` mirrors the dsh installation dependency closure. Plain Node writes symlinks…”* 已确认存在且 `@deepseek-ai/schemastery` 在列(`Get-ChildItem $DSH_HOME\profiles\node_modules\@deepseek-ai` → `schemastery Junction`)。 * 两处副本版本一致:DSH 副本 3.18.2、profile hoisted 副本 3.18.2。 * 约束:**只允许在工作区内建这个链接**;指向的是 DSH 只读副本,不复制、不修改、不安装。 **路径 ②(无链接,证据充分但未在真实插件里跑过):运行时按 `ctx.baseUrl` 解析** ```js import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; /** Resolve the Host's own schemastery copy from the profile anchor. */ async function loadSchemastery(ctx) { const anchor = new URL('package.json', ctx.baseUrl); // ctx.baseUrl = file:///$DSH_HOME/profiles/web/ const resolved = createRequire(anchor).resolve('@deepseek-ai/schemastery'); const mod = await import(pathToFileURL(resolved).href); return mod.default ?? mod; } ``` 证据:`ctx.baseUrl?: string`(`@deepseek-ai/cordis/lib/types/context.d.ts:23`)在 boot 时被设为 profile 目录 URL(`dsh-app-boot:1529`),且 loader 自己就是这么用的(`dsh-app-boot/lib/index.js:135`:`new URL(this.config.path, this.ctx.baseUrl)`)。 **未证实**:插件 `apply(ctx)` 里的 `ctx.baseUrl` 是否一定继承到(本任务无法启动 DSH 做真机验证)。若走这条路,**必须写成 try/catch + 明确日志**,不要静默降级。 **路径 ③(明确不推荐)**:手搓一个可调用 + `toJSON()` + `type`/`dict` 的假 schema。`dsh-settings` 对 schema 的用法虽只要求这些表面,但 `redactSecrets` 会遍历 `type`/`inner`/`dict` 结构(`lib/index.js:20-53`),任何一处不吻合都会在 `describe()`(即设置页)时报错,且错误路径离根因很远。 --- ## C. 设置卡片:`settings.plugin.item`、`settingsScope`、以及「第三方能不能 require CardForm」 ### C.1 结论 | 问题 | 结论 | | --- | --- | | 卡片注册形态 | `ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name:'settings.plugin.item', key:, locale:NS, inject:()=>({hooks:{…}}) }, Card))` | | key | **= 设置命名空间**(tab 用它把 DSH 命名空间和卡片配对) | | `CardForm` / `numberField` 能否跨包 require? | **不能。**`dsh-client-ui-settings-plugins` 的浏览器 bundle **只导出 `apply` / `inject`**(`lib/client.js:1813-1814`);`CardForm`/`numberField`/`ValueField`/`PluginCard` 只存在于类型声明和内部实现里。**卡片 UI 必须自己写。** | | 浏览器端 require 的命名空间 | 静态种子表(`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives`、`@deepseek-ai/dsh-client-ui-dockkit`)+ 图上存在的行 id(`<包名>` 或 `<包名>/client`)| | 卡片 `available = true` 的条件 | `scope.getSnapshot().status === 'ready'`;而「哪个命名空间会被派发」由 tab store 决定:卡片 slot 条目的 `key` **必须出现在 DSH `settings.describe` 的 `namespaces[].ns` 里** | ### C.2 slot 契约(原文) `@deepseek-ai/dsh-client-ui-settings-plugins/lib/types/client/slot-contract.d.ts:1-30`(节选): ```ts /** * The `settings.plugin.item` slot type — one plugin's card inside the * configurable-plugins tab, keyed by the settings namespace the card edits. * Options: `key` (the namespace). A card draws its own internals; the tab only * decides which namespaces to dispatch and stacks what comes back. * ... * Keying on the namespace is what lets a plugin distributed outside this * repository contribute a card: it registers its own settings namespace on the * Host and its own card under that key in the browser, and the tab pairs the * two without ever learning what the namespace means. */ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps; }; } } ``` **官方自己的卡片就是第三方要照抄的形态** `@deepseek-ai/dsh-client-ui-settings-plugins/lib/client.js:1785-1797`: ```js ctx.slots.inject("settings.plugin.item", function* () { yield ctx.slots.register({ name: "settings.plugin.item", key: SHELL_NS, locale: NS, inject: () => bash.inject() }, BashCard); yield ctx.slots.register({ name: "settings.plugin.item", key: AGENT_LOOP_NS, locale: NS, inject: () => agentLoop.inject() }, AgentLoopCard); ... ``` 工作区里 `dsh-quorum-panel/lib/client.js:1298-1306` 给出了同一套 API 的最小用法(含「注册前必须等 slot 被声明」的注释): ```js // `inject` waits for the slot to be DECLARED by its owner; registering // against an undeclared slot throws at load time. ctx.slots.inject('shell.overlay', function () { MOUNTED.push('shell.overlay'); return ctx.slots.register( { name: 'shell.overlay', id: PLUGIN_ID + '.board', order: 88, locale: NS }, Panel, ); }); ``` ### C.3 卡片何时出现:tab 的两道闸门 闸门一:**namespace 必须被 DSH served 给这个客户端** `@deepseek-ai/dsh-client-ui-settings-plugins/lib/client.js:1140-1152`: ```js publish() { if (this.disposed) return; const mirrored = this.describeFace.getSnapshot(); const loaded = mirrored.view !== void 0; const served = new Set(mirrored.view?.namespaces.map((view) => view.ns) ?? []); const namespaces = this.entries().flatMap((entry) => entry.options.key !== void 0 && served.has(entry.options.key) ? [entry.options.key] : []); ``` 渲染(`:411-417`): ```js const { loaded, namespaces } = props.useConfigurablePlugins((snapshot) => snapshot); if (namespaces.length > 0) return … children: namespaces.map((ns) => … renderSlot("settings.plugin.item", {}, { entryKey: ns }) … ``` 即:**没有注册命名空间 → 卡片不出现;卡片 slot 条目的 `key` 与命名空间不一致 → 卡片不出现。** 两者都必须对。 闸门二:**`available` 由 scope 快照状态决定**(卡片自己判)`.../client.js:850` 与 `:1412`: ```js available: snapshot.status === "ready", ``` 官方卡片的渲染守卫(`.../lib/client.js:218`):`if (!state.available) return null;` ### C.4 `settingsScope` 绑定 服务与绑定方法 `@deepseek-ai/dsh-client-ui-settings/lib/types/client/settings-scope.d.ts:88-92, 128-139`: ```ts declare module '@deepseek-ai/cordis' { interface Context { settingsScope: SettingsScopeBinder; } } ... /** The shared mirror's read/fold face for cross-namespace surfaces … */ describe(): SettingsDescribeFace; /** Bind one namespace scope on the CALLER's plugin lifecycle … */ bind(spec: SettingsScopeSpec): SettingsScope; ``` **`bind` 的参数是对象,不是裸字符串**(任务书里写的 `bind({namespace})` 正确,但要注意 `spec` 的形状)`@deepseek-ai/dsh-client-ui-settings/lib/types/client/settings-contract.d.ts:33-43`: ```ts export interface SettingsScopeSpec { /** Settings namespace registered by the owning Host plugin. */ namespace: string; /** Narrow one wire section; undefined keeps the last accepted value. … */ decode?: (section: unknown) => T | undefined; } ``` 运行时实现(连续可引用)`@deepseek-ai/dsh-client-ui-settings/lib/client.js:1169-1179`: ```js bind(spec) { const ctx = this.ctx; const controller = new SettingsScopeController(this.owner, spec, this.mirror, this.persistence, this.schema); ctx.effect(() => { this.mirror.ensure(); return async () => { await controller.dispose(); }; }, `ui-settings: ${spec.namespace} settings scope`); return controller; } ``` 客户端 scope 读 API `.../settings-contract.d.ts:50-85`:`getSnapshot()` / `subscribe(listener)` / `set(field, value)` / `unset(field)` / `mutate(ops, expectedRevision?)`; 快照字段 `:6-32`:`{status, value, base, user, revision, writable, mode}`(`status: 'loading' | 'ready' | 'unavailable'`)。 ### C.5 浏览器端 require 的真实命名空间(实测自 shell 产物) `@deepseek-ai/dsh-web-frontend/dist/assets/index-BKQ_L1z6.js` 中的 `staticModules` 种子表(原文): ```js function by(){return{react:ec,"react/jsx-runtime":ic,"react-dom":cc,"react-dom/client":fc,"@deepseek-ai/cordis":Ha,"@deepseek-ai/dsh-client-store":Hc,"@deepseek-ai/dsh-client-ui-slots":Ac,"@deepseek-ai/dsh-client-ui-primitives":Zg,"@deepseek-ai/dsh-client-ui-dockkit":Ey}} }); ``` 配套证据:`dsh-client-modules/lib/types/client/manifest.d.ts:150-156`(`ClientBundleRegistration.id` / `factory(require)`),以及第三方插件的实际 require 列表——`dshmarket/client/client.js:29-33` 与 `deepseek-harness-background/lib/client.js:29-31` **只 require 种子词**(`react`、`react/jsx-runtime`、`react-dom`、`@deepseek-ai/dsh-client-ui-primitives`),跨插件协作一律走 cordis 服务(`dsh-client-ui-settings/lib/client.js:1120-1124` 的注释明说 bundle purity gate 禁止跨插件值导入)。**我们的 client.js 只需要 `require('react')`。** --- ## D. 客户端装载:第三方包怎么被服务/加载 ### D.1 声明要求 `@deepseek-ai/dsh-client-modules/lib/index.js:637-666`(`resolveMeta`,节选): ```js const dsh = pkg.dsh; const decl = parseDshClient(packageName, dsh !== null && typeof dsh === "object" ? dsh.client : void 0); if (decl === void 0 || decl.platform !== "web") { this.pkgMeta.set(sourceKey, null); return null; } const clientRel = clientExportOf(packageName, pkg.exports); if (clientRel === void 0) throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`); const resolved = { packageName, meta: { clientPath: join(dirname(pkgPath), clientRel), ...decl.inject !== void 0 ? { inject: decl.inject } : {}, external: decl.external ?? [], immediately: decl.immediately === true } }; ``` * `dsh.client.platform` 必须是字符串 `"web"`(`:140-154`;缺失/非对象 → 该包**不参与**浏览器图)。 * `exports["./client"]` 必须是字符串,或对象里带字符串 `default`(`:155-166`): ```js const client = exportsField["./client"]; if (client === void 0) return void 0; if (typeof client === "string") return client; if (typeof client === "object" && client !== null) { const fallback = client.default; if (typeof fallback === "string") return fallback; } throw new Error(`client-modules: ${packageName} exports["./client"] must be a string or an object with a string default`); ``` * 文件必须真实存在,否则**启动直接失败**(`MissingClientBundleError` / `:750-764`): ```js client-modules: client bundles not found; run `pnpm run build` before launch: package: path: ``` * **包名必须有 Node 可解析的 DSH 入口**(行要能被 loader import)。工作区样例的原话 `dsh-quorum-panel/lib/index.js:8-11`: > *“It exists rather than being omitted because a bundle row needs a Node-resolvable package: the loader resolves the row's `name` and imports it, so a client-only package still needs a host entry to mount.”* ### D.2 为什么客户端模块 id 必须等于包名 * `@deepseek-ai/dsh-client-modules/lib/types/client/manifest.d.ts:46-49`: ```ts export interface WebBootEntry { /** Entry name == package name. */ id: string; ``` * `:147-157`: ```ts export interface ClientBundleRegistration { /** Plugin id (package name) — the registration key; must match the graph row being executed. */ id: string; ``` * 运行时也用它当表键与 URL:`lib/index.js:813-835`(`this.table.set(packageName, …)`)、`:182-184`(`comboUrl` → `/plugins/??/client.js&rev=`)、`:857-871`(按该 URL 查响应,未命中 404)。注册 id 与图行不一致 ⇒ 该行找不到 factory(模块系统「anything else → throw」)。 * 工作区样例的注释同样强调:`dsh-quorum-panel/lib/client.js:5-7` > *“Loaded by the host as a CLASSIC script (a plain `