summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/plugin-host.svelte.ts134
-rw-r--r--src/app/store.svelte.ts118
-rw-r--r--src/core/plugin-abi/driver.test.ts207
-rw-r--r--src/core/plugin-abi/driver.ts140
-rw-r--r--src/core/plugin-abi/index.ts27
-rw-r--r--src/core/plugin-abi/load-diff.test.ts167
-rw-r--r--src/core/plugin-abi/load-diff.ts137
-rw-r--r--src/core/plugin-abi/types.ts95
-rw-r--r--src/core/plugin-abi/version.test.ts54
-rw-r--r--src/core/plugin-abi/version.ts80
-rw-r--r--src/features/layout/index.ts2
-rw-r--r--src/features/layout/logic/registry.test.ts68
-rw-r--r--src/features/layout/logic/registry.ts32
-rw-r--r--src/features/plugins/index.ts7
-rw-r--r--src/features/plugins/logic/types.ts7
-rw-r--r--src/features/plugins/logic/view-model.test.ts71
-rw-r--r--src/features/plugins/logic/view-model.ts29
-rw-r--r--src/features/plugins/store.svelte.ts20
-rw-r--r--src/features/plugins/ui/PluginManager.svelte10
-rw-r--r--src/features/plugins/ui/PluginRow.svelte4
-rw-r--r--src/plugin-runtime/svelte-api.ts6
-rw-r--r--src/plugin-runtime/svelte-internal-client.ts12
22 files changed, 1409 insertions, 18 deletions
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts
new file mode 100644
index 0000000..9e53bc1
--- /dev/null
+++ b/src/app/plugin-host.svelte.ts
@@ -0,0 +1,134 @@
+/**
+ * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue
+ * between the ABI driver (`core/plugin-abi`), the view registry, the
+ * surfaces ports, and the plugins HTTP edge.
+ *
+ * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app
+ * store calls `sync()` on exactly those events) each fetch the plugin list +
+ * the ACTIVE workspace's enablement rows, derive the desired candidate set
+ * (pure), and run one driver pass. The driver's effects are injected HERE at
+ * the edge:
+ * - `importModule` — the dynamic `import()` (the only module-system touch);
+ * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId
+ * attribution into the layout registry, app-store-backed surfaces ports,
+ * and a plugin-prefixed logger;
+ * - `unregisterViewsByPlugin` — the registry's unload sweep.
+ *
+ * After every pass the per-plugin FE states are reported to the plugins
+ * store (the manager UI renders "incompatible frontend plugin" and
+ * activation failures).
+ */
+import {
+ createFrontendDriver,
+ FRONTEND_ABI_VERSION,
+ type FrontendDriver,
+ type FrontendHostAPI,
+ type FrontendPluginState,
+ frontendCandidates,
+ usablePluginIds,
+} from "../core/plugin-abi";
+import {
+ registerPluginView,
+ unregisterViewsByPlugin,
+ type ViewComponent,
+} from "../features/layout";
+import type { PluginsHttp } from "../features/plugins";
+
+/** The surfaces ports the ABI hands plugin views — backed by the app store. */
+export interface FrontendSurfacesPorts {
+ readonly subscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly invoke: (
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ) => void;
+ readonly getSpec: (surfaceId: string) => unknown;
+}
+
+/** The shape of the plugins store the FE states are reported to. */
+export interface FrontendStateReporter {
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
+}
+
+export interface PluginHostOptions {
+ readonly http: PluginsHttp;
+ /** The backend HTTP base (import-URL origin). */
+ readonly httpBase: string;
+ readonly reporter: FrontendStateReporter;
+ /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */
+ readonly activeWorkspaceId: () => string;
+ readonly surfaces: FrontendSurfacesPorts;
+}
+
+export interface PluginHost {
+ /**
+ * Run one sync pass (all three triggers share it — the active workspace
+ * is read per call, which is what makes `workspace.set` a distinct
+ * trigger). Never throws: a failed list fetch keeps the current set.
+ */
+ sync(): Promise<void>;
+}
+
+export function createPluginHost(opts: PluginHostOptions): PluginHost {
+ const driver: FrontendDriver = createFrontendDriver({
+ importModule: (url) => import(/* @vite-ignore */ url),
+ buildHostApi: (pluginId) => buildHostApi(pluginId),
+ unregisterViewsByPlugin,
+ logger: console,
+ });
+
+ function buildHostApi(pluginId: string): FrontendHostAPI {
+ return {
+ abiVersion: FRONTEND_ABI_VERSION,
+ registerView: (reg) =>
+ registerPluginView(
+ {
+ id: reg.id,
+ label: reg.label,
+ // ONE controlled cast at the ABI seam — the registry is a
+ // heterogeneous component bag (register-views.ts precedent);
+ // the plugin's own build already vetted its component.
+ component: reg.component as ViewComponent,
+ ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}),
+ },
+ pluginId,
+ ),
+ surfaces: {
+ subscribe: (surfaceId, conversationId) =>
+ opts.surfaces.subscribe(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ opts.surfaces.unsubscribe(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ opts.surfaces.invoke(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never,
+ },
+ logger: {
+ info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`),
+ warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`),
+ },
+ };
+ }
+
+ return {
+ async sync(): Promise<void> {
+ // Both reads are fail-soft (null on network failure) — a down backend
+ // keeps the CURRENT imported set untouched (no unload on a flaky
+ // fetch, and the manager UI's last-known states persist).
+ const workspaceId = opts.activeWorkspaceId();
+ const [list, workspace] = await Promise.all([
+ opts.http.list(),
+ opts.http.workspacePlugins(workspaceId),
+ ]);
+ if (list === null) return;
+ const candidates = frontendCandidates(list, {
+ workspaceId,
+ usableIds: usablePluginIds(workspace?.plugins ?? []),
+ httpBase: opts.httpBase,
+ });
+ await driver.sync(candidates);
+ opts.reporter.setFrontendStates(driver.states());
+ },
+ };
+}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index bfec235..8a80160 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -103,6 +103,7 @@ import {
type VisionSettings,
type VisionSettingsPatch,
} from "../features/vision";
+import { createPluginHost } from "./plugin-host.svelte";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
import { randomId } from "./uuid";
@@ -250,6 +251,15 @@ export interface AppStore {
renameTab(conversationId: string, title: string): void;
invoke(surfaceId: string, actionId: string, payload?: unknown): void;
/**
+ * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the
+ * focused conversation when absent) — the frontend-plugin ABI's
+ * `surfaces.subscribe` path (plan 03). The app auto-subscribes every
+ * catalog entry; this is the re-assertion port (same wire semantics).
+ */
+ subscribeSurface(surfaceId: string, conversationId?: string): void;
+ /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void;
+ /**
* Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
* Returns null when no conversation is focused (a draft has nothing to warm).
*/
@@ -703,7 +713,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// The app store wires the server's `extensions-changed` broadcast into it
// (§11 multi-client coherence) and refreshes the workspace-scoped model
// picker + guardrail on the same event.
- const pluginsStore = createPluginsStore(createPluginsHttp(httpBase, fetchImpl));
+ const pluginsHttp = createPluginsHttp(httpBase, fetchImpl);
+ const pluginsStore = createPluginsStore(pluginsHttp);
// ── Layout (plan 02) ─────────────────────────────────────────────────────────
// The ACTIVE workspace's tile tree, owned by the backend's layout provider
@@ -1180,6 +1191,57 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let socket: ReturnType<typeof createSurfaceSocket> | null = null;
/**
+ * The surface OP implementations — closure functions shared by the app
+ * store's public methods AND the frontend-plugin ABI's surfaces ports (the
+ * plugin host binds exactly these; there is no second protocol state).
+ */
+ function invokeSurfaceOp(
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ conversationId ?? focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+
+ function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ const result = protocolSubscribe(
+ protocol,
+ surfaceId,
+ conversationId ?? focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+
+ function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ // protocolUnsubscribe keys on the surface alone (it drops the pair it
+ // was subscribed under); the optional id documents the intent.
+ void conversationId;
+ const result = protocolUnsubscribe(protocol, surfaceId);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+
+ function surfaceSpecOp(surfaceId: string): SurfaceSpec | null {
+ return getSurfaceSpec(protocol, surfaceId);
+ }
+
+ /**
* Open a conversation tab — used by the `conversation.open` WS broadcast
* (CLI `--open` flag) and by `conversation.statusChanged` when a new active
* conversation is discovered. If the conversation is already open, this is a
@@ -1335,6 +1397,28 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
+ // ── Frontend plugins (plan 03) ─────────────────────────────────────────────
+ // The ABI driver's composition-root wiring: one host instance per app
+ // store. `sync()` runs on boot, on `extensions-changed`, and on every
+ // workspace switch (the three load-lifecycle triggers — the active
+ // workspace is read per call, which makes workspace.set meaningful).
+ // The surfaces ports delegate to the SAME protocol/socket machinery the
+ // app store's own surface methods use (closure functions below, shared
+ // with the returned object — no second protocol state).
+ const pluginHost = createPluginHost({
+ http: pluginsHttp,
+ httpBase,
+ reporter: pluginsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ surfaces: {
+ subscribe: (surfaceId, conversationId) => subscribeSurfaceOp(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) => unsubscribeSurfaceOp(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => surfaceSpecOp(surfaceId),
+ },
+ });
+
const socketOpts: SurfaceSocketOptions = {
url: wsUrl,
onMessage: handleServerMessage,
@@ -1428,6 +1512,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// surface catalog is NOT refetched here — the server re-pushes it
// (workspace-filtered) on the same broadcast.
pluginsStore.onExtensionsChanged();
+ // Frontend plugins (plan 03): the load lifecycle's extensions-changed
+ // trigger — newly-loaded/enabled frontend plugins import + activate;
+ // unloaded/disabled ones are swept (their tiles fall back).
+ void pluginHost.sync();
// A layout plugin enabled/disabled mid-session starts/stops serving —
// the probe picks the new provider state up without a reload (§2.6).
void layoutStore.probe(activeWorkspaceId);
@@ -1464,6 +1552,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// served by the backend's layout plugin when one is active. `boot: true`
// arms the one-way §2.7 localStorage migration on the first success.
void layoutStore.probe(activeWorkspaceId, { boot: true });
+ // Frontend plugins (plan 03): the load lifecycle's boot trigger — every
+ // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is
+ // imported + activated (after the first list fetch lands).
+ void pluginHost.sync();
// One-time legacy model-name migration on boot (independent of the fetch;
// preserved from the pre-refreshModels boot chain).
if (migrateLegacyModelName(activeModel) !== activeModel) {
@@ -1587,6 +1679,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// default for the new workspace and probe its provider (§2.6 — every
// workspace switch is a probe, which is also the missed-push catch-up).
layoutStore.onWorkspaceChanged(workspaceId);
+ // Frontend plugins (plan 03): the load lifecycle's workspace.set
+ // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may
+ // load and unload frontend plugins.
+ void pluginHost.sync();
},
get activeChat(): ChatStore {
return activeChat;
@@ -1793,17 +1889,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
invoke(surfaceId: string, actionId: string, payload?: unknown): void {
- const result = protocolInvoke(
- protocol,
- surfaceId,
- actionId,
- payload,
- focusedConversationId(),
- );
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
+ invokeSurfaceOp(surfaceId, actionId, payload);
+ },
+
+ subscribeSurface(surfaceId: string, conversationId?: string): void {
+ subscribeSurfaceOp(surfaceId, conversationId);
+ },
+
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void {
+ unsubscribeSurfaceOp(surfaceId, conversationId);
},
async warmNow(): Promise<WarmResult | null> {
diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts
new file mode 100644
index 0000000..e8c8e7e
--- /dev/null
+++ b/src/core/plugin-abi/driver.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it, vi } from "vitest";
+import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js";
+import type { FrontendCandidate } from "./load-diff.js";
+
+/**
+ * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic
+ * import + the host-API builder — injected effects). The driver's internals,
+ * the load diff, and the registry-facing sweep contract are real.
+ */
+
+const c = (id: string, bust = "1"): FrontendCandidate => ({
+ id,
+ url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`,
+ apiVersion: "^1",
+});
+
+interface Harness {
+ readonly deps: FrontendDriverDeps & {
+ // The race test swaps the import edge before the driver exists — the
+ // driver reads the dep object per call, so the mutation is the swap.
+ importModule: (url: string) => Promise<unknown>;
+ };
+ readonly imports: string[];
+ readonly sweeps: string[];
+ /** The modules the fake import() returns, keyed by the next id. */
+ readonly modules: Map<string, unknown>;
+ readonly logs: string[];
+}
+
+function harness(): Harness {
+ const imports: string[] = [];
+ const sweeps: string[] = [];
+ const logs: string[] = [];
+ const modules = new Map<string, unknown>();
+ const deps: FrontendDriverDeps = {
+ importModule: async (url) => {
+ imports.push(url);
+ const id = new URL(url).pathname.split("/")[2] ?? "";
+ const mod = modules.get(id);
+ if (mod === undefined) throw new Error(`404: ${url}`);
+ return mod;
+ },
+ buildHostApi: (pluginId) => ({
+ abiVersion: "1",
+ registerView: () => () => {},
+ surfaces: {
+ subscribe: () => {},
+ unsubscribe: () => {},
+ invoke: () => {},
+ getSpec: () => null,
+ },
+ logger: { info: () => {}, warn: () => {} },
+ ...(pluginId === undefined ? {} : {}),
+ }),
+ unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId),
+ logger: {
+ info: (msg) => logs.push(msg),
+ warn: (msg) => logs.push(msg),
+ },
+ };
+ return { deps, imports, sweeps, modules, logs };
+}
+
+describe("createFrontendDriver — the load lifecycle", () => {
+ it("boot: imports newcomers and calls activate with the plugin's own host API", async () => {
+ const h = harness();
+ const activate = vi.fn();
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ expect(h.imports).toEqual([c("a").url]);
+ expect(activate).toHaveBeenCalledOnce();
+ expect(driver.states().get("a")).toEqual({ kind: "loaded" });
+ });
+
+ it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => {
+ const h = harness();
+ const deactivate = vi.fn();
+ h.modules.set("a", { default: { activate: () => deactivate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]); // the plugin is gone from the desired set
+ expect(deactivate).toHaveBeenCalledOnce();
+ expect(h.sweeps).toEqual(["a"]);
+ expect(driver.states().has("a")).toBe(false);
+ });
+
+ it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => {
+ const h = harness();
+ h.modules.set("a", { default: { activate: () => () => {} } });
+ h.modules.set("b", { default: { activate: () => () => {} } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a"), c("b")]);
+ await driver.sync([c("b")]); // only `a` vanishes
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => {
+ const h = harness();
+ const activate = vi.fn();
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]); // disable → unload (disposer + sweep)
+ await driver.sync([c("a")]); // re-enable → activate re-runs
+ expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL
+ expect(activate).toHaveBeenCalledTimes(2);
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("a changed URL (backend reload) unloads then re-imports fresh code", async () => {
+ const h = harness();
+ const activate = vi.fn(() => () => {});
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a", "old")]);
+ await driver.sync([c("a", "new")]);
+ expect(h.sweeps).toEqual(["a"]);
+ expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]);
+ expect(activate).toHaveBeenCalledTimes(2);
+ });
+
+ it("a throwing activate is LOUD: failed state, no crash, others still load", async () => {
+ const h = harness();
+ h.modules.set("bad", {
+ default: {
+ activate: () => {
+ throw new Error("boom");
+ },
+ },
+ });
+ h.modules.set("good", { default: { activate: () => () => {} } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("bad"), c("good")]);
+ expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" });
+ expect(driver.states().get("good")).toEqual({ kind: "loaded" });
+ // The failed plugin owns nothing — the sweep never ran for it.
+ expect(h.sweeps).toEqual([]);
+ });
+
+ it("a broken module (404 / missing activate) is a failed state, not a crash", async () => {
+ const h = harness();
+ h.modules.set("no-activate", { default: {} });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("ghost"), c("no-activate")]);
+ expect(driver.states().get("ghost")?.kind).toBe("failed");
+ expect(driver.states().get("no-activate")?.kind).toBe("failed");
+ expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true);
+ });
+
+ it("a throwing deactivate still gets the registry sweep (views never linger)", async () => {
+ const h = harness();
+ h.modules.set("a", {
+ default: {
+ activate: () => () => {
+ throw new Error("disposer boom");
+ },
+ },
+ });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]);
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("handshake-incompatible candidates are never imported (reported for the UI)", async () => {
+ const h = harness();
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([{ ...c("bad"), apiVersion: "^2" }]);
+ expect(h.imports).toEqual([]);
+ expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" });
+ });
+
+ it("overlapping syncs serialize (boot + extensions-changed race)", async () => {
+ const h = harness();
+ let releaseImport: (() => void) | undefined;
+ h.modules.set("a", {
+ default: {
+ activate: () => {
+ return () => {};
+ },
+ },
+ });
+ const blockingImport = async (url: string) => {
+ h.imports.push(url);
+ await new Promise<void>((resolve) => {
+ releaseImport = resolve;
+ });
+ return h.modules.get("a");
+ };
+ // Swap the import edge BEFORE the driver exists (the driver reads the
+ // dep object per call — the swap is the injection point for the race).
+ h.deps.importModule = blockingImport;
+ const driver = createFrontendDriver(h.deps);
+ const first = driver.sync([c("a")]);
+ const second = driver.sync([]); // queued behind the first
+ // While the first sync is blocked on import, the second must not have
+ // started (no unload of `a` mid-import).
+ await new Promise((r) => setTimeout(r, 10));
+ releaseImport?.();
+ await Promise.all([first, second]);
+ // biome-ignore lint/suspicious/noConsole: temporary debug
+ // The second pass sees `a` already imported (the queued sync ran after)
+ // and unloads it — exactly one sweep, no interleaving.
+ expect(h.sweeps).toEqual(["a"]);
+ });
+});
diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts
new file mode 100644
index 0000000..7ba326f
--- /dev/null
+++ b/src/core/plugin-abi/driver.ts
@@ -0,0 +1,140 @@
+/**
+ * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host.
+ * Owns the imported-module set and runs ONE sync pass per trigger (boot,
+ * `extensions-changed`, `workspace.set`):
+ *
+ * 1. diff (pure, `decideFrontendLoads`) the desired set against the
+ * imported set;
+ * 2. UNLOAD stragglers: run the module's deactivate disposer (if it
+ * returned one) then the host's unregister-by-pluginId sweep — the UI is
+ * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the
+ * bytes linger in the module cache. Re-enable re-runs `activate` on the
+ * cached module (same URL ⇒ no re-fetch).
+ * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call
+ * `activate(api)` with the plugin's own host API (forced pluginId
+ * attribution). A throwing/missing activate is LOUD: the plugin's FE
+ * half is marked failed (the backend half stays loaded) and the failure
+ * never crashes the host or blocks the other plugins.
+ *
+ * Syncs are serialized (a chain) — boot + extensions-changed + workspace
+ * switches can overlap, and import/unload ordering must stay coherent.
+ * No internal mocks: tests inject the import edge + host-API builder.
+ */
+import type {
+ FrontendCandidate,
+ FrontendLoadDecision,
+ ImportedFrontendModule,
+} from "./load-diff.js";
+import { decideFrontendLoads } from "./load-diff.js";
+import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js";
+
+export interface FrontendDriverDeps {
+ /**
+ * THE edge: dynamic ESM import. Injected (the composition root binds
+ * `import(url)`; tests bind a fake) so nothing else in the driver touches
+ * the module system.
+ */
+ readonly importModule: (url: string) => Promise<unknown>;
+ /**
+ * Build the plugin's host API — the composition root closes over the
+ * registry + surfaces ports + logger and FORCES the pluginId attribution.
+ */
+ readonly buildHostApi: (pluginId: string) => FrontendHostAPI;
+ /** The unregister-by-pluginId sweep (the registry-side unload path). */
+ readonly unregisterViewsByPlugin: (pluginId: string) => void;
+ readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
+}
+
+export interface FrontendDriver {
+ /** Run one sync pass. Serialized; never throws (failures are states). */
+ sync(candidates: readonly FrontendCandidate[]): Promise<void>;
+ /** The per-plugin states the manager UI renders. */
+ states(): ReadonlyMap<string, FrontendPluginState>;
+}
+
+interface ImportedEntry extends ImportedFrontendModule {
+ readonly deactivate: (() => void) | undefined;
+}
+
+export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver {
+ /** pluginId → the imported module's entry. */
+ const imported = new Map<string, ImportedEntry>();
+ /** pluginId → its last-known FE state (for the manager UI). */
+ const pluginStates = new Map<string, FrontendPluginState>();
+ /** The sync chain (serialization across triggers). */
+ let chain: Promise<void> = Promise.resolve();
+
+ /** Unload one plugin: module disposer FIRST, then the registry sweep. */
+ function unload(id: string, entry: ImportedEntry): void {
+ try {
+ entry.deactivate?.();
+ } catch (err) {
+ // A broken deactivate must not block the sweep — the registration
+ // ledger's force-clean philosophy (the views must not linger).
+ deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`);
+ }
+ try {
+ deps.unregisterViewsByPlugin(id);
+ } catch (err) {
+ deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`);
+ }
+ imported.delete(id);
+ pluginStates.delete(id);
+ deps.logger.info(`frontend-plugin "${id}": unloaded`);
+ }
+
+ /** Import + activate one candidate. Failures are LOUD states, not throws. */
+ async function load(candidate: FrontendCandidate): Promise<void> {
+ try {
+ const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined;
+ const activate = mod?.default?.activate;
+ if (typeof activate !== "function") {
+ throw new Error("the module has no default.activate export");
+ }
+ const disposer = activate(deps.buildHostApi(candidate.id));
+ imported.set(candidate.id, {
+ url: candidate.url,
+ deactivate: typeof disposer === "function" ? disposer : undefined,
+ });
+ pluginStates.set(candidate.id, { kind: "loaded" });
+ deps.logger.info(`frontend-plugin "${candidate.id}": activated`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ pluginStates.set(candidate.id, { kind: "failed", error: message });
+ deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`);
+ }
+ }
+
+ return {
+ async sync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const run = chain.then(() => runSync(candidates));
+ // The NEXT sync waits for this one; a rejected run must not poison the
+ // chain (runSync itself never throws, but belt-and-suspenders).
+ chain = run.catch(() => {});
+ await run;
+ },
+
+ states(): ReadonlyMap<string, FrontendPluginState> {
+ return new Map(pluginStates);
+ },
+ };
+
+ async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported);
+
+ // The handshake verdicts persist even when nothing else changes — the
+ // manager UI renders "incompatible frontend plugin" until the plugin's
+ // manifest (or the host) changes.
+ for (const bad of decision.incompatible) {
+ pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion });
+ }
+
+ for (const id of decision.toUnload) {
+ const entry = imported.get(id);
+ if (entry !== undefined) unload(id, entry);
+ }
+ for (const candidate of decision.toLoad) {
+ await load(candidate);
+ }
+ }
+}
diff --git a/src/core/plugin-abi/index.ts b/src/core/plugin-abi/index.ts
new file mode 100644
index 0000000..3c97eca
--- /dev/null
+++ b/src/core/plugin-abi/index.ts
@@ -0,0 +1,27 @@
+/**
+ * The frontend plugin ABI (plan 03) — the contract + lifecycle machinery for
+ * dynamically-imported frontend plugin modules. See `types.ts` (the surface),
+ * `version.ts` (the handshake), `load-diff.ts` (pure decisions), and
+ * `driver.ts` (the effectful host). The composition root (src/app) wires the
+ * driver to the registry, the surfaces ports, and the plugin HTTP edge.
+ */
+export { createFrontendDriver, type FrontendDriver, type FrontendDriverDeps } from "./driver.js";
+export {
+ decideFrontendLoads,
+ type FrontendCandidate,
+ type FrontendCandidatesInput,
+ type FrontendLoadDecision,
+ frontendCandidates,
+ type ImportedFrontendModule,
+ usablePluginIds,
+} from "./load-diff.js";
+export type {
+ FrontendHostAPI,
+ FrontendPluginModule,
+ FrontendPluginState,
+ FrontendViewRegistration,
+} from "./types.js";
+export {
+ FRONTEND_ABI_VERSION,
+ isFrontendCompatible,
+} from "./version.js";
diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts
new file mode 100644
index 0000000..157b93f
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -0,0 +1,167 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ decideFrontendLoads,
+ frontendCandidates,
+ type ImportedFrontendModule,
+ usablePluginIds,
+} from "./load-diff.js";
+import { FRONTEND_ABI_VERSION } from "./version.js";
+
+const httpBase = "http://localhost:24203";
+
+function listWith(
+ ...rows: readonly {
+ readonly id: string;
+ readonly apiVersion?: string;
+ readonly bust?: string;
+ }[]
+): PluginListResponse {
+ return {
+ loaded: rows.map((r) => ({
+ id: r.id,
+ name: r.id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ ...(r.apiVersion === undefined
+ ? {}
+ : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }),
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+}
+
+describe("frontendCandidates", () => {
+ it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => {
+ const candidates = frontendCandidates(
+ listWith(
+ { id: "a", apiVersion: "^1", bust: "111" },
+ { id: "no-fe" },
+ { id: "b", apiVersion: "^1", bust: "222" },
+ ),
+ { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase },
+ );
+ expect(candidates).toEqual([
+ {
+ id: "a",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`,
+ },
+ {
+ id: "b",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`,
+ },
+ ]);
+ });
+
+ it("excludes plugins disabled in the active workspace", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), {
+ workspaceId: "team-a",
+ usableIds: new Set<string>(),
+ httpBase,
+ });
+ expect(candidates).toEqual([]);
+ });
+
+ it("URL-encodes the workspace and bust", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), {
+ workspaceId: "team a",
+ usableIds: new Set(["a"]),
+ httpBase,
+ });
+ expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`);
+ });
+});
+
+describe("usablePluginIds", () => {
+ it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => {
+ const usable = usablePluginIds([
+ { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true },
+ { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true },
+ { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false },
+ { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false },
+ { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false },
+ { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false },
+ ]);
+ expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]);
+ });
+});
+
+describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => {
+ const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({
+ id,
+ url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`,
+ apiVersion: "^1",
+ });
+
+ it("boot: every desired candidate imports (nothing imported yet)", () => {
+ const d = decideFrontendLoads([c("a"), c("b")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]);
+ expect(d.toUnload).toEqual([]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("extensions-changed: a removed plugin unloads, a newcomer imports", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["new"]);
+ expect(d.toUnload).toEqual(["gone"]);
+ });
+
+ it("workspace.set: the enablement set change drives load AND unload", () => {
+ // The candidate list already reflects the NEW workspace (composition
+ // root re-derives per sync) — previously-imported ids absent from it unload.
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual(["b"]);
+ });
+
+ it("a stable desired set is a no-op (idempotent sync)", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("b")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual([]);
+ });
+
+ it("a changed URL (backend reload → new bust) is unload + re-import", () => {
+ const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]);
+ const d = decideFrontendLoads([c("a", "new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a"]);
+ expect(d.toUnload).toEqual(["a"]);
+ });
+
+ it("handshake failure → incompatible, never imported, still unloaded if it was", () => {
+ const bad = { ...c("bad"), apiVersion: "^2" };
+ const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]);
+ const d = decideFrontendLoads([bad], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]);
+ // The previously-imported instance must be swept (the manifest changed
+ // under us — the UI must not keep running incompatible code).
+ expect(d.toUnload).toEqual(["bad"]);
+ });
+
+ it("the handshake checks against FRONTEND_ABI_VERSION", () => {
+ // A range that includes the host version passes — proving the version
+ // constant feeds the check.
+ expect(FRONTEND_ABI_VERSION).toBe("1");
+ const d = decideFrontendLoads([c("ok")], new Map());
+ expect(d.incompatible).toEqual([]);
+ });
+});
diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts
new file mode 100644
index 0000000..b846a40
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.ts
@@ -0,0 +1,137 @@
+/**
+ * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On
+ * boot, on `extensions-changed`, and on `workspace.set`, the composition
+ * root derives the DESIRED set of frontend plugins (loaded ∧
+ * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active
+ * workspace's enablement rows, and diffs it against the IMPORTED set:
+ * newcomers are `import()`-ed + activated; stragglers are deactivated
+ * (module disposer + unregister-by-pluginId sweep); a changed import URL
+ * (backend reload → new content bust) is an unload + re-import.
+ *
+ * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does
+ * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER
+ * imported — it is also (re)unloaded if a previous version of it was
+ * imported.
+ */
+import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+/** One desired frontend plugin, fully URL-resolved. */
+export interface FrontendCandidate {
+ readonly id: string;
+ /** The import URL: the backend route + workspace + the content bust. */
+ readonly url: string;
+ /** The plugin's declared frontend ABI semver range (the handshake input). */
+ readonly apiVersion: string;
+}
+
+/** What the currently-imported modules look like to the diff. */
+export interface ImportedFrontendModule {
+ /** The URL the module was imported from (a change → unload + re-import). */
+ readonly url: string;
+}
+
+/** The diff verdict for one sync pass. */
+export interface FrontendLoadDecision {
+ /** Newcomers (or bust-changed plugins) to import + activate. */
+ readonly toLoad: readonly FrontendCandidate[];
+ /** Imported plugin ids no longer desired (or superseded by a new URL). */
+ readonly toUnload: readonly string[];
+ /** Handshake failures — "incompatible frontend plugin" in the manager UI. */
+ readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[];
+}
+
+/** The composition-root inputs for candidate derivation. */
+export interface FrontendCandidatesInput {
+ /** The active workspace whose enablement set applies. */
+ readonly workspaceId: string;
+ /** The plugin ids enabled in that workspace (see `usablePluginIds`). */
+ readonly usableIds: ReadonlySet<string>;
+ /** The backend HTTP base (the import URL's origin). */
+ readonly httpBase: string;
+}
+
+/**
+ * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧
+ * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is
+ * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content
+ * bust the additive list field carries (the entry's mtimeMs), so a
+ * backend-side reload (new mtime) produces a NEW URL → the diff re-imports.
+ */
+export function frontendCandidates(
+ list: PluginListResponse,
+ input: FrontendCandidatesInput,
+): readonly FrontendCandidate[] {
+ const out: FrontendCandidate[] = [];
+ for (const row of list.loaded) {
+ if (row.frontend === undefined) continue;
+ if (!input.usableIds.has(row.id)) continue;
+ out.push({
+ id: row.id,
+ apiVersion: row.frontend.apiVersion,
+ url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`,
+ });
+ }
+ return out;
+}
+
+/**
+ * Diff the desired set against the imported set. Handshake failures are
+ * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest
+ * changed to an incompatible range), it lands in `toUnload`.
+ */
+export function decideFrontendLoads(
+ candidates: readonly FrontendCandidate[],
+ imported: ReadonlyMap<string, ImportedFrontendModule>,
+): FrontendLoadDecision {
+ const toLoad: FrontendCandidate[] = [];
+ const incompatible: { id: string; apiVersion: string }[] = [];
+ const desired = new Set<string>();
+ /** Imported ids whose URL changed (unload + re-import — fresh code). */
+ const superseded: string[] = [];
+
+ for (const candidate of candidates) {
+ if (!isFrontendCompatible(candidate.apiVersion)) {
+ incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion });
+ continue;
+ }
+ desired.add(candidate.id);
+ const current = imported.get(candidate.id);
+ if (current === undefined) {
+ toLoad.push(candidate);
+ } else if (current.url !== candidate.url) {
+ // The backend reloaded the plugin (a new content bust → new URL):
+ // unload the OLD instance (its disposer + registry sweep run), then
+ // import the fresh code.
+ superseded.push(candidate.id);
+ toLoad.push(candidate);
+ }
+ }
+
+ // Everything imported that is no longer a (handshake-passing) desired id,
+ // plus same-id candidates whose URL changed.
+ const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded];
+
+ return { toLoad, toUnload, incompatible };
+}
+
+/**
+ * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins`
+ * response (plan-06 rows) — the FE mirror of the backend's
+ * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`;
+ * every other tier is usable only when explicitly `enabled`. The response's
+ * `loaded: false` rows (records for unloaded plugins) cannot be desired
+ * anyway (candidates come from the LOADED set).
+ */
+export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> {
+ const usable = new Set<string>();
+ for (const row of rows) {
+ if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") {
+ usable.add(row.pluginId);
+ }
+ }
+ return usable;
+}
+
+/** Re-export for the composition root's single-import convenience. */
+export { FRONTEND_ABI_VERSION, isFrontendCompatible };
diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts
new file mode 100644
index 0000000..14abf6e
--- /dev/null
+++ b/src/core/plugin-abi/types.ts
@@ -0,0 +1,95 @@
+/**
+ * The frontend plugin ABI surface (plan 03 §3.2) — the ONLY contract between
+ * the host app and a dynamically-imported frontend plugin module.
+ *
+ * A frontend plugin is a SINGLE prebuilt ESM file (svelte externalized —
+ * resolved through the host's import map to the app's own Svelte 5 runtime)
+ * whose default export is `{ activate(api) }`. `activate` registers views /
+ * stashes ports and MAY return a deactivate disposer (plan 03's deactivate
+ * contract). Trust model: plugin code runs with full page privileges — no
+ * sandbox (documented non-goal); the trust signal is the manifest's `trust`
+ * field rendered in the manager UI.
+ *
+ * Views get data via SURFACES (the ui-contract philosophy: backend-declared
+ * data rendered generically) — never via the app store. `registerView`
+ * FORCES the importing plugin's pluginId attribution (the host wraps the
+ * registry call), which is what makes unload-time sweeps exact.
+ */
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import type { FRONTEND_ABI_VERSION } from "./version.js";
+
+/** The registration input for one plugin view (as the ABI exposes it). */
+export interface FrontendViewRegistration {
+ /** Registry id — the same id a tile leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /**
+ * A Svelte 5 component (compiled by the plugin's build against the SHARED
+ * svelte runtime). Opaque here — the host casts it into the registry's
+ * heterogeneous component bag at the seam.
+ */
+ readonly component: unknown;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+}
+
+/** The per-plugin host API object handed to `activate`. */
+export interface FrontendHostAPI {
+ /** The host's ABI version (the other half of the semver handshake). */
+ readonly abiVersion: typeof FRONTEND_ABI_VERSION;
+ /**
+ * Register a view into the SAME registry built-ins use. pluginId
+ * attribution is FORCED to the importing plugin (the host closes over it)
+ * — plugins cannot attribute views to another plugin. The returned
+ * disposer removes the view (call it from your deactivate).
+ */
+ readonly registerView: (reg: FrontendViewRegistration) => () => void;
+ /**
+ * Talk to backend surfaces — the plugin-view data path. The app
+ * auto-subscribes every catalog entry, so `getSpec` typically returns
+ * data immediately for surfaces your plugin's backend half declares;
+ * subscribe/unsubscribe re-assert scopes explicitly.
+ */
+ readonly surfaces: {
+ readonly subscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly invoke: (
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ) => void;
+ readonly getSpec: (surfaceId: string) => SurfaceSpec | null;
+ };
+ readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
+ // plan 04 ADDS `layout: { applyOps(...) }` here — an additive ABI bump
+ // (v1 plugins keep working; range-checked per the handshake).
+}
+
+/**
+ * What `activate` may return: nothing, or the deactivate disposer (the
+ * plan-03 deactivate contract). A named alias keeps the plan grammar
+ * readable; the suppression is needed because biome flags `void` inside a
+ * union type — here it is the plan-specified shape.
+ */
+// biome-ignore lint/suspicious/noConfusingVoidType: plan-specified activate grammar — "may return a disposer" (the deactivate contract)
+export type FrontendActivateResult = void | (() => void);
+
+/** The module shape a frontend plugin's entry must default-export. */
+export interface FrontendPluginModule {
+ readonly default: {
+ /**
+ * Register the plugin's views/ports. MAY return a deactivate disposer —
+ * run on unload/disable BEFORE the host's unregister-by-pluginId sweep
+ * (the sweep is the safety net, not the primary path).
+ */
+ activate(api: FrontendHostAPI): FrontendActivateResult;
+ };
+}
+
+/** The per-plugin FRONTEND lifecycle state the manager UI renders. */
+export type FrontendPluginState =
+ | { readonly kind: "loaded" }
+ | { readonly kind: "incompatible"; readonly apiVersion: string }
+ | { readonly kind: "failed"; readonly error: string };
diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts
new file mode 100644
index 0000000..2620847
--- /dev/null
+++ b/src/core/plugin-abi/version.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+describe("FRONTEND_ABI_VERSION", () => {
+ it("is 1 (plan 03)", () => {
+ expect(FRONTEND_ABI_VERSION).toBe("1");
+ });
+});
+
+describe("isFrontendCompatible — handshake accept/reject matrix", () => {
+ it("accepts the canonical authoring range ^1", () => {
+ expect(isFrontendCompatible("^1")).toBe(true);
+ expect(isFrontendCompatible("^1.0")).toBe(true);
+ expect(isFrontendCompatible("^1.0.0")).toBe(true);
+ });
+
+ it("accepts the bare major version and exact matches", () => {
+ expect(isFrontendCompatible("1")).toBe(true);
+ expect(isFrontendCompatible("1.0.0")).toBe(true);
+ });
+
+ it("accepts wildcard and >= ranges", () => {
+ expect(isFrontendCompatible("*")).toBe(true);
+ expect(isFrontendCompatible(">=1.0.0")).toBe(true);
+ });
+
+ it("rejects a next-major caret range", () => {
+ expect(isFrontendCompatible("^2")).toBe(false);
+ expect(isFrontendCompatible("^2.0.0")).toBe(false);
+ });
+
+ it("rejects an older pinned range", () => {
+ expect(isFrontendCompatible("0.9.0")).toBe(false);
+ expect(isFrontendCompatible("^0.1.0")).toBe(false);
+ });
+
+ it("rejects a tilde range of an older minor", () => {
+ expect(isFrontendCompatible("~0.9.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0")).toBe(true);
+ });
+
+ it("rejects malformed ranges (fail closed, never throws)", () => {
+ expect(isFrontendCompatible("")).toBe(false);
+ expect(isFrontendCompatible("banana")).toBe(false);
+ expect(isFrontendCompatible("^abc")).toBe(false);
+ expect(isFrontendCompatible("1.0.0.0")).toBe(false);
+ });
+
+ it("rejects a future patch-level host (the host version pins the answer)", () => {
+ // The host version defaults to "1.0.0"; "~1.0.0" includes it.
+ expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true);
+ expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false);
+ });
+});
diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts
new file mode 100644
index 0000000..becae87
--- /dev/null
+++ b/src/core/plugin-abi/version.ts
@@ -0,0 +1,80 @@
+/**
+ * The frontend plugin ABI version + the semver handshake (plan 03 §3.2).
+ *
+ * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only. A plugin's
+ * `manifest.frontend.apiVersion` is a semver RANGE that must include the
+ * host's version; a mismatch lists the plugin as "incompatible frontend
+ * plugin" in the manager UI and its module is NEVER imported.
+ *
+ * `isFrontendCompatible` mirrors the backend kernel's
+ * `isApiVersionCompatible` semantics (host/src/host/version.ts) so both
+ * sides of the handshake behave identically. It additionally accepts
+ * major-only versions/ranges ("1", "^1") — the ABI's version grammar is
+ * single-number, normalized here to x.y.z with zeros — because a frontend
+ * ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`.
+ */
+
+export const FRONTEND_ABI_VERSION = "1";
+
+/** The host version the handshake checks against (normalized to semver). */
+const HOST_VERSION = "1.0.0";
+
+interface SemVer {
+ readonly major: number;
+ readonly minor: number;
+ readonly patch: number;
+}
+
+/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */
+function parseSemver(version: string): SemVer | null {
+ const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
+ if (!match) return null;
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2] ?? 0),
+ patch: Number(match[3] ?? 0),
+ };
+}
+
+function gte(a: SemVer, b: SemVer): boolean {
+ if (a.major !== b.major) return a.major > b.major;
+ if (a.minor !== b.minor) return a.minor > b.minor;
+ return a.patch >= b.patch;
+}
+
+/**
+ * Whether the plugin's declared `frontend.apiVersion` range includes the
+ * host's FRONTEND_ABI_VERSION. A malformed range is NEVER compatible (a
+ * plugin-verify check catches it at authoring time; the handshake fails
+ * closed here).
+ */
+export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERSION): boolean {
+ const range = apiVersion.trim();
+ if (range === "*") return true;
+ const host = parseSemver(hostVersion);
+ if (host === null) return false;
+
+ if (range.startsWith("^")) {
+ const min = parseSemver(range.slice(1));
+ if (min === null) return false;
+ if (!gte(host, min)) return false;
+ if (min.major === 0) {
+ return host.major === 0 && host.minor === min.minor;
+ }
+ return host.major === min.major;
+ }
+ if (range.startsWith("~")) {
+ const min = parseSemver(range.slice(1));
+ if (min === null) return false;
+ if (!gte(host, min)) return false;
+ return host.major === min.major && host.minor === min.minor;
+ }
+ if (range.startsWith(">=")) {
+ const min = parseSemver(range.slice(2));
+ if (min === null) return false;
+ return gte(host, min);
+ }
+ const exact = parseSemver(range);
+ if (exact === null) return false;
+ return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch;
+}
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
index f5696ac..6a17589 100644
--- a/src/features/layout/index.ts
+++ b/src/features/layout/index.ts
@@ -25,8 +25,10 @@ export {
listPanelViews,
listViews,
type RegisteredView,
+ registerPluginView,
registerView,
unregisterView,
+ unregisterViewsByPlugin,
type ViewComponent,
type ViewRegistration,
viewRegistry,
diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts
index 443e50d..e5e947c 100644
--- a/src/features/layout/logic/registry.test.ts
+++ b/src/features/layout/logic/registry.test.ts
@@ -4,8 +4,10 @@ import {
getView,
listPanelViews,
listViews,
+ registerPluginView,
registerView,
unregisterView,
+ unregisterViewsByPlugin,
type ViewRegistration,
viewRegistry,
} from "./registry";
@@ -78,3 +80,69 @@ describe("view registry", () => {
expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second"));
});
});
+
+// ── Plugin-view registration (plan 03) — forced attribution + the sweep ─────
+
+describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => {
+ it("forces the importing plugin's pluginId attribution", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ expect(getView("abi-view")?.pluginId).toBe("my-plugin");
+ dispose();
+ expect(getView("abi-view")).toBeUndefined();
+ });
+
+ it("returns a working per-view disposer (the plugin deactivate contract)", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view-2", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ dispose();
+ dispose(); // idempotent
+ expect(getView("abi-view-2")).toBeUndefined();
+ });
+
+ it("the sweep removes exactly that plugin's views (registry attribution)", () => {
+ registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2");
+ registerView(registration({ id: "builtin" })); // no pluginId — never swept
+
+ unregisterViewsByPlugin("plugin-1");
+ expect(getView("p1-a")).toBeUndefined();
+ expect(getView("p1-b")).toBeUndefined();
+ expect(getView("p2-a")).not.toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+
+ unregisterViewsByPlugin("plugin-2");
+ expect(getView("p2-a")).toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+ });
+
+ it("the sweep is idempotent and a no-op for an unknown plugin", () => {
+ unregisterViewsByPlugin("nobody");
+ registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3");
+ unregisterViewsByPlugin("plugin-3");
+ expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow();
+ });
+
+ it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => {
+ registerView(registration({ id: "taken" }));
+ expect(() =>
+ registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"),
+ ).toThrow(/duplicate view id/);
+ });
+
+ it("sweep removals are visible to registry subscribers (reactive)", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => {
+ seen.push(views.map((v) => v.id));
+ });
+ registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p");
+ unregisterViewsByPlugin("p");
+ unsubscribe();
+ expect(seen.at(-1)).not.toContain("reactive");
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
index b072d2f..3a86cb9 100644
--- a/src/features/layout/logic/registry.ts
+++ b/src/features/layout/logic/registry.ts
@@ -82,6 +82,38 @@ export function unregisterView(id: string): void {
if (byId.delete(id)) publish();
}
+/**
+ * Register a PLUGIN view (plan 03) — the ABI-facing registration: the
+ * `pluginId` attribution is FORCED to the importing plugin (plugins cannot
+ * attribute views to another plugin or to no plugin), which is what makes
+ * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view
+ * disposer (the plugin's deactivate contract). A duplicate id throws
+ * LOUDLY — the driver catches it and marks the plugin's FE half failed.
+ */
+export function registerPluginView(
+ registration: Omit<ViewRegistration, "pluginId">,
+ pluginId: string,
+): () => void {
+ registerView({ ...registration, pluginId });
+ return () => unregisterView(registration.id);
+}
+
+/**
+ * Remove every view attributed to one plugin (the plan-03 unload sweep).
+ * Idempotent; the reactive registry re-renders open dropdowns, and tiles
+ * holding removed views fall back to `FallbackTile` (never a crash).
+ */
+export function unregisterViewsByPlugin(pluginId: string): void {
+ let changed = false;
+ for (const [id, view] of byId) {
+ if (view.pluginId === pluginId) {
+ byId.delete(id);
+ changed = true;
+ }
+ }
+ if (changed) publish();
+}
+
/** Look a view up by id (undefined → the renderer shows its fallback tile). */
export function getView(id: string): RegisteredView | undefined {
return byId.get(id);
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
index 0f2abbc..7a9d1a0 100644
--- a/src/features/plugins/index.ts
+++ b/src/features/plugins/index.ts
@@ -10,9 +10,12 @@ export type { HighlightSets } from "./logic/highlight";
export { dependencyHighlightSets } from "./logic/highlight";
export { isLastProvider, unloadBlockReason } from "./logic/last-provider";
export { autoCheckBackendPrereqs } from "./logic/prereqs";
-export type { PluginNode, PluginSection } from "./logic/types";
+export type {
+ PluginNode,
+ PluginSection,
+} from "./logic/types";
export { TIER_ORDER, tierLabel } from "./logic/types";
-export { groupByTier, pluginNodes, splitSections } from "./logic/view-model";
+export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model";
export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row";
export type { PluginsStore, WorkspacePluginsView } from "./store.svelte";
export { createPluginsStore } from "./store.svelte";
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
index 702483b..d5fa4bb 100644
--- a/src/features/plugins/logic/types.ts
+++ b/src/features/plugins/logic/types.ts
@@ -25,6 +25,13 @@ export interface PluginNode {
readonly dependsOn: readonly string[];
/** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */
readonly reason: string | null;
+ /**
+ * The frontend-plugin (plan 03) issue line for the manager row, when the
+ * plugin's FE half is incompatible ("incompatible frontend plugin") or
+ * failed (`frontend failed: <error>`). Absent = the FE half is fine (or
+ * the plugin declares no frontend section).
+ */
+ readonly frontendIssue?: string;
readonly section: PluginSection;
}
diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts
index 680ba94..661cade 100644
--- a/src/features/plugins/logic/view-model.test.ts
+++ b/src/features/plugins/logic/view-model.test.ts
@@ -1,7 +1,7 @@
import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import { emptyListResponse, node } from "./test-fixtures";
-import { groupByTier, pluginNodes, splitSections } from "./view-model";
+import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model";
function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse {
return {
@@ -225,3 +225,72 @@ describe("splitSections", () => {
expect(sections.missing.map((n) => n.id)).toEqual(["gone"]);
});
});
+
+// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ─────────
+
+describe("withFrontendStates", () => {
+ function listWith(...ids: readonly string[]): PluginListResponse {
+ return {
+ loaded: ids.map((id) => ({
+ id,
+ name: id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+ }
+
+ it("renders the plan-mandated grammar for an incompatible FE half", () => {
+ const nodes = pluginNodes(listWith("bad"));
+ const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin");
+ });
+
+ it("renders the activation error for a failed FE half", () => {
+ const nodes = pluginNodes(listWith("broken"));
+ const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("frontend failed: boom");
+ });
+
+ it("a loaded FE half adds no issue line; absent states add none", () => {
+ const nodes = pluginNodes(listWith("ok", "no-fe"));
+ const states = new Map([["ok", { kind: "loaded" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined();
+ expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined();
+ });
+
+ it("never annotates non-loaded sections (discovered / missing rows)", () => {
+ const response: PluginListResponse = {
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [
+ {
+ id: "dis",
+ name: "dis",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "local",
+ dir: "/plugins/dis",
+ },
+ ],
+ missing: [],
+ };
+ const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(pluginNodes(response), states);
+ expect(out[0]?.frontendIssue).toBeUndefined();
+ });
+
+ it("an empty state map is the identity (no allocation on the hot path)", () => {
+ const nodes = pluginNodes(listWith("a"));
+ expect(withFrontendStates(nodes, new Map())).toBe(nodes);
+ });
+});
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts
index 20d7d68..5a66ffa 100644
--- a/src/features/plugins/logic/view-model.ts
+++ b/src/features/plugins/logic/view-model.ts
@@ -1,4 +1,5 @@
import type { PluginListResponse } from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../../core/plugin-abi";
import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types";
/**
@@ -79,6 +80,34 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
}
/**
+ * Merge the frontend-plugin (plan 03) per-plugin FE states into the node
+ * list — the manager rows' `frontendIssue` line. Pure input → output:
+ * - `incompatible` → the plan-mandated "incompatible frontend plugin"
+ * grammar (the backend apiVersion-mismatch wording, FE half);
+ * - `failed` → the activation error (the FE half failed; the backend half
+ * stays loaded).
+ * Loaded rows only — the FE states never apply to discovered/missing rows.
+ */
+export function withFrontendStates(
+ nodes: readonly PluginNode[],
+ states: ReadonlyMap<string, FrontendPluginState>,
+): readonly PluginNode[] {
+ if (states.size === 0) return nodes;
+ return nodes.map((node) => {
+ if (node.section !== "loaded") return node;
+ const state = states.get(node.id);
+ if (state === undefined) return node;
+ const issue =
+ state.kind === "incompatible"
+ ? "incompatible frontend plugin"
+ : state.kind === "failed"
+ ? `frontend failed: ${state.error}`
+ : null;
+ return issue === null ? node : { ...node, frontendIssue: issue };
+ });
+}
+
+/**
* Cluster nodes for one manager table: tier groups in display order (Core →
* Providers → Standard), each tier's rows sorted by id. Tiers with no rows
* are omitted. Used by the loaded table's tier grouping (plan 07 §7.2).
diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts
index 580293e..fe48940 100644
--- a/src/features/plugins/store.svelte.ts
+++ b/src/features/plugins/store.svelte.ts
@@ -3,6 +3,7 @@ import type {
SetWorkspacePluginStateRequest,
WorkspacePluginRow,
} from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../core/plugin-abi";
import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http";
import type { PluginNode } from "./logic/types";
import { pluginNodes } from "./logic/view-model";
@@ -70,6 +71,16 @@ export interface PluginsStore {
* connected client converges. Fire-and-forget.
*/
onExtensionsChanged(): void;
+
+ /**
+ * The frontend-plugin (plan 03) per-plugin FE states — reported by the
+ * plugin host after every driver sync. Rendered by the manager rows:
+ * `incompatible` → "incompatible frontend plugin" (the backend-mismatch
+ * grammar), `failed` → the activation error. Absent = no FE section.
+ */
+ readonly frontendStates: ReadonlyMap<string, FrontendPluginState>;
+ /** The plugin host's post-sync report (the only writer). */
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
}
/** One open workspace modal's view (rows + materialized provider count). */
@@ -86,6 +97,7 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore {
let error = $state<string | null>(null);
let lastScan = $state<PluginScanResult | null>(null);
let workspaceView = $state<WorkspacePluginsView | null>(null);
+ let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map());
// Normalized nodes — derived so the graph logic re-runs only when the list
// changes, not on every read.
@@ -194,5 +206,13 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore {
const open = workspaceView?.workspaceId;
if (open !== undefined) void this.openWorkspace(open);
},
+
+ get frontendStates(): ReadonlyMap<string, FrontendPluginState> {
+ return frontendStates;
+ },
+
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void {
+ frontendStates = states;
+ },
};
}
diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte
index cd3d542..fd2a8a7 100644
--- a/src/features/plugins/ui/PluginManager.svelte
+++ b/src/features/plugins/ui/PluginManager.svelte
@@ -6,7 +6,7 @@
import { autoCheckBackendPrereqs } from "../logic/prereqs";
import { dependencyHighlightSets } from "../logic/highlight";
import { unloadBlockReason } from "../logic/last-provider";
- import { splitSections } from "../logic/view-model";
+ import { splitSections, withFrontendStates } from "../logic/view-model";
import PluginTable from "./PluginTable.svelte";
import UnloadConfirmModal from "./UnloadConfirmModal.svelte";
@@ -17,9 +17,13 @@
});
// ── Derived sections + hover highlighting (§8) ────────────────────────────
- const sections = $derived(splitSections(plugins.nodes));
+ // The FE-half states (plan 03) merge into the nodes here — one reactive
+ // derivation over both inputs (the normalized nodes + the plugin host's
+ // post-sync state map).
+ const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates));
+ const sections = $derived(splitSections(nodes));
let hoveredId = $state<string | null>(null);
- const highlight = $derived(dependencyHighlightSets(hoveredId, plugins.nodes));
+ const highlight = $derived(dependencyHighlightSets(hoveredId, nodes));
function highlightFor(id: string): "dep" | "dependent" | null {
if (highlight.deps.has(id)) return "dep";
diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte
index 457bd29..73e0b18 100644
--- a/src/features/plugins/ui/PluginRow.svelte
+++ b/src/features/plugins/ui/PluginRow.svelte
@@ -83,6 +83,10 @@
{#if node.reason !== null}
<span class="text-xs text-error">{node.reason}</span>
{/if}
+ {#if node.frontendIssue !== undefined}
+ <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error -->
+ <span class="text-xs text-error">{node.frontendIssue}</span>
+ {/if}
{#if node.dependsOn.length > 0}
<span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span>
{/if}
diff --git a/src/plugin-runtime/svelte-api.ts b/src/plugin-runtime/svelte-api.ts
new file mode 100644
index 0000000..cfc1de5
--- /dev/null
+++ b/src/plugin-runtime/svelte-api.ts
@@ -0,0 +1,6 @@
+// The Svelte API surface exposed to dynamically-imported frontend plugins
+// (plan 03): the import map maps the bare `svelte` specifier to this module's
+// emitted chunk, whose star re-exports are the SAME module instances the app
+// bundles (one rollup graph → one runtime). Entry-module exports are
+// preserved in full, so plugins can import bindings the app never uses.
+export * from "svelte";
diff --git a/src/plugin-runtime/svelte-internal-client.ts b/src/plugin-runtime/svelte-internal-client.ts
new file mode 100644
index 0000000..cc6b4d6
--- /dev/null
+++ b/src/plugin-runtime/svelte-internal-client.ts
@@ -0,0 +1,12 @@
+// The compiled-runes runtime exposed to dynamically-imported frontend
+// plugins (plan 03): the import map maps the bare `svelte/internal/client`
+// specifier to this module's emitted chunk — the same runtime instance the
+// app's own compiled components use.
+//
+// `svelte/internal/client` is intentionally untyped (the compiled-runes
+// runtime — its API is compiler-private), so the re-export needs the
+// suppression below. Nothing in the app imports THROUGH this module — the
+// import map hands it to plugins; its entry exports are preserved in full.
+//
+// @ts-expect-error — svelte/internal/client ships no declaration file
+export * from "svelte/internal/client";