The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.
So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).
The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.
- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
`launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
own UI as nothing but "Decode error"
@punktfunk/plugin-kit
The Effect-based framework punktfunk plugins are built on. It owns everything that is the
same in every plugin — lifecycle, config/state, the sync engine, UI serving, the CLI
scaffold, logging — so a plugin is just its domain logic, its HttpApi contract, and its UI.
The reference consumer (and the blueprint to copy) is
punktfunk-plugin-rom-manager.
Built on @punktfunk/host (the SDK stays the low-level host client; the kit is
the opinionated plugin layer on top). Effect 4.x and the SDK are peer dependencies —
the plugin's own copies are the only copies.
The one rule: async at the boundary, Effect inside
The packaged runner bundles its own effect + SDK; a plugin's imports resolve to the
plugin's node_modules. Effect values must therefore never cross the plugin boundary
(Context.Tag identity is per-instance). definePluginKit enforces this by construction:
you write Effect, it exports a plain async-main PluginDef, and a ManagedRuntime
built from your effect instance runs everything. SIGINT/SIGTERM interrupt the plugin
fiber (scoped finalizers run: UI deregistration, watcher close), bounded by
shutdownGraceMs.
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
import { Effect, Layer } from "effect";
export default definePluginKit({
name: "my-plugin",
version: "0.1.0",
layer: MyServices.layer, // over the kit base: HostClient | PluginInfo
main: Effect.gen(function* () {
const engine = yield* MySync;
yield* engine.start;
yield* serveUi({ title: "My Plugin", icon: "puzzle", staticDir, api: MyApiLive });
yield* Effect.never;
}),
});
Modules
| Export | What it owns |
|---|---|
definePluginKit / runPluginKitDirect |
the async-main boundary + ManagedRuntime + signal handling |
HostClient, PluginInfo |
the pf facade as services (request = the skew-safe untyped seam) |
makeConfigService |
Schema-driven config: raw shape on disk, defaults ONLY in the Schema (withDecodingDefaultKey + encodingStrategy: "omit"), atomic writes, world-writable refusal, changes stream |
makeCacheStore |
disposable derived state (corrupt/absent → empty, write-through) |
ProviderClient + wire schemas |
typed library-provider reconcile over the untyped wire — including the optional detect hint (see below) |
makeSyncEngine |
poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
serveUi / httpApiEnv |
an effect/unstable/httpapi HttpApi behind the SDK's servePluginUi, core-only layers |
sseRoute |
the status SSE endpoint (httpapi has no event-stream media type) |
runPluginCli |
<bin> <command> dispatcher reusing the plugin's layer graph (deliberately not effect/unstable/cli — that would drag platform packages into every plugin) |
loggingLayer |
runner-journal line format |
@punktfunk/plugin-kit/react |
browser glue: createPluginRouter (path→hash→fallback deep-link restore + pf-ui:navigate), resolvePluginBase, useIsEmbedded, ResultGate, sseAtom |
@punktfunk/plugin-kit/theme.css |
the console's violet identity for plugin UIs (import first in your Tailwind entry) |
@punktfunk/plugin-kit/library |
everything a game-library scanner plugin needs — see below |
Library-scanner plugins (@punktfunk/plugin-kit/library)
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in their own repo, like every other punktfunk plugin. Nothing is lost by that split because everything they share is published here rather than sitting adjacent to them:
| Export | What it saves you writing |
|---|---|
defineLibraryPlugin |
the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, __config, category: "library" registration, and the detect / scan / parity / uninstall CLI verbs |
parsers/* |
text VDF + .acf, binary shortcuts.vdf (with the CRC-32 appid and the 64-bit rungameid composition), read-only SQLite, reg.exe, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
diffParity + the parity verb |
the acceptance gate below |
A first-party scanner is therefore its parsers and a scan function — a few hundred lines.
The parity gate
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
A plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks every Moonlight
pin on the host, and no parser test notices. So, on a box with that launcher installed:
# 1. while the host is still using its BUILT-IN scanner:
punktfunk-plugin-steam parity --snapshot before.json
# 2. offline — runs this plugin's own scan and diffs:
punktfunk-plugin-steam parity --compare before.json
--compare exits non-zero on any difference, so it works as a release gate. It compares ids,
titles, launch recipes, roles and metadata exactly; art by presence, not value (the
representation legitimately changes — a host-relative proxy path or inlined data: URL becomes a
file:// path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
adds are reported separately rather than failing the run; an ordinary title the scanner never had
still fails.
Telling the host how to recognize a running title (detect)
A ProviderEntry may carry an optional detect hint:
{ external_id: "playnite:9f2…", title: "Hades",
launch: { kind: "command", value: "playnite://playnite/start/9f2…" },
detect: { install_dir: "D:\\Games\\Hades" } }
It is what lets the host tell that the game has exited — which ends the streaming session, so the player's client returns to its library instead of showing a bare desktop — and what lets an operator who opted into it end the game when the session ends.
Omit it and nothing breaks: the host tracks the process it spawns for your launch command. It matters
when that command hands off and exits — a launcher client, flatpak run, a front-end that starts
an emulator — because then there is nothing left for the host to watch, and both behaviors go quiet
for that title. Send whatever you genuinely know; install_dir is the one to send if you send only
one, since any process running from under it counts as the game. The host never lets a hint override
what it worked out itself, and never adopts a process that was already running before the launch.
Publishing
Tag plugin-kit-vX.Y.Z (matching package.json) — .gitea/workflows/plugin-kit-publish.yml
typechecks, tests, builds, and publishes to the Gitea registry.