# 02 — DeepSeek Harness plugin platform contract Evidence-grade analysis for the Excalidraw plugin (host + browser halves, agent tools, file persistence). Everything below is either **read from source** (public clone, path:line), **read from the installed app** (0.2.1-rc.2 `app.asar` docs/packages), **queried from the live Host** (`cordis_inspect_query`, raw output saved), or **measured** in the isolated experiment in §6. Claims that could not be verified are marked **UNVERIFIED** in place. Convention: `path:line` means the file at that path in this workspace, `upstream/deepseek-harness/…` is the public clone. Raw inspect output and experiment logs live in [`docs/evidence/dsh/`](evidence/dsh/). --- ## 0. Sources, versions, and what is actually live | Fact | Value | Evidence | |---|---|---| | Live installed Harness | **0.2.1-rc.2** | `dsh --version` → `0.2.1-rc.2` ([live-profile.txt](evidence/dsh/live-profile.txt)) | | Live profile | `desktop`, dir `$DSH_PROFILE_DIR` = `~/.dsh/profiles/desktop` | same | | Public clone | commit `22ddee3792402c3e70ec5f86a63c3e3e895a2f60` (2026-10-23), **root version 0.2.1-rc.3** | `upstream/deepseek-harness/package.json`; `git log -1` | | Live client-modules package | `@deepseek-ai/dsh-client-modules@0.2.1-rc.2`, `dsh.client = { platform: "web", inject: [], immediately: true }` | `app.asar/dsh/node_modules/@deepseek-ai/dsh-client-modules/package.json` | | Installed version's own plugin docs | `…/dsh-agent-preset/skills/cordis-plugin-development/references/*.md` (read from `app.asar` with the file-read tool) | cited as "installed skill reference" | **Version caveat.** The clone is one release candidate *ahead* of the running app. Source citations below are rc.3; where the contract matters for the delivered plugin, it is corroborated by the installed skill reference (rc.2) and, for the tool surface, by the live Host itself. I found **no contradiction** between the two versions on any contract in this document, but the rc.3 clone can contain additive fields the rc.2 package does not have. The Excalidraw plugin must be written against **rc.2 semantics** (the app it will run in) and re-checked after any app update. **Live capability boundary of this analysis.** - Works: `host/Service.listService` (catalog, 86 KB), `host/Tool.listTools` (123 KB, full tool schemas), `host/Config.listConfigs` (204 live Loader entries). - Blocked: **every query that passes `input`** — the inspect bridge rejected `input` with `Error: … rejected input: "input" must be an object`, including `input: {}` and a JSON string ([live-inspect-attempts.txt](evidence/dsh/live-inspect-attempts.txt)). So the per-service exact contract view, the per-entry Config schema, and `Slots.listSubTree(root)` could not be captured. - Blocked: **every client-platform query** (`Builtin.listBuiltins`, `Slots.listSubTree`) timed out after 10 s with `Open or reconnect the Harness page, then retry` — no page was connected to the client inspect bridge during this run. The live slot tree is therefore **UNVERIFIED**; slot facts below come from the installed rc.2 skill reference, the rc.3 source, and the reference plugin's proven registration. --- ## 0.1 Short version (the decisions this document supports) 1. **One npm package, two halves.** Host: an ESM module exporting `apply`/`inject` (+ optional `Config`) or a service class. Browser: `exports["./client"]` containing `window.__ModuleLoader__.load({id: , factory(require){…}})`. 2. **One bundle row** inserted by the package's own `cordis.patch.yml`; the profile lists the package in `dsh.profile.bundles`. Install = package install + that list entry (CLI `dsh plugin add` or `plugin_manager install_bundle`), uninstall = the reverse. 3. **Everything is an effect.** Register services, routes, timers, tools, slots, styles and locale through `ctx.effect`/`ctx.on` and return the disposer; the Loader disposes them when the row is disabled or the plugin reloaded. Do not rely on async disposers at process exit. 4. **Tools:** `name`, `description`, `parameters` (JSON Schema), **mandatory** `output {schema, render}` and `execute`; the registry validates only the output schema, so validate your own arguments. `tools.register` returns the disposer. `restrict` is per-agent; `guard` only denies; real confirmation goes through `ctx.approval` (open turn required). 5. **Browser module table is 12 words** (React, cordis, zod, client-store, ui-slots, ui-primitives, dockkit, react-virtual). Everything else must be bundled; never ship a second React. 6. **UI:** register the page into `main` (`key: `, keyed/root) and its icon into `sidebar.panellist` (`id: `, list/root, `label`, `order`) — the reference plugin is a proven example. `ctx.layout.selectPanel(id)` navigates. 7. **Browser ↔ Host:** same-origin `fetch` on a `webServer.register({kind:'prefix'})` route whose handler first calls `ctx.get('connection').requestRejection(req)` (403 trust fence, then 401 cookie check). Alternatives: `connection.fetch.register` (exact `/api` routes, owner Peers only), `connection.rpc.handle`, or a WebSocket upgrade + `admitConnection`. Never add a second auth scheme. 8. **State:** plugin state in `$DSH_HOME//` (or `ctx.storageDomain` when you want schema + change events); user `.excalidraw` files stay ordinary workspace files. The Host is not sandboxed — path discipline is the plugin's own job. 9. **A 3–5 MB client bundle is loaded, parsed and executed at every page boot** for every user (all client rows activate at boot; `immediately` only changes prefetch timing). Split the heavy editor into a package-local `client..js` chunk loaded via `require.async`, serve fonts/WASM from a custom Host route (package assets reject non-image types), and keep `client.js` small. 10. **Bounds:** no iframe pages, no custom client packages at runtime, no plugin-run HTTP server, no new session event types, no worker entry point for plugin code. Those need source-level changes or a separate app (§8). --- ## 1. Lifecycle: package → bundle → row → fiber → effect ### 1.1 The five units ``` npm package --(dsh.bundle.patch)--> bundle layer profile/package.json dsh.profile.bundles --(ordered layers)--> composed entry tree (rows) row --(cordis-plugin-loader)--> Loader entry --(cordis)--> plugin fiber --(ctx.effect)--> disposable ``` A **profile** is a directory under `$DSH_HOME/profiles/` with a `package.json` (plugin dependencies + `dsh.profile.bundles`) and its own `cordis.patch.yml`. Composition imports the root entry list from `cordis.yml` (always `[]`), applies each selected bundle's patch files **in `bundles` order**, then the profile's own patch layer, then launcher `--patch` overlays: `upstream/deepseek-harness/packages/boot/app-boot/src/profile.ts:1-18`. A **bundle** is any npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (string or ordered list) — `profile.ts:47-70`. Module resolution is **two-anchor**: a row's package name resolves first from the dsh installation (packages shipped with the app, e.g. `@deepseek-ai/dsh-base`), then from the profile directory (`profile.ts:14-18`). The runtime resolution also supplies installation-carried packages to Node's ESM and CommonJS resolvers, so a plugin *can* `import '@deepseek-ai/cordis'` without declaring it — but the set of importable packages is version-dependent, which is why the reference plugin deliberately imports nothing from the harness (`src/host/tools.js:308-316` says so explicitly) and ships its own JSON-Schema compiler. ### 1.2 Row shape and patch dialect A row has `id`, `name` (module specifier), optional `config`, and optional `disabled`, `inject`, `intercept`, `isolate`. Patch semantics, from the installed `cordis-composition-reference` skill (the rc.2 authority) and visible in the composed dump: - `insert: [rows]` appends rows; with an `id` naming a `group: true` row, it appends inside that group. - A patch with an `id` and no `insert` **targets** that row; supplied fields replace row fields, and **`config` is replaced wholesale, never deep-merged**. A truthy `name` only asserts identity. - `disabled` accepts bool/null/`!!js` expression evaluated at every mount decision. A disabled row omits required `config` unless `group: true` forces activation. - `!!js` scalars are Loader expressions (never `!js`); inside `config` they are evaluated after the row's injections activate, against that plugin's context — e.g. `!!js dshHomePath('sessions')`. - `group: true` + `name: cordis:group` makes `config` a nested entry list; `cordis:include` loads a literal YAML/JSON entry list. - `isolate` maps service names to `true`/realm labels (service-instance isolation); scope controls contribution visibility. - Non-insert patches without a nonempty `id`, and targets matching no row, are warned and skipped. A real composed tree is in [experiment/dump-config-after-add.txt](evidence/dsh/experiment/dump-config-after-add.txt): 360 lines, framework rows with `disabled: !!js '!ctx.get(''profileContext'')'` (dump lines 6, 11-12, …) and our fixture bundle appended as `# == dsh-expt-fixture` + row `expt-fixture` (lines 361-365). ### 1.3 Install / uninstall / disable **CLI path** (used by our experiment): `dsh plugin --profile ` forwards to pnpm inside the profile directory (`upstream/deepseek-harness/apps/cli/src/args.ts:188`, `apps/cli/src/plugin.ts:71-107`). Installing a local package writes: 1. `profiles//package.json` → `dependencies[""] = "link:"` **and** appends the package to `dsh.profile.bundles` (observed: [plugin-add](evidence/dsh/experiment/plugin-add.stdout.txt), [composed tree after add](evidence/dsh/experiment/dump-config-after-add.txt); the manifest itself is reproduced in §6); 2. a `node_modules/` symlink to the source directory (pnpm `link:` protocol, hoisted linker); 3. a lockfile update. Uninstalling removes the dependency and the `bundles` entry, but **pnpm's `link:` symlink stays in `node_modules`** (dangling). Measured: §6.3. A plugin must therefore never be resolved by scanning `node_modules`; DSH resolves via the `bundles` list, which is why the leftover link is harmless — but it is a real difference between "uninstalled" and "clean". **App path (sanctioned live install):** `plugin_manager` `install_bundle` performs package installation and bundle selection; do not reproduce those steps with shell commands (installed skill reference `references/host-plugin.md:66-68`). Its result model matters for verification: `application` is `applied` / `failed` / `overridden` / `restart-required` (`failed` requires diagnosis, `overridden` means a higher layer wins); installing a new bundle can activate through HMR, **replacing** an installed package requires a restart, and an unchanged slot id does not prove new browser code loaded (`host-plugin.md:68-70`). **Disable** is a row-level state, not a package removal: `disabled: true` (or an expression) makes the Loader skip/dispose the fiber, and every `ctx.effect` inside the plugin runs its disposer. The composed tree shows framework rows disabled by expression this way ([dump lines 6, 11-12](evidence/dsh/experiment/dump-config-after-add.txt)). **Client code replacement** is revision-driven: the artifact revision is a hash of the bundle's `mtimeMs`, `ctimeMs`, and `size` (`upstream/deepseek-harness/packages/client/modules/src/index.ts:219-224`), and a changed revision invalidates the module, tears down the entry fiber, removes owned styles, re-imports and refreshes (`packages/client/modules/src/client/entries.ts:163-200`). ### 1.4 Config declaration and validation A Host plugin declares config with a Cordis plugin `Config` (schemastery `z` or zod v4, anything implementing Standard Schema): ```js export const Config = z.object({ dataDir: z.string().default(''), dryRun: z.boolean().default(true) }) ``` Validation happens at activation, not at patch-parse time: `upstream/deepseek-harness/vendor/cordis/src/fiber.ts:50-62` calls `runtime.Config['~standard'].validate(config)`, throws on `issues` (async validation is unsupported), and publishes the parsed value as the second `apply(ctx, config)` argument. Two consequences that matter: 1. **Unknown keys are not rejected.** The schemastery Standard-Schema face resolves with `strict = false` (`vendor/schemastery/src/index.ts:290-303`) and the object resolver `merge(result, data)`s undeclared keys back in (`:812-823`). A plugin must normalize/clamp its own config (the reference plugin does exactly that in `normalizeConfig`, see §5). 2. **A matching override replaces the complete config**, so the user's patch layer must restate every field a row needs (patch dialect above). Live Config surface: `host/Config.listConfigs` returned **204 entries** with per-row status `tree` / `inactive` / `schema` / `absent` and `patchId` separate from `id` ([host-Config.listConfigs.p1.txt](evidence/dsh/host-Config.listConfigs.p1.txt)). Status `absent` means the package publishes no Config; `schema` means a validated schema exists. Querying one entry's exact JSON Schema requires the blocked `input` path (see §0) — **UNVERIFIED** for the reference plugin, but its `apply` normalizes config defensively anyway. ### 1.5 What `ctx.effect` guarantees (and what it does not) Contract, from `vendor/cordis/src/fiber.ts`: - An effect body returns **one disposer, a promise of one, or a (async) iterable of several**; generator effects register each yielded disposer as it is produced (`fiber.ts:66-88`, `:340-400`). - Disposers run **in reverse registration order** when the owning fiber unloads, and the fiber awaits them (`fiber.ts:66-72`, `:694-730`). A plugin fiber's disposer awaits its own `inertia` before reporting disposal complete (`fiber.ts:265-297`). - Ownership is the **caller's context**: `tools.register`, `ctx.provide`, `locale.register`, `slots.register`, `webServer.register`, `storageDomain` mounts all return the disposer for that registration; `ctx.provide(name, service)` returns the disposer (`ctx.provide` usage in the reference plugin, §5). `slots.register` is deliberately a prototype method so the Cordis service proxy binds `this.ctx` to the **caller's** fiber at call time — that is what routes the effect into the right plugin (`packages/client/ui-renderer/src/client/registry.ts:166-207`). **Process shutdown is bounded and racy for async work.** The CLI wraps the whole tree in a `createProcessShutdown(dispose)` with a 5 s grace, SIGTERM → exit 0, SIGINT → exit 130 (`upstream/deepseek-harness/apps/cli/src/process-shutdown.ts:1-76`, `apps/cli/src/profile-boot.ts:270-310`). Measured on the isolated profile ([fixture log](evidence/dsh/experiment/fixture-events.log), trials in [fixture-events-1..6.log](evidence/dsh/experiment/)): - a **synchronous** disposer ran in **6/6** trials (`sync-probe-disposed`); - an **async** disposer (50 ms `await`) started in 6/6 trials but completed in only **1/6**; the process exited 9–10 ms after the start in the other five. Rule for the Excalidraw plugin: **do not put durable work behind an async disposer at process exit.** Flush/flush-on-write inside the tool call or the HTTP handler; keep disposers for closing handles (`store.close()`, timers, sockets) and accept that shutdown may cut them off. The reference plugin follows this pattern (sync `store.close()` in an effect; `await scheduler.stop()` in another) — see §5. **UNVERIFIED:** fiber disposal on a *live* row disable/restart in the running app (would require touching the live profile). --- ## 2. Host tool registration contract ### 2.1 Exact `ToolDefinition` (required vs optional) From `upstream/deepseek-harness/packages/core/tools/src/contracts.ts:26-101` — `ToolDefinition extends ToolSchema`, so `name`, `description`, `parameters` come from `ToolSchema`: | Field | Required | Contract | |---|---|---| | `name` | yes | registry key; duplicate within a scope throws; `run_code` is reserved and cannot be registered or shadowed (`packages/core/tools/src/index.ts:139-147`) | | `description` | yes | model-facing | | `parameters` | yes | **raw JSON Schema**, projected to the model as a lossless-JSON snapshot (name, description, parameters, plus `deferLoading` when true) — `packages/core/tools/src/schema.ts:641-650`; a non-lossless value throws at projection time. The registry validates it only against the *supported subset* (`assertSupportedJsonSchema`), **not** against object-rootedness — the object root is a model-gateway requirement that every shipped tool satisfies (live list confirms) | | `output` | **yes** | `{ schema, render(args, value): ContentBlock[], presentationMeta?(args, value): JsonValue }` (`contracts.ts:20-31`). Missing/malformed throws `must declare output { schema, render, presentationMeta? }` (`index.ts:132-137`) | | `execute(args, exec)` | yes | returns the canonical lossless-JSON value declared by `output.schema`; must observe/forward `exec.signal` | | `projectContent?` | no | install execution-prepared content before `tools/post-execute` | | `finalizeContent?` | no | synchronous last-mile model-content transform; must be total and not throw | | `timeoutMs?` | no | cooperative budget, **never** sent to the model; declaring asserts the tool forwards `exec.signal` | | `isConcurrencySafe?` | no | pure sync classifier; only literal `true` opts into a parallel group | | `presentCall?` | no | pure pending-state UI card from `args` (replay-safe, may run on old args) | | `presentResult?` | no | pure completed-state UI card from `args` + durable result | The registry additionally validates `output.schema` with `assertSupportedJsonSchema` (`packages/core/tools/src/json-schema.ts:385-389`) and `timeoutMs` positivity (`index.ts:138-147`). `assertObjectJsonSchema` (object-root enforcement, `json-schema.ts:397-405`) is used for **subagent/workflow structured output**, not for tool `output.schema`. `register()` returns the **exact disposer** that unregisters (`index.ts:122-153`), and can take `{ when: (ctx) => boolean | Promise }` for step-time activation (`contracts.ts:115-127`). ### 2.2 Two ways to author a tool (do not mix) 1. **`defineTool`** (`packages/core/tools/src/schema.ts:555-633`) — typed authoring DSL. It compiles the compact parameter map into JSON Schema (`:578-579`), compiles output schema (`:579`), validates arguments on every `execute` and throws `ToolArgsError` (`:598-602`), and project/presentation callbacks that throw or fail validation fall back to `undefined`. Parameter semantics: the map is an **implicit open object root**; requiredness is `required: true` per property; nested objects must state `additionalProperties`; `type: 'json'` is author-only unconstrained JSON (`schema.ts:101-115`). 2. **Hand-written `ToolDefinition`** — what a plain-JS plugin does (the reference plugin, §5). The registry only checks `output` shape and the output schema; **it does not validate `parameters` or incoming args for you**, so the plugin compiles its own JSON Schema and validates arguments itself. ### 2.3 Scoping - Registered on a plugin's root context → **global**: visible to every agent whose preset admits plugin tools. Registered on an `agent.ctx` (from an `agent/created` listener) → **scoped**: that layer shadows globals, and `ScopeKey` chains intersect (`index.ts:199-260`). - `tools.restrict({allow?, deny?})` only filters **global** tools and only from a **scoped** context — a global restriction throws (`index.ts:162-190`). It cannot name `run_code`. - `tools.guard((execution) => reason | undefined)` is the synchronous, order-independent denial surface: guards have no "allow" result, so ordering can never turn a denial back into permission (`contracts.ts:383-391`). - A denial that must *await* something belongs in `tools/pre-execute` returning `ask` (installed skill reference `references/practices.md:17`). - Human approval: `ctx.approval.request(req)` with `{agent, toolName, callId?, reason?, signal?}` → `'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'` (`packages/interaction/user-approval/src/types.ts:32`, `src/index.ts:140-230`); the ask requires an **open turn** because the audit pair is logged. The reference plugin instead uses an in-band `confirm: true` argument + `dryRun` — cheaper, but not a host-enforced permission. ### 2.4 Live confirmation `host/Tool.listTools` returned the live tool surface, including the reference plugin's 12 tools **exactly as its own compiler produced them** — object-rooted JSON Schema, `required` array, no `additionalProperties` (e.g. `scale_commander_preview` with `required: ["account_id"]`) ([host-Tool.listTools.txt](evidence/dsh/host-Tool.listTools.txt)). That is direct evidence that the published contract is "raw JSON Schema in, render function out", and that a plain-JS plugin can satisfy it without `defineTool`. **UNVERIFIED:** live `output.schema` enforcement per tool (would require calling a tool with an invalid canonical value), and live guard/approval behaviour for these tools. --- ## 3. The client half ### 3.1 Manifest: `dsh.client` + `exports["./client"]` `dsh.client` is parsed and **validated** by `parseDshClient` (`upstream/deepseek-harness/packages/client/modules/src/client/manifest.ts:165-190`): | Field | Required | Meaning | |---|---|---| | `platform` | **yes**, string | only `"web"` is composed into the browser graph (`packages/client/modules/src/index.ts:903`) | | `inject` | no, `string[]` | **package names** whose factories must arrive before this row materializes, and Cordis package edges for composition (`manifest.ts:44-65`) | | `external` | no, `string[]` | exact non-inject module specifiers this row requests | | `immediately` | no, boolean | stage-one prefetch tier: load the script during module-face boot | The bundle file is `package.json` `exports["./client"]` — a string or `{ default: string }`; declaring `dsh.client` without it is a composition error (`packages/client/modules/src/index.ts:201-213`, `:908-910`). The **registration id must equal the package name**: the loader addresses the browser module by it and the graph row is keyed on it (reference `build.mjs:100-113`). The reference plugin's manifest is a valid example: `/Volumes/ZHITAI/ScaleInsights插件开发/package/package.json:29-45` → `{"plugin": true, "kind": "server", "bundle": {"patch": "./cordis.patch.yml"}, "client": {"platform": "web", "immediately": true, "inject": ["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-sidebar", "@deepseek-ai/dsh-client-ui-primitives"]}}`. ### 3.2 Delivery: `/plugins` combo scripts and the boot graph The node half scans live Loader entries, reads each package's manifest + `dsh.client` + bundle metadata, and composes `window.__DSH_BOOT__`: `{ rev, release?, entries: [{id, url, rev, inject?, immediately?, external?}], batches: [{phase:'bootstrap'|'application', url, rev, entries}] }` (`manifest.ts:44-110`). URLs are document-relative references under one prefix: - row URL: `/plugins/??/client.js&rev=` (single resource; `comboSearch`/`comboUrl`, `packages/client/modules/src/index.ts:270-286`); - batched combo: `/plugins/??/client.js,/client.js&rev=` — several plugins' bundles concatenated into **one** `text/javascript; charset=utf-8` response (`:840-860`); - package-local chunk: `/plugins//client..js?rev=` (`:290-294`, regex `:198-199`); - package asset: `/plugins//assets/` (`:1244-1255`, regex `:236-240`); - `rev` is a 12-hex hash of the bundle artifact's mtime/ctime/size (`:219-224`); responses carry an immutable cache header. The route is registered by the client-modules node half as `{ kind: 'prefix', path: '/plugins' }` (`index.ts:703`). Anything not an exact advertised URL, current revision, or existing chunk is a **404** (`index.ts:1256-1269`) — verified live: `/plugins/dsh-scale-commander/client.js` with guessed `rev` values → 404 ([live-http-probes.txt](evidence/dsh/live-http-probes.txt)). **Every row activates at page boot.** `bootClient` creates one Loader entry per manifest row, imports/activates all of them and awaits quiescence; an entry whose module cannot be imported is marked failed and **fails startup** (`upstream/deepseek-harness/packages/client/web/src/boot-client.ts:36-60`). So a plugin's entry script is fetched, its factory executed, and its plugin `apply` run on every page load — `immediately` only decides whether that happens in the stage-one prefetch or slightly later. Deferring heavy code therefore requires a package-local **chunk**, not a manifest flag (§7 item 2). ### 3.3 Bundle format: `window.__ModuleLoader__.load({id, factory})` ```js window.__ModuleLoader__.load({ id: 'dsh-excalidraw', // === package name === graph row id factory(require) { // runs once, at materialization const React = require('react') // module table // ... bundle body ... return { apply, inject } // the Cordis client plugin }, }) ``` Exact types: `ClientBundleRegistration {id, chunk?, factory(require: ClientBundleRequire)}` and `ClientBundleRequire` = synchronous `(specifier) => unknown` + `async(specifier)` for package-local chunks + `asset(reference)` for package assets (`manifest.ts:349-376`). The facade is **queue before boot, live after**: `ClientModuleLoaderTarget {mode:'queue'|'live', pendingQueue, load(reg), create(options)}` (`manifest.ts:397-408`); the module system drains the queue when the shell creates it (`packages/client/modules/src/client/system.ts:160-175`). The model is **lazy CJS** (documented at `manifest.ts:9-27`): - executing a bundle script only **registers** its factory; all module side effects (including CSS injection) live inside the factory closure and run at **materialization**, not script execution; - materialization happens on first `import`/`require` and is memoized in `loadCache`; a factory requiring another registered module materializes it recursively; - resolution order: seed word → memoized record → graph row (register dependency factories, then own factory) → registered factory → **throw** (the runtime mirror of the build-time bundle purity gate). Loading is async, so a dynamically requested package must have registered its factory before a consumer materializes. ### 3.4 The module table is exactly 12 specifiers `packages/client/web/src/seed.ts:26-42` is the complete, frozen platform table: ``` react, react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis, zod, @tanstack/react-virtual, @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit ``` Consequences for the Excalidraw plugin: - React (and JSX runtime) comes from the page; **never** bundle a second React copy. esbuild must externalize `react`, `react/jsx-runtime`, `react-dom`, `react-dom/client` (reference `build.mjs:78-95`) and resolve them through the `require` handed to the factory. - Everything else (Excalidraw core, its dependencies) must be **inside the bundle** — there is no `node_modules` in the page and no CDN. - The installed skill reference explicitly warns **not to require Harness Client packages** as modules (`references/practices.md:35`) even though some appear in the table: they change without notice and a throwing component blanks the slot entry. `dsh.client.inject` entries only order activation. - `dsh.client.external` is for a *graph row* specifier (another client plugin's package), not for arbitrary npm packages. ### 3.5 Slot registration API `ctx.slots.register(options, Component)` returns the disposer; the options shape is `name` (target slot) plus kind-specific fields, `locale`, `inject`, `children`, `store`, `registrant` (`packages/client/ui-slots/src/index.ts:762-845`, register at `:1207`): - `keyed` slots (`main`): `key` + optional `priority`; - `list` slots (`sidebar.panellist`, `conversation.view`, `conversation.composer.dock`): `id`, optional `order`, `label` (string or `() => string`), `priority`; - `chain`: `select` + `priority`; `single`: `priority` only; - `locale: ''` puts the framework's `t` on the component props and **requires an installed locale face** (a missing dictionary fails loud); - `inject`/`children`/`store` bind shared props/state to the entry. Recommended pattern — and what the reference plugin does — is to register **behind a declaration**: ```js ctx.slots.inject('main', () => ctx.slots.register({ name:'main', key: PANEL_ID, locale: NS, inject: () => ({ toasts }) }, Page)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name:'sidebar.panellist', id: PANEL_ID, order: 20, locale: NS, label: () => t('panel') }, Icon)) ``` `slots.inject(key, cb)` installs an effect for each **declaration lifetime** of the slot: the callback runs when the declaration exists (synchronously if it already does), is disposed when the declaration collapses, and runs again on re-declaration; the whole controller belongs to the caller's fiber (`packages/client/ui-renderer/src/client/registry.ts:209-260`). Registering directly (without `inject`) also works while the slot is already declared, but loses that re-declaration behaviour (the reference uses `inject` for both entries). Slot facts for our three targets: | Slot | Kind | Scope | Declared by | Notes | |---|---|---|---|---| | `main` | `keyed` | `root` | `packages/client/ui-layout/src/client/index.ts:73` (declaration at `:182-183`) | central panel selected by sidebar entry id; key `conversation` is reserved for the Conversation. `retainMainPanels` keeps the registered keys in the live layout store so navigation does not drop a selection (`ui-layout/src/client/index.ts:167-175`); that store is explicitly **transient** (`ui-layout/src/client/stores.ts:12-17`), i.e. not a persistence mechanism | | `sidebar.panellist` | `list` | `root` | `packages/client/ui-sidebar/src/client/contract/slots.ts:36` | "Each list id addresses the matching main panel; the sidebar owns the button and resolves its label from list metadata." So **entry `id` must equal the `main` `key`** | | `conversation.view` | `list` | `session` | `packages/client/ui-conversation/src/client/contract/slots.ts:278` | a Conversation target view (tab), session-scoped props | | `conversation.composer.dock` | `list` | `session` | `packages/client/ui-conversation/src/client/contract/slots.ts:294` | ambient rows below the composer card; the least invasive surface for a small panel | Navigation from plugin code: `ctx.layout.selectPanel(id | null)` throws if no `main` panel with that key is registered (`packages/client/ui-layout/src/client/service.ts:71-78`). ### 3.6 Styles, locale, and what disposal actually removes - **Styles:** register inside `apply` through `ctx.effect` and remove the `