# Architecture and Pitfalls English | [中文](ARCHITECTURE.zh.md) This document combines the current source contract with measured historical incidents. Older numbered sections name the hosts investigated at the time; they are not fresh acceptance of every current host. ## 2.3.1 source contract These additions are prepared for patch `2.3.1`; they do not migrate assistant files or rewrite their format. The candidate/publication state is tracked in [current compatibility](CURRENT_COMPATIBILITY.md#231-validation). YAML quoting around `!!js` is decoded before interpretation. Known disable conditions remain native `__jsExpr` nodes for the official loader's real context; unsupported context conditions are truly disabled. The editor understands the shipped `profileContext` condition using only a request-scoped immutable boolean snapshot, isolated with AsyncLocalStorage, without retaining service objects or adding a hard dependency. Successful non-context expressions keep their prior evaluation behavior and native Boolean result. Diffing compares decoded expression sources while preserving literal-versus-expression tri-state and untouched source bytes. - `GET /api/custom-mode/state` returns an opaque `revision` over the raw bytes of `prompt.md`, `preset.yml` and `agent.cordis.yml`, including explicit missing-file state. New saves send `baseRevision`; any changed or unreadable editable field yields `conflict` before writing. Legacy numeric `baseN` still compares prompt history, independently of audit append success. A legacy request with neither token retains its overwrite semantics. - Repair sends `baseRevision`. Reorder sends a `baseRevisions` map for all loaded assistants; every supplied baseline is checked before any reorder write. Successful save/repair returns `revision`, while successful reorder returns only its own `revisions` map. These tokens are calculated from the exact intended writes before commit, without a post-commit file read. A failed persistence operation or rolled-back reorder does not authorize adopting tentative tokens; a saved-but-unsynchronized response retains the completed write's token alongside `persisted:true`. - Dirty drafts retain their original content and baseline during ordinary reads. Only a checked successful local mutation can advance its affected baseline; external reads cannot silently bless changes the draft has not incorporated. Draft memory belongs to the current plugin context/browser document, survives settings-section remounts, and is cleared on plugin disposal. It is not localStorage or a disk backup; reload/close reminders do not recover confirmed losses. - The clone uninstaller treats the host removal result as authoritative. Failure or unverifiable remaining profile declarations stop later cleanup/purge. Confirmed leftover package cleanup stays within the named package path and preserves a shared installation referenced by another profile; desktop profiles remain owned by the app. ## Inherited contract from 2.3.0 - The preset owns prompt content; the root profile bundle owns the editor. The eight exact paths in §5 stay on the carrier's authenticated shared `/api` channel. `POST /api/custom-mode/sync` retries only registration, never a save/create/delete/reorder operation. - The loop supplies model/provider. On hosts with workingDirectory, the reader provides a scoped cwd compatibility variable from the real session directory and refreshes it after host assembly/ensure. Older hosts retain their native cwd. Only the exact old standalone directory sentence is normalized at render time; no user prompt/suffix file is rewritten (§7). - Known reader-owned directories recover before discovery requires prompt.md. A prepared transaction restores one whole old generation; a committed one keeps the whole new generation. Unique legacy backup groups recover before missing user files may be filled, including in the clone installer through the same restoreBackups implementation. A live/uninspectable owner, ambiguous generations or invalid journal fails closed and preserves evidence. This is not cross-process writer coordination or a physical power-loss durability guarantee. - Disk mutation and registry synchronization share a queue; readers wait for synchronization. HTTP503 savedButSyncFailed means persisted:true, synchronized:false, with the completed operation code. Retry only synchronization. The sync-only failure's persisted:false describes that retry, not an undo of an earlier save. The backend also serializes mount/unmount/sync and retains failures for retry. - Declarative registration disposers belong to the declaring plugin scope. Its cleanup closes admission immediately, drains in-flight mutations, releases late-returning registrations through their own disposers and attempts every owned cleanup while retaining failed handles. Cordis waits for cleanup but logs its rejection rather than guaranteeing that the next generation is blocked; native success therefore does not replace checking cleanup logs and registration health. A later generation never takes ownership of another generation's preset id. - List responses expose overall `registrationSync`; state responses scope it to that assistant or an unknown/global failure, using only typed codes and bounded ids. Warnings do not imply a healthy assistant is broken. Exact registration conflicts/cleanup failures may suggest restart; malformed or missing composition does not become a restart requirement. Released 2.2.6 has no retroactive cleanup and retains its explicit first-migration restart boundary. - The page header reads the running version from the existing list response. Manual update checks use the host's optional native plugin-manager service, confirmed in `0.2.2-alpha.2`; the older checked hosts fall back to public plugin-details navigation and a guide. Query/update permissions are separate where the host exposes them. Installation, confirmation, progress and self-update lifecycle belong to the official Plugins details, with all held drafts and pending recovery guarded before navigation. No new update route, shell updater or credential handling is added; file/link/tarball sources are not converted to registry installs. The current component and platform evidence is recorded in [CURRENT_COMPATIBILITY.md](CURRENT_COMPATIBILITY.md). Historical API examples below remain useful for understanding the design, with these current contracts taking precedence. ## 1. Why the settings page must be split into a standalone plugin and cannot be written as one row of a preset This is the single most important constraint in the whole project. In an agent preset's composition file (`agent.cordis.yml`), a row **may** reference a bundled module: ```yaml - id: persona name: './prompt-reader.mjs' ``` That works because a preset's relative paths are resolved against the preset directory (`dsh-agent-presets`'s `classifyRowSpecifier` classifies `./x` as the `preset` type, and imports it through the `Include` subtree whose baseUrl has been rewritten). **But the browser half does not.** The web client's module scanner (`@deepseek-ai/dsh-client-modules`): - runs during **startup assembly** — in its constructor, `for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)`; - reads only the **root loader's entries**; - on a miss it does `throw new ClientPackageCompositionError`, and **the entire boot fails**. Meanwhile, an agent preset's rows are mounted by `dsh-agent-presets` through the `Include` subtree's `PresetTree`, **scoped per session at runtime**, and are **not in the root loader**. Measured data (queried in a real run): ``` root loader entries: 161 client graph entries: 56 ← none of them are preset rows ``` So a browser half written inside a preset is **never discovered** and only becomes dead code. The only plane that can mount a browser half is the **root plane** (the profile). **Conclusion**: split into two artifacts — a preset (files) + a settings-page plugin (profile bundle). ## 2. How a bundle plugin gets assembled The settings-page plugin is a **bundle**: `package.json` declares `dsh.bundle.patch`, and the profile's `dsh.profile.bundles` lists it. Composition order (`composeProfile`): ``` bundlePatches → profile.cordis.patch.yml → $DSH_HOME/cordis.patch.yml → --patch overlays ``` Two easy-to-get-wrong points: - **A bundle patch adds rows with `- insert:`**, whereas **the profile's own `cordis.patch.yml` can only replace rows that already exist** (adding one reports `entry not found`). In the first version the new rows were written into the profile patch and were rejected outright. - Package resolution first tries `INSTALL_ANCHOR` (the dsh install directory), and on failure **falls back to the profile directory**: ```js for (const anchor of [installAnchor, join(profileDir, "package.json")]) { … } ``` So a package installed into the profile by `dsh plugin --profile
add .` can be resolved, without needing to be installed into the dsh install directory.
## 3. `dsh.client.inject` must be declared (this bug kept the page from appearing at first)
The browser half needs to read a service:
```js
const slots = ctx.get("slots")
```
**That alone does not work.** The client registry guards service reads, and every official settings plugin additionally exports:
```js
exports.apply = apply
exports.inject = inject // inject = ["slots"]
```
The symptom is subtle: the client graph **has an entry, with the correct rev, and the bundle is served**, yet the page simply never appears — because `apply` was rejected by the guard and registration never happened. Adding `exports.inject` fixed it immediately.
## 4. The browser half's hand-written bundle
This package's `client.js` is **hand-written**, not a build artifact. Format:
```js
window.__ModuleLoader__.load({
id: "dsh-custom-mode",
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
const react = require("react") // ← resolved from the "seed module table"
function apply(ctx) { … }
exports.apply = apply
exports.inject = ["slots"]
return module.exports
},
})
```
The seed module table is provided by the web frontend; in 0.1.6 it contains:
```
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
```
So `require("react")` works, **with no bundler and no installed dependencies needed**.
The whole `load()` call is wrapped in `try/catch`, so this package cannot crash the page — the worst case is that this one page does not appear.
## 5. Talking to the host: a private HTTP route
The browser half has no `host.call` (that is the channel reserved for dynamic Cordis packages); a statically loaded plugin goes through `ctx.remote. --dump-config | grep