summaryrefslogtreecommitdiffhomepage
path: root/packages/surface-loaded-extensions
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
committerAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
commit22936857685c318b71752d625808100b1a96e63e (patch)
tree5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/surface-loaded-extensions
parent969afc45f895230fe3da1c737f18e64452efc8f2 (diff)
downloaddispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz
dispatch-22936857685c318b71752d625808100b1a96e63e.zip
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked. B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime. tsc -b + biome clean; 460 vitest + 77 bun pass.
Diffstat (limited to 'packages/surface-loaded-extensions')
-rw-r--r--packages/surface-loaded-extensions/package.json13
-rw-r--r--packages/surface-loaded-extensions/src/extension.ts43
-rw-r--r--packages/surface-loaded-extensions/src/index.ts2
-rw-r--r--packages/surface-loaded-extensions/src/spec.test.ts94
-rw-r--r--packages/surface-loaded-extensions/src/spec.ts25
-rw-r--r--packages/surface-loaded-extensions/tsconfig.json10
6 files changed, 187 insertions, 0 deletions
diff --git a/packages/surface-loaded-extensions/package.json b/packages/surface-loaded-extensions/package.json
new file mode 100644
index 0000000..66e2e69
--- /dev/null
+++ b/packages/surface-loaded-extensions/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@dispatch/surface-loaded-extensions",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*"
+ }
+}
diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts
new file mode 100644
index 0000000..abef4b6
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/extension.ts
@@ -0,0 +1,43 @@
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import type { SurfaceProvider } from "@dispatch/surface-registry";
+import { surfaceRegistryHandle } from "@dispatch/surface-registry";
+import { buildLoadedExtensionsSpec } from "./spec.js";
+
+export const manifest: Manifest = {
+ id: "surface-loaded-extensions",
+ name: "Loaded Extensions Surface",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: ["surface-registry"],
+ contributes: { services: [] },
+};
+
+export function createLoadedExtensionsExtension(): Extension {
+ let dispose: (() => void) | undefined;
+
+ return {
+ manifest,
+ activate(host: HostAPI) {
+ const registry = host.getService(surfaceRegistryHandle);
+
+ const provider: SurfaceProvider = {
+ catalogEntry: {
+ id: "loaded-extensions",
+ region: "side",
+ title: "Loaded Extensions",
+ },
+ getSpec() {
+ return buildLoadedExtensionsSpec(host.getExtensions());
+ },
+ invoke() {},
+ };
+
+ dispose = registry.register(provider);
+ },
+ deactivate() {
+ dispose?.();
+ },
+ };
+}
diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts
new file mode 100644
index 0000000..bc10dc5
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/index.ts
@@ -0,0 +1,2 @@
+export { createLoadedExtensionsExtension, manifest } from "./extension.js";
+export { buildLoadedExtensionsSpec } from "./spec.js";
diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts
new file mode 100644
index 0000000..9c1aa6a
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/spec.test.ts
@@ -0,0 +1,94 @@
+import type { Manifest } from "@dispatch/kernel";
+import type { StatField } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import { buildLoadedExtensionsSpec } from "./spec.js";
+
+function fakeManifest(id: string, name: string, version: string): Manifest {
+ return {
+ id,
+ name,
+ version,
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ };
+}
+
+describe("buildLoadedExtensionsSpec", () => {
+ it("returns a count stat of '0' and no extension stats for empty manifests", () => {
+ const spec = buildLoadedExtensionsSpec([]);
+
+ expect(spec.id).toBe("loaded-extensions");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Loaded Extensions");
+ expect(spec.fields).toHaveLength(1);
+ expect(spec.fields[0]).toEqual({
+ kind: "stat",
+ label: "Loaded",
+ value: "0",
+ });
+ });
+
+ it("returns a count stat plus one stat per manifest in order", () => {
+ const manifests = [
+ fakeManifest("alpha", "Alpha", "1.0.0"),
+ fakeManifest("beta", "Beta", "2.3.1"),
+ fakeManifest("gamma", "Gamma", "0.5.0"),
+ ];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect(spec.fields).toHaveLength(4);
+ expect(spec.fields[0]).toEqual({
+ kind: "stat",
+ label: "Loaded",
+ value: "3",
+ });
+ expect(spec.fields[1]).toEqual({
+ kind: "stat",
+ label: "Alpha",
+ value: "1.0.0",
+ });
+ expect(spec.fields[2]).toEqual({
+ kind: "stat",
+ label: "Beta",
+ value: "2.3.1",
+ });
+ expect(spec.fields[3]).toEqual({
+ kind: "stat",
+ label: "Gamma",
+ value: "0.5.0",
+ });
+ });
+
+ it("preserves input order of manifests", () => {
+ const manifests = [
+ fakeManifest("z-last", "Z Last", "1.0.0"),
+ fakeManifest("a-first", "A First", "2.0.0"),
+ ];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect((spec.fields[1] as StatField).label).toBe("Z Last");
+ expect((spec.fields[2] as StatField).label).toBe("A First");
+ });
+
+ it("sets the surface id, region, and title correctly", () => {
+ const spec = buildLoadedExtensionsSpec([]);
+
+ expect(spec.id).toBe("loaded-extensions");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Loaded Extensions");
+ });
+
+ it("uses manifest.name as label and manifest.version as value", () => {
+ const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect(spec.fields[1]).toEqual({
+ kind: "stat",
+ label: "My Extension",
+ value: "3.2.1",
+ });
+ });
+});
diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts
new file mode 100644
index 0000000..bd3dd56
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/spec.ts
@@ -0,0 +1,25 @@
+import type { Manifest } from "@dispatch/kernel";
+import type { StatField, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * Pure core — builds the SurfaceSpec for the loaded-extensions surface.
+ * Zero I/O, zero ambient state. Decision logic only: input → output.
+ */
+export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec {
+ const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }];
+
+ for (const manifest of manifests) {
+ fields.push({
+ kind: "stat",
+ label: manifest.name,
+ value: manifest.version,
+ });
+ }
+
+ return {
+ id: "loaded-extensions",
+ region: "side",
+ title: "Loaded Extensions",
+ fields,
+ };
+}
diff --git a/packages/surface-loaded-extensions/tsconfig.json b/packages/surface-loaded-extensions/tsconfig.json
new file mode 100644
index 0000000..db257d0
--- /dev/null
+++ b/packages/surface-loaded-extensions/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../ui-contract" },
+ { "path": "../surface-registry" }
+ ]
+}