[0.0.23] - 2026-07-28
Added
userSettingscan declaretype: slot. Whether the frontend rendered a model-slot picker used to depend on the setting being literally namedmodelPresetId— an undocumented convention discoverable only by reading framework source, and one that produced a picker on the prep screen but a free-text box under Settings → Plugins for the same setting. Discovery now keys off the declared type, so the setting can be called anything. The value stays a bare string rather than an enum, so a slot added after boot is still accepted.- Colliding
userSettingsacross a plugin's runtimes are caught at load. The storage key is plugin-scoped (plugin.<id>.<key>) while the declaration sits on a runtime manifest, so two runtimes of one plugin could declare the same key and silently disagree. Identical declarations now dedupe quietly; diverging ones warn and keep the first.pnpm validate:pluginon a plugin directory runs the same cross-runtime check.
Removed
relationsentries are plain plugin-id strings; the object form is gone. It had accumulated four interchangeable ways to spell one plugin id ({plugin},{runtime},{target: id},{target: {plugin}}) alongside the bare string every bundled plugin actually used, plus three fields nothing read:typerestated the array the entry already sat in, andoptional/reasonhad no consumer at all (optional: trueunderrequireswasrecommendsspelled differently). Dependency rationale belongs in a YAML comment above the entry — which is what the bundled plugins were already doing. A third-party manifest using the object form now fails to load with a named migration hint rather than resolving to something unintended.relationsno longer acceptscapabilityortagtargets. Both parsed cleanly — they were in the schema, the types, and the reference docs — and were then ignored forever, because relations resolve throughrelationPluginId(), which only ever handled plugin ids. The repo's own schema fixture demonstrated the trap: it declaredrequires: [{ capability: "scene-cast" }]wherescene-castis a plugin id, not a capability, so the author meant a dependency and wrote a form that could never deliver one. A documented option that never works is worse than no option. Manifests still using them now fail to load with a migration hint instead of losing the dependency at runtime. Capability-based scheduling dependencies are unaffected —needs/afterare a separate mechanism.
Changed
- Working memory renders after the plugin instructions instead of before them. It is the one system-prompt segment that changes every turn, and sitting between the framework preamble and the instructions it invalidated everything downstream: the instructions are usually the largest block in the prompt and were re-billed every single turn purely because a few lines of memory ahead of them had changed. Both caching models paid this — an explicit
cache_controlsegment is only reusable when its whole body is byte-identical, and an automatic prefix cache stops at the first differing byte. The instructions now own a cacheable segment by themselves, and working memory trails the final breakpoint as an uncached tail. Segment numbering is unchanged (it is the segment's identity); only render order moved. Placing it last also puts the turn's freshest state nearest the conversation, where a model weighs it most heavily. This changes what every runtime sees and in what order, so it warrants a real-model check before release — the mechanical behaviour is covered by tests, prompt quality is not.
Fixed
-
A background job orphaned by a restart is reported instead of spinning forever. Background runtimes (scene backdrops, generated illustrations, TTS) run in-process, so killing the process kills the callback while its
_jobsrow stayspending— the panel showed "generating…" with nothing behind it, permanently and silently. The boot sweep meant to clean this up decided by age, and its predicate read the row's enqueue time, so it could not tell a corpse from a job legitimately still running; raising the threshold only traded false kills for longer spinners, which is why an earlier attempt to run the sweep periodically was reverted. Liveness is now answered by ownership rather than elapsed time: each pending row is stamped with the id of the process that owns it, and a freshly booted process cannot own any pre-existing row. A running job is therefore never reaped by construction, no heartbeat write is needed, and an orphan is failed the moment the server comes back rather than 15 minutes later. Orphans are failed rather than re-run — re-running bills the player again for image or speech generation, and the request-scoped settings a faithful re-run would need are not stored on the row — but the trigger context is carried onto the terminal row so a retry can be offered deliberately. Note this makes the sweep exact for one process per store, which is every deployment Covel ships today; running several instances against one database would additionally need a lease, since a booting instance now reads another instance's in-flight rows as foreign rather than granting them the old grace window. -
Generating a scene backdrop no longer blocks the next message. A deferred background follower ran its entire turn inside the session lock, and for the media plugins that turn is a provider call —
background-genallows six minutes and one image genuinely takes 60-300s. Anything the player did meanwhile queued behind the artwork. Execution now runs outside the lock and only the commit takes it, so the wait drops from a whole generation to a transaction. The three reasons the lock was originally taken do not apply to this path: it passes no session clock (andcompletedPlayerTurnscounts only player-origin executions), its domain writes are buffered intofinalizeExecution's single transaction rather than dribbling out mid-run, and a follower appends no turn messages. What did need the lock — the commit and the post-commit snapshot, which must observe a settled session — still holds it. Two guarantees are preserved explicitly: followers of the same runtime are serialised on a<sessionId>::<runtimeId>job lock, so the "already generated?" checks that prevent double-billing stay atomic; and the session status is re-read under the lock before committing, so results are discarded rather than written into a session the player paused or ended during the minutes the job was running. -
The Docker image builds again, and its
llm.tomlis actually read. The image had not built since 2026-07-21 and its slot config was a silent no-op, from three independent breaks: the per-member manifestCOPYlist went stale when three plugins landed, sopnpm install --frozen-lockfilefailed on a missing workspace importer (nowCOPY --parentsglobs, so a new workspace member is covered automatically instead of drifting again); Node 25+ no longer bundles corepack, socorepack enable pnpmexited 127 right after the Node 22 → 26 bump (corepack is installed explicitly, still resolving pnpm from the rootpackageManagerfield); and the server resolves./llm.tomlfrom its cwd/app/apps/serverwhile both theCOPYand the compose mount target/app/llm.toml, so every slot definition was ignored and the server fell back to built-in defaults (COVEL_LLM_TOMLnow points at the path the mount actually uses). -
Losing a race for the session lock returns a retryable 503 instead of a server error. Under PostgreSQL the lock acquire budget is 30 s while a background image generation legitimately holds the lock for minutes, so acting during generation hit the timeout on an entirely normal path. Nothing caught
SessionLockTimeoutError, so it reached the global handler and surfaced as500 Internal server error— indistinguishable from a real fault in logs and monitoring, and giving the client no reason to retry. It is now a503 { code: "session_busy" }, which is whatdocs/reference/api.mdand this changelog already described for manual snapshots and forks; the behaviour simply had never existed. The raw message names the session and internal module, so only a fixed string crosses the wire and the detail stays in the log. -
Saving a theme with a Chinese name no longer deletes the previous one.
slugifyThemeIdstrips every non-[a-z0-9]character, so an all-CJK name reduced to an empty string and fell back to the shared constantcustom-theme— 我的极光, 我的纸本 and 夜读模式 all produced the same id.ensureThemeIdonly de-duplicated against builtin ids, andsaveCustomThemewrites withfilter(id !== …).concat(…), so the second save silently dropped the first theme with no warning and no undo. In a Chinese-first product that is the main path, not an edge case. The fallback is now derived from the name itself, which keeps re-saving the same name an update (same id, as intended) while two different names no longer collide. -
Colour swatches announce which token they belong to. All 48 shared one hardcoded
aria-label="colour", so a screen-reader user heard the same word 48 times with no way to tell the page background from the danger accent. Both the swatch and its paired text field now carry the token's own label. The theme-name field gained an accessible name too — it had only a placeholder, which is not a reliable one and vanishes on input. -
Reading theme defaults ignores an in-flight drag preview.
readTokenDefaultslifted only the properties the override module had written, but the controls also paint directly to the element while a slider is being dragged. Inside the 200 ms commit debounce those two views disagreed, so a theme or scheme switch landing in that window could record a half-dragged preview as the theme's own default. It now lifts every adjustable token regardless of who wrote it.
What's Changed
- fix(web): appearance review follow-ups — theme id collision + a11y by @ackness in #33
- docs: sync documentation with the code by @ackness in #34
- Release v0.0.23 by @ackness in #35
Full Changelog: v0.0.22...v0.0.23