# AGENTS.md — dsh-boot-animation Maintainer briefing. Written for whoever (or whatever) picks this up next: what the package is, which two facts about DSH it depends on, the invariants that break it silently, and how to prove a change is safe. > **Porting note (DSH 0.2.0-rc.2).** The Host settings contract changed: a plugin > no longer *registers* a settings namespace, it *declares* `Config`. Everything > below reflects that. The wide `verify-*` set the README names belongs to the > author's working copy; what actually ships here is the four suites under > [`verify/`](verify/) — see [Verifying a change](#verifying-a-change). Human-facing usage lives in [MANUAL.md](MANUAL.md). The long forensic record — every bug, why it happened, what the evidence was — is [docs/DEVELOPMENT_NOTES.md](docs/DEVELOPMENT_NOTES.md). Read this file first; go to the archived development notes only for the history of a specific symptom. ## What it is A DSH plugin that replaces the kernel's boot page with a full-window video clip, then dissolves into the app. No DSH source is modified: it injects a script into `
` ahead of the shell, and contributes a card to Settings → Plugins. | Half | File | Job | |---|---|---| | Host (Node) | `entry.js` | Serves clips over HTTP with Range, declares the `Config` schema the settings namespace is derived from, injects the pre-boot screen into the index | | Host custom media | `src/custom-clips.js` | Authenticated streaming import/read/removal, with persistent per-profile files outside the npm package | | Browser (pre-boot) | `src/boot-screen.js` | The overlay itself: framework-free, injected as text, must run before the shell | | Browser (plugin) | `src/client.js` → `lib/client.js` | Reports activation to the overlay, and renders the settings card | ### The two DSH facts everything rests on 1. **`webserver/index-inject` is the only moment early enough.** Rows pushed into that table render into ``, ahead of the shell module. Anything later cannot pre-empt the boot page. `entry.js` subscribes to it. 2. **A settings namespace is a plugin entry, and a field is served only if it is volatile.** The Host declares `Config` on the module namespace object; the entry id in the profile patch (`- insert: id: boot-animation`) *is* the namespace; the browser card reaches that same id through the client `configForms` service. `@deepseek-ai/dsh-settings` describes only an entry whose `Config` projects a non-empty form, and that projection keeps only fields marked `.volatile()` — so a schema with no volatile field yields no namespace, no card, and no error anywhere. `src/client.js` holds the other end of the pairing in `SETTINGS_NAMESPACE`, and nothing in the harness checks that the two strings agree. ### What `.volatile()` costs on the Host side A volatile field does not arrive in `apply(ctx, config)` as a value: it arrives as a stable reference (`{ get() }`) whose snapshot the loader commits in place, so `entry.js` reads every field through `readField()` rather than directly. That is also why a settings edit needs no restart — when only volatile values change, `@deepseek-ai/cordis-plugin-loader` calls `updateVolatile` on the existing reference and emits `loader/volatile-update` instead of restarting the fiber, so the next index render already sees the stored value. ## Rules that were each learned the hard way Every line here cost a real debugging session. They are not style preferences. | Rule | What happens if it is broken | |---|---| | **Never read/write a CJK-bearing file with PowerShell** — source, data or the user's config alike. Use the file tools; verify with Node `readFileSync(..., 'utf8')`. | PowerShell 5.1 decodes BOM-less UTF-8 as GBK, and a `Set-Content` round-trip destroys every Chinese character — once collapsed `'…'` into `鈥?` and broke the script. The same rule covers files this package does not own: `cordis.patch.yml` carries a provider `displayName`, and the `Get-Content`/`Set-Content` pair in `tools/uninstall.ps1` turned it into mojibake and added a BOM. When a `.ps1` genuinely must rewrite such a file, go through .NET — `[System.IO.File]::ReadAllText($p, $utf8NoBom)` / `WriteAllText($p, $text, $utf8NoBom)` with `New-Object System.Text.UTF8Encoding($false)` — because on 5.1 `Set-Content -Encoding utf8` means *UTF-8 with BOM*, which is still a change to a file that is not the script's to change. **Not `ReadAllLines`/`WriteAllLines`:** `WriteAllLines` terminates every line with `[Environment]::NewLine`, so an LF layer came back all-CRLF — a whole-file rewrite reported as a byte-identical round-trip. | | **A step that only checks must not repair.** | `tools/install.ps1`'s last step guesses which port dsh is on; when the guess missed it reported FAILED and rolled back a fully working install. A check that cannot tell "broken" from "not measured" has to say which one it is and leave the state alone. | | **No backtick anywhere inside a CSS blob** (the screen's `style()` string, the card's `CSS` string). | The string ends early and the build keeps the *previous* artifact. It looks like the change had no effect. | | **An attribute selector with a numeric value must quote it** — `[data-hint="0"]`, never `[data-hint=0]`. | An unquoted value must be a CSS identifier, and one starting with a digit is not, so Chrome drops the ENTIRE rule without a word. The switch looks wired and does nothing. `[data-shown=1]` and both `[data-attention=1]` rules in this package had never applied (only the inline `style.opacity` in `reveal()` hid it). `verify/verify-options.mjs` asserts it with a regex, comments stripped first. | | **A scratch-copy suite must copy with `dereference: true`.** | Installed, `node_modules/@deepseek-ai/schemastery` is a symlink; `cpSync(..., { recursive: true })` preserves it, so the scratch tree points back at the real library. `verify-degraded.mjs`'s "rename `.volatile()` away" then rewrote the INSTALLED schemastery and silently disarmed every settings card in the deployment. The suites now copy real files and assert the scratch is not a link before rewriting. | | **Read optional services with `ctx.inject([...], (owner) => owner.get(name))`.** Never `ctx.get(name)`. | `ctx.get` is a one-shot read that races activation and silently answers `undefined`; the card then never registers and there is no error anywhere. Every other client plugin in the deployment uses the `ctx.inject` form. | | **The client half declares no `inject`.** | A required service leaves the fiber PENDING in a profile that lacks it, `apply` never runs, `clientReady` is never sent, and the animation refuses to hand over. | | **Every settings field the card edits must be marked `.volatile()`** — and the schema library that resolves beside this package must actually have the method. | `@deepseek-ai/dsh-settings` projects volatile fields and nothing else: a `Config` with none is not described at all, so the namespace is never served, the card's `whileServed` never fires, and the settings row is simply absent with no error. `.volatile()` exists from schemastery 3.18.4. It used to be reached through a peer dependency, which the profile's `autoInstallPeers: false` never installed — a fresh `dsh plugin add` resolved no schemastery at all and the static import threw while `entry.js` was being evaluated, killing the routes and the screen too. It is now a declared `^3.18.4` dependency; `LIVE_CAPABLE` still probes instead of trusting, because a profile `pnpm.overrides` or a hand-made link can still hand it an older copy. Cross-copy use is safe by construction: the projection reads plain data (`meta.volatile`, `dict`, `toJSON()`) and the live reference is keyed by `Symbol.for('cosmokit.volatile.write')`. | | **Read a volatile config field through `.get()`, never directly.** | `.volatile()` puts a stable reference in the applied config, not the value. Reading it directly yields an object, every type guard falls through to the defaults, and the settings page looks like it does nothing at all — while the values it writes are stored correctly. | | **Serve clips `cache-control: no-store`, always.** | The overlay aborts the media request when it replaces the element; anything cacheable stores a truncated body. The next normal reload decodes the fragment — and a clip with `moov` at the end has no index in a fragment, so it paints nothing. A hard reload hides it. `ETag`/`no-cache` does **not** fix this: an ETag only ever claims the file has not changed, never that the copy in hand is complete. | | **A clip's first frame must be evidenced by decoded data** (`readyState >= 2`, `loadeddata`, `canplay`, advancing time) — **not** by `requestVideoFrameCallback` alone. | The element starts at `opacity: 0` and reveal is what makes it visible, so waiting for a *composited* frame waits on the style change it is itself blocking. The screen locks on the gradient. | | **A resolved `play()` promise is not a picture.** Keep the 6-second retry armed until a frame is really there. | A clip that starts and never paints leaves the gradient up forever, with no error and no next attempt. | | **`faststart.mjs` must assert the RESULT** — that the output has `moov` before `mdat`. | It was written checking only the *plan* and its own payload proof, both of which are vacuously true for a file that did not move. It reported success for two sessions while writing byte-identical copies, so "I remuxed it" changed nothing. | | **Strip comments before any regex assertion on an artifact.** | The bundles are comment-preserving concatenations, so prose explaining an old approach is matched as if it were the approach. This happened twice. | | **The exit's audio fade must reuse the exit's own `span`** — plus `AUDIO_GRACE_MS` only for a clip that is detached and still playing, whose stop deadline stays `span + 1300`. | A fixed fade duration drifts away from a tail dissolve, whose span is the clip's remaining time; and handing the grace to the tail path leaves the track audible after the clip itself has ended. | | **Do not hardcode a user asset's name in a test.** Read it from the manifest. | The clips get renamed; the suite then fails for a reason that has nothing to do with the code. | | **Do not `spawnSync` with piped stdio.** Import the module and call the function. | The agent sandbox refuses a child process whose output is captured through a pipe (`EPERM`). `stdio: 'inherit'` works, which is how the author's aggregator spawns its children. | | **Suites must be runnable together** — no interactive prompts, no fixed ports. | A suite that only passes alone is a defect in the suite. | | **A PowerShell statement that ends in `+` does not continue onto the next line.** | `$x = 'a' + $y` is already complete, so a following line starting with `+` is parsed as a *unary* plus, throws at runtime, and — under `$ErrorActionPreference = 'Continue'` — the throw is swallowed. The value is then half-built and nothing says so: a split regex assembled this way matched the wrong block and returned a plausible count. One complete statement per line. | | **The install/uninstall pair edits the patch layer as BYTES, and accounts for them before writing.** | `ReadAllLines`/`WriteAllLines` normalises every terminator, so "the row was removed" came with a whole-file rewrite. `ReadAllText`/`WriteAllText` keeps every other byte as it was; the byte total is asserted before the write, and the one case that cannot round-trip (a layer with no final newline) is stated rather than claimed away. | ## Change → restart matrix | Changed | To see it | |---|---| | `src/boot-screen.js` | **Reload the page.** The Host re-reads this file on every index render, so a plain F5 is enough and no restart is needed. | | `entry.js` (routes, settings schema, injected rows) | **Restart DSH.** The running process holds the old module. | | `src/client.js` | Rebuild (`node build-client.mjs`), then reload; if the change does not appear, restart — the initial bundle revision is allocated per process. | | `assets/videos/*` | Reload. The manifest is read per request and clip URLs carry a content revision. | Installed state: there are two routes, and they leave different footprints. **The package route (the one users take).** `dsh plugin --profile web add