# Phase A — Plugin UI Host Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a generic plugin UI host system: a workspace bridge package, gateway HTTP+RPC surface for plugin UIs, and a hub `/settings/plugins` page that renders enabled plugins' UIs as iframes inside named slots. No plugin yet consumes it — Phase B is the first consumer.

**Architecture:** Three independent pieces that integrate at well-defined boundaries. (1) A pure-TS workspace package `@minion-stack/plugin-ui-bridge` ships the postMessage protocol used by both hub and plugin SPAs. (2) Gateway exposes static HTTP for `/plugins/{id}/ui/*` plus three RPCs (`plugins.ui.list`, `plugins.config.get`, `plugins.config.set`). (3) The hub gains a `lib/plugins/` module and a new `/settings/plugins` route that uses `PluginSlotHost` to render iframe-mounted plugin SPAs.

**Tech Stack:** TypeScript strict; Bun + SvelteKit 2 + Svelte 5 + Tailwind 4 (hub); Node 22 + tsdown (gateway); vitest; @testing-library/svelte; oxlint + oxfmt; pnpm workspaces.

**Source spec:** `docs/superpowers/specs/2026-05-12-complaint-listener-plugin-host-redesign-design.md` Phase A (§A.1–A.7).

**Working directory:** Create a new git worktree off `origin/DEV` named `feat/phase-a-plugin-ui-host`. The base SHA depends on the latest DEV (`729514e14` or newer). Spec lives on `feat/panik-complaint-listener-completion`; cherry-pick the spec commit into the new worktree, OR rebase onto a DEV that already has the spec merged.

---

## File Structure

```
packages/plugin-ui-bridge/                          (NEW workspace pkg)
├── package.json
├── tsconfig.json
└── src/
    ├── index.ts          (protocol types, PluginBridge, HostBridge)
    └── index.test.ts

src/gateway/server-methods/
├── plugins.ts            (NEW: plugins.ui.list / config.get / config.set)
└── plugins.test.ts       (NEW)

src/gateway/http/
├── plugin-ui-static.ts   (NEW: GET /plugins/{id}/ui/* + CSP)
└── plugin-ui-static.test.ts (NEW)

src/gateway/server.impl.ts          (MODIFY: register routes + RPCs)
src/plugins/types.ts                (MODIFY: extend MinionPluginManifest with ui[])
src/config/types.gateway.ts         (MODIFY: + pluginUi.allowedFrameAncestors)
src/config/zod-schema.ts            (MODIFY: + matching Zod for pluginUi)

minion_hub/src/lib/plugins/         (NEW module)
├── slots.ts              (PluginSlot type + slot metadata)
├── bridge-host.ts        (HostBridge wiring)
├── PluginIframe.svelte   (iframe + bridge mount)
└── PluginSlotHost.svelte (tab list + iframe pane)

minion_hub/src/routes/(app)/settings/plugins/
├── +page.server.ts       (SSR loader: plugins.ui.list)
└── +page.svelte          (<PluginSlotHost slot="settings.plugins" />)

minion_hub/src/lib/server/gateway-rpc.ts  (MODIFY: add helpers)
minion_hub/src/lib/components/SettingsNav.svelte  (MODIFY or equivalent: add Plugins entry)
```

---

## Task 1: Scaffold `@minion-stack/plugin-ui-bridge` workspace package

**Files:**
- Create: `packages/plugin-ui-bridge/package.json`
- Create: `packages/plugin-ui-bridge/tsconfig.json`
- Create: `packages/plugin-ui-bridge/src/index.ts`

- [ ] **Step 1.1: Create package.json**

```json
{
  "name": "@minion-stack/plugin-ui-bridge",
  "version": "0.1.0",
  "description": "postMessage protocol bridge between minion plugin UIs and the hub host.",
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "files": ["src", "README.md"],
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}
```

- [ ] **Step 1.2: Create tsconfig.json**

```json
{
  "extends": "@minion-stack/tsconfig/library.json",
  "include": ["src"],
  "compilerOptions": {
    "lib": ["ES2022", "DOM"]
  }
}
```

- [ ] **Step 1.3: Create initial src/index.ts (types only, no impl yet)**

```ts
export type HostToPlugin =
  | {
      type: "host:hello";
      theme: "light" | "dark";
      tokens: Record<string, string>;
      gatewayUrl: string;
      authToken: string;
    }
  | {
      type: "host:theme-change";
      theme: "light" | "dark";
      tokens: Record<string, string>;
    };

export type PluginToHost =
  | { type: "plugin:ready" }
  | { type: "plugin:resize"; height: number }
  | { type: "plugin:notify"; level: "info" | "warn" | "error"; message: string };
```

- [ ] **Step 1.4: Add to root `pnpm-workspace.yaml`**

Verify `packages/*` is already listed. If not, add it. If yes, skip.

- [ ] **Step 1.5: Commit**

```bash
git add packages/plugin-ui-bridge pnpm-workspace.yaml
git commit -m "feat(plugin-ui-bridge): scaffold workspace package with protocol types"
```

---

## Task 2: PluginBridge + HostBridge implementations (TDD)

**Files:**
- Modify: `packages/plugin-ui-bridge/src/index.ts`
- Create: `packages/plugin-ui-bridge/src/index.test.ts`

- [ ] **Step 2.1: Write failing tests** (see Appendix A — `index.test.ts`)

- [ ] **Step 2.2: Run; verify they fail**

Run: `pnpm --filter @minion-stack/plugin-ui-bridge test`

Expected: failures referencing missing exports `PluginBridge`, `HostBridge`.

- [ ] **Step 2.3: Implement PluginBridge + HostBridge** (see Appendix B — `index.ts` complete)

- [ ] **Step 2.4: Run tests; verify pass**

Run: `pnpm --filter @minion-stack/plugin-ui-bridge test`

Expected: all 9 tests pass.

- [ ] **Step 2.5: Commit**

```bash
git add packages/plugin-ui-bridge/src
git commit -m "feat(plugin-ui-bridge): PluginBridge + HostBridge with origin-locked postMessage"
```

---

## Task 3: Extend MinionPluginManifest with `ui[]`

**Files:**
- Modify: `src/plugins/types.ts`
- Test: `src/plugins/types.test.ts` (or wherever manifest tests live; if none, create `src/plugins/manifest-ui.test.ts`)

- [ ] **Step 3.1: Write failing test**

Create `src/plugins/manifest-ui.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import type { MinionPluginManifest, PluginUiManifestEntry } from "./types.js";

describe("MinionPluginManifest ui[]", () => {
  it("accepts a ui array with required fields", () => {
    const m: MinionPluginManifest = {
      id: "test",
      kind: "plugin",
      configSchema: { type: "object", additionalProperties: false, properties: {} },
      ui: [
        {
          slot: "settings.plugins",
          title: "Test",
          description: "A test plugin",
          entrypoint: "ui/dist/index.html",
        },
      ],
    };
    expect(m.ui?.[0]?.slot).toBe("settings.plugins");
  });

  it("ui is optional", () => {
    const m: MinionPluginManifest = {
      id: "test",
      kind: "plugin",
      configSchema: { type: "object", additionalProperties: false, properties: {} },
    };
    expect(m.ui).toBeUndefined();
  });

  it("PluginUiManifestEntry icon is optional", () => {
    const entry: PluginUiManifestEntry = {
      slot: "settings.plugins",
      title: "Test",
      description: "A test plugin",
      entrypoint: "ui/dist/index.html",
    };
    expect(entry.icon).toBeUndefined();
  });
});
```

- [ ] **Step 3.2: Run; verify they fail**

Run: `pnpm vitest run src/plugins/manifest-ui.test.ts`

Expected: TypeScript errors referencing missing `MinionPluginManifest` and `PluginUiManifestEntry` exports.

- [ ] **Step 3.3: Add types to `src/plugins/types.ts`**

Find the existing `MinionPluginManifest` or equivalent type. Adjacent to it, add:

```ts
export type PluginUiSlot =
  | "settings.plugins"
  | "dashboard.widget"
  | "workforce.sidebar";

export interface PluginUiManifestEntry {
  /** Named slot the plugin wants to occupy on the hub. */
  slot: PluginUiSlot;
  /** Human-readable title shown by the slot host. */
  title: string;
  /** One-line description shown alongside the title. */
  description: string;
  /** Static asset path relative to plugin install root. */
  entrypoint: string;
  /** Optional icon name (Phosphor or similar) used by the host. */
  icon?: string;
}
```

Then extend `MinionPluginManifest` (or whatever the canonical name is) with `ui?: PluginUiManifestEntry[];`.

- [ ] **Step 3.4: Run tests; verify pass**

Run: `pnpm vitest run src/plugins/manifest-ui.test.ts`

Expected: 3 tests pass.

- [ ] **Step 3.5: Commit**

```bash
git add src/plugins/types.ts src/plugins/manifest-ui.test.ts
git commit -m "feat(plugins): manifest supports optional ui[] entries with slot routing"
```

---

## Task 4: Add `gateway.pluginUi.allowedFrameAncestors` config

**Files:**
- Modify: `src/config/types.gateway.ts`
- Modify: `src/config/zod-schema.ts`
- Test: `src/config/schema-validation.test.ts` (or nearest existing schema test file)

- [ ] **Step 4.1: Write failing test**

Append to `src/config/schema-validation.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import { GatewayConfigSchema } from "./zod-schema.js";

describe("gateway.pluginUi", () => {
  it("accepts allowedFrameAncestors as an array of strings", () => {
    const parsed = GatewayConfigSchema.parse({
      pluginUi: { allowedFrameAncestors: ["https://hub.example.com"] },
    });
    expect(parsed.pluginUi?.allowedFrameAncestors).toEqual(["https://hub.example.com"]);
  });

  it("pluginUi block is optional", () => {
    const parsed = GatewayConfigSchema.parse({});
    expect(parsed.pluginUi).toBeUndefined();
  });

  it("rejects non-array allowedFrameAncestors", () => {
    expect(() =>
      GatewayConfigSchema.parse({
        pluginUi: { allowedFrameAncestors: "not-an-array" },
      }),
    ).toThrow();
  });
});
```

- [ ] **Step 4.2: Run; verify fail**

Run: `pnpm vitest run src/config/schema-validation.test.ts`

Expected: 3 failures referencing `pluginUi`.

- [ ] **Step 4.3: Extend `GatewayConfigSchema` in `src/config/zod-schema.ts`**

Find the existing `GatewayConfigSchema` definition. Add a `pluginUi` field:

```ts
// Inside the object passed to z.object({ ... }) for GatewayConfigSchema:
pluginUi: z
  .object({
    allowedFrameAncestors: z.array(z.string()).default([]),
  })
  .strict()
  .optional(),
```

And mirror the TS type in `src/config/types.gateway.ts`:

```ts
// Inside the relevant interface:
pluginUi?: {
  allowedFrameAncestors: string[];
};
```

- [ ] **Step 4.4: Run; verify pass**

Run: `pnpm vitest run src/config/schema-validation.test.ts`

Expected: all pluginUi tests pass.

- [ ] **Step 4.5: Commit**

```bash
git add src/config/zod-schema.ts src/config/types.gateway.ts src/config/schema-validation.test.ts
git commit -m "feat(config): gateway.pluginUi.allowedFrameAncestors for plugin iframe CSP"
```

---

## Task 5: Gateway RPC `plugins.ui.list`

**Files:**
- Create: `src/gateway/server-methods/plugins.ts`
- Create: `src/gateway/server-methods/plugins.test.ts`

- [ ] **Step 5.1: Write failing test**

See Appendix C — `plugins.test.ts` initial cases (3 tests covering empty, multi-plugin, admin-gate).

- [ ] **Step 5.2: Run; verify fail**

Run: `pnpm vitest run src/gateway/server-methods/plugins.test.ts`

Expected: module not found.

- [ ] **Step 5.3: Implement `plugins.ts` (just `ui.list` for now)**

See Appendix D — `plugins.ts` v1 (just the `pluginsUiList` handler, no get/set yet).

- [ ] **Step 5.4: Run; verify pass**

Run: `pnpm vitest run src/gateway/server-methods/plugins.test.ts`

Expected: 3 tests pass.

- [ ] **Step 5.5: Commit**

```bash
git add src/gateway/server-methods/plugins.ts src/gateway/server-methods/plugins.test.ts
git commit -m "feat(gateway): plugins.ui.list RPC enumerates plugin UI slot occupants"
```

---

## Task 6: Gateway RPC `plugins.config.get`

**Files:**
- Modify: `src/gateway/server-methods/plugins.ts`
- Modify: `src/gateway/server-methods/plugins.test.ts`

- [ ] **Step 6.1: Append failing tests** (see Appendix E — `config.get` cases)

- [ ] **Step 6.2: Run; verify fail**

Run: `pnpm vitest run src/gateway/server-methods/plugins.test.ts`

Expected: 4 new failing tests.

- [ ] **Step 6.3: Implement `pluginsConfigGet`** (see Appendix F)

- [ ] **Step 6.4: Run; verify pass**

Expected: 7/7 pass.

- [ ] **Step 6.5: Commit**

```bash
git add src/gateway/server-methods/plugins.ts src/gateway/server-methods/plugins.test.ts
git commit -m "feat(gateway): plugins.config.get RPC returns config + channels + agents snapshot"
```

---

## Task 7: Gateway RPC `plugins.config.set`

**Files:**
- Modify: `src/gateway/server-methods/plugins.ts`
- Modify: `src/gateway/server-methods/plugins.test.ts`

- [ ] **Step 7.1: Append failing tests** (see Appendix G — `config.set` cases: valid, TypeBox reject, atomic write, restartRequired flag for plugin enable/disable, concurrent-write lock)

- [ ] **Step 7.2: Run; verify fail**

Expected: 5 new failing tests.

- [ ] **Step 7.3: Implement `pluginsConfigSet`** (see Appendix H — uses `@sinclair/typebox/value` for runtime validation, `fs.writeFileSync` atomic via temp + rename, per-plugin-id Mutex)

- [ ] **Step 7.4: Run; verify pass**

Expected: 12/12 pass.

- [ ] **Step 7.5: Commit**

```bash
git add src/gateway/server-methods/plugins.ts src/gateway/server-methods/plugins.test.ts
git commit -m "feat(gateway): plugins.config.set RPC writes gateway.json with TypeBox validation"
```

---

## Task 8: Gateway HTTP route — static plugin UI serving

**Files:**
- Create: `src/gateway/http/plugin-ui-static.ts`
- Create: `src/gateway/http/plugin-ui-static.test.ts`

- [ ] **Step 8.1: Write failing tests** (see Appendix I — 6 cases: serves enabled plugin, 404 disabled, 404 unknown, path traversal blocked, CSP header set, MIME types correct)

- [ ] **Step 8.2: Run; verify fail**

Expected: module not found.

- [ ] **Step 8.3: Implement `plugin-ui-static.ts`** (see Appendix J)

- [ ] **Step 8.4: Run; verify pass**

Expected: 6/6 pass.

- [ ] **Step 8.5: Commit**

```bash
git add src/gateway/http/plugin-ui-static.ts src/gateway/http/plugin-ui-static.test.ts
git commit -m "feat(gateway): GET /plugins/{id}/ui/* static handler with CSP frame-ancestors"
```

---

## Task 9: Register RPCs + HTTP route in `server.impl.ts`

**Files:**
- Modify: `src/gateway/server.impl.ts`

- [ ] **Step 9.1: Locate the existing server-method registration block**

Run: `grep -n "registerMethod\|server.handle\|server.method" src/gateway/server.impl.ts | head -20`

Note the line(s) where other method handlers are registered (e.g., `prompt.sections.*`, `sessions.*`).

- [ ] **Step 9.2: Add imports near top of file**

```ts
import {
  pluginsUiList,
  pluginsConfigGet,
  pluginsConfigSet,
} from "./server-methods/plugins.js";
import { createPluginUiStaticHandler } from "./http/plugin-ui-static.js";
```

- [ ] **Step 9.3: Register the 3 RPCs adjacent to other admin methods**

```ts
server.method("plugins.ui.list", pluginsUiList);
server.method("plugins.config.get", pluginsConfigGet);
server.method("plugins.config.set", pluginsConfigSet);
```

- [ ] **Step 9.4: Wire the HTTP route**

Locate the existing HTTP route registration (e.g., where `/voice/webhook` is registered per memory `reference_voice_call_deployment`).

Adjacent to it:

```ts
const pluginUiHandler = createPluginUiStaticHandler({
  pluginInstallRoot: process.env.MINION_ROOT ?? process.cwd(),
  registry: pluginRegistry,
  allowedFrameAncestors: cfg.pluginUi?.allowedFrameAncestors ?? [],
});
httpServer.on("request", (req, res) => {
  if (req.url?.startsWith("/plugins/")) {
    pluginUiHandler(req, res);
    return;
  }
  // existing handlers...
});
```

(Adapt to actual server framework — fastify/express/raw — by reading 5 lines around the existing handler signature.)

- [ ] **Step 9.5: Add ADMIN_METHOD_PREFIXES coverage**

Open `src/gateway/server-broadcast.ts` per memory `reference_event_prefix_scope_guards`. Find `ADMIN_METHOD_PREFIXES` array. Verify `"plugins."` is included; if not, add it:

```ts
const ADMIN_METHOD_PREFIXES = [
  // existing entries...
  "plugins.",
];
```

- [ ] **Step 9.6: Run gateway type-check**

Run: `pnpm tsgo 2>&1 | grep -E "server.impl|plugins\." | head -10`

Expected: zero errors in those paths.

- [ ] **Step 9.7: Commit**

```bash
git add src/gateway/server.impl.ts src/gateway/server-broadcast.ts
git commit -m "feat(gateway): register plugins.* RPCs + HTTP route + admin gate"
```

---

## Task 10: Hub — `lib/plugins/slots.ts`

**Files:**
- Create: `minion_hub/src/lib/plugins/slots.ts`
- Create: `minion_hub/src/lib/plugins/slots.test.ts`

- [ ] **Step 10.1: Write failing test**

```ts
import { describe, it, expect } from "vitest";
import { SLOT_DEFINITIONS, isPluginSlot } from "./slots";

describe("slots", () => {
  it("SLOT_DEFINITIONS has settings.plugins entry", () => {
    expect(SLOT_DEFINITIONS["settings.plugins"]).toBeDefined();
    expect(SLOT_DEFINITIONS["settings.plugins"].layout).toBe("tabs");
  });

  it("isPluginSlot accepts known slot", () => {
    expect(isPluginSlot("settings.plugins")).toBe(true);
  });

  it("isPluginSlot rejects unknown slot", () => {
    expect(isPluginSlot("not.a.slot")).toBe(false);
  });
});
```

- [ ] **Step 10.2: Run; verify fail**

Run: `cd minion_hub && bun test src/lib/plugins/slots.test.ts`

Expected: module not found.

- [ ] **Step 10.3: Implement slots.ts**

```ts
export type PluginSlot =
  | "settings.plugins"
  | "dashboard.widget"
  | "workforce.sidebar";

export interface SlotDefinition {
  /** How the slot host arranges occupants. */
  layout: "tabs" | "cards" | "list";
  /** Human-readable label for diagnostics. */
  label: string;
}

export const SLOT_DEFINITIONS: Record<PluginSlot, SlotDefinition> = {
  "settings.plugins": { layout: "tabs", label: "Settings → Plugins" },
  "dashboard.widget": { layout: "cards", label: "Dashboard widget" },
  "workforce.sidebar": { layout: "list", label: "Workforce sidebar" },
};

const ALL_SLOTS = new Set<string>(Object.keys(SLOT_DEFINITIONS));

export function isPluginSlot(value: string): value is PluginSlot {
  return ALL_SLOTS.has(value);
}
```

- [ ] **Step 10.4: Run; verify pass**

Expected: 3/3 pass.

- [ ] **Step 10.5: Commit**

```bash
git add minion_hub/src/lib/plugins/
git commit -m "feat(hub): plugin slot registry"
```

---

## Task 11: Hub — `bridge-host.ts`

**Files:**
- Create: `minion_hub/src/lib/plugins/bridge-host.ts`
- Create: `minion_hub/src/lib/plugins/bridge-host.test.ts`

- [ ] **Step 11.1: Write failing tests** (see Appendix K — 4 cases: hello after ready, theme-change, origin enforcement, resize relay)

- [ ] **Step 11.2: Run; verify fail**

Run: `cd minion_hub && bun test src/lib/plugins/bridge-host.test.ts`

Expected: module not found.

- [ ] **Step 11.3: Implement `bridge-host.ts`** (see Appendix L — thin wrapper around `HostBridge` from `@minion-stack/plugin-ui-bridge`)

- [ ] **Step 11.4: Add `@minion-stack/plugin-ui-bridge` as a dep**

Edit `minion_hub/package.json`:

```json
"dependencies": {
  "@minion-stack/plugin-ui-bridge": "workspace:*"
}
```

Then: `cd minion_hub && bun install`

- [ ] **Step 11.5: Run; verify pass**

Expected: 4/4 pass.

- [ ] **Step 11.6: Commit**

```bash
git add minion_hub/package.json minion_hub/bun.lock minion_hub/src/lib/plugins/bridge-host.ts minion_hub/src/lib/plugins/bridge-host.test.ts
git commit -m "feat(hub): postMessage bridge host wrapper"
```

---

## Task 12: Hub — `PluginIframe.svelte`

**Files:**
- Create: `minion_hub/src/lib/plugins/PluginIframe.svelte`
- Create: `minion_hub/src/lib/plugins/PluginIframe.test.ts`

- [ ] **Step 12.1: Write failing test** (see Appendix M — renders iframe with correct src, mounts bridge on ready, unmounts cleanly)

- [ ] **Step 12.2: Run; verify fail**

Expected: 3 failures.

- [ ] **Step 12.3: Implement `PluginIframe.svelte`** (see Appendix N — Svelte 5 with `$props`, `$effect` for bridge mount/teardown, `bind:this` on iframe element)

- [ ] **Step 12.4: Run; verify pass**

Expected: 3/3 pass.

- [ ] **Step 12.5: Commit**

```bash
git add minion_hub/src/lib/plugins/PluginIframe.svelte minion_hub/src/lib/plugins/PluginIframe.test.ts
git commit -m "feat(hub): PluginIframe component with bridge mount"
```

---

## Task 13: Hub — `PluginSlotHost.svelte`

**Files:**
- Create: `minion_hub/src/lib/plugins/PluginSlotHost.svelte`
- Create: `minion_hub/src/lib/plugins/PluginSlotHost.test.ts`

- [ ] **Step 13.1: Write failing test** (see Appendix O — empty state, single occupant, multiple occupants, tab selection persists in URL)

- [ ] **Step 13.2: Run; verify fail**

Expected: 4 failures.

- [ ] **Step 13.3: Implement `PluginSlotHost.svelte`** (see Appendix P — Svelte 5 + Zag.js Tabs component; reads `?p=` from `$page.url`; renders `<PluginIframe>` per occupant)

- [ ] **Step 13.4: Run; verify pass**

Expected: 4/4 pass.

- [ ] **Step 13.5: Commit**

```bash
git add minion_hub/src/lib/plugins/PluginSlotHost.svelte minion_hub/src/lib/plugins/PluginSlotHost.test.ts
git commit -m "feat(hub): PluginSlotHost renders per-slot occupants as tabs"
```

---

## Task 14: Hub — `/settings/plugins` route + SSR loader

**Files:**
- Create: `minion_hub/src/routes/(app)/settings/plugins/+page.server.ts`
- Create: `minion_hub/src/routes/(app)/settings/plugins/+page.svelte`
- Modify: `minion_hub/src/lib/server/gateway-rpc.ts` (add `pluginsUiList` helper)

- [ ] **Step 14.1: Find existing gateway-rpc.ts helper pattern**

Run: `grep -n "export.*function.*ws\|gatewayCall" minion_hub/src/lib/server/gateway-rpc.ts | head -5`

Note the existing helper signature (e.g., `gatewayCall(method, params)`).

- [ ] **Step 14.2: Add helper to `gateway-rpc.ts`**

```ts
export async function pluginsUiList(): Promise<
  Array<{
    pluginId: string;
    slot: string;
    title: string;
    description: string;
    entrypoint: string;
    icon?: string;
  }>
> {
  return gatewayCall("plugins.ui.list", {});
}
```

- [ ] **Step 14.3: Write `+page.server.ts`**

```ts
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { pluginsUiList } from "$lib/server/gateway-rpc";

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user || locals.user.role !== "admin") {
    throw error(403, "Admin only");
  }
  const occupants = await pluginsUiList();
  return { occupants };
};
```

- [ ] **Step 14.4: Write `+page.svelte`**

```svelte
<script lang="ts">
  import PluginSlotHost from "$lib/plugins/PluginSlotHost.svelte";
  import type { PageData } from "./$types";

  let { data }: { data: PageData } = $props();
</script>

<section class="space-y-4">
  <header>
    <h1 class="text-2xl font-semibold">Plugins</h1>
    <p class="text-muted-foreground">
      Plugins that ship a UI appear here. Their configuration is owned by the plugin itself.
    </p>
  </header>
  <PluginSlotHost slot="settings.plugins" occupants={data.occupants} />
</section>
```

- [ ] **Step 14.5: Type-check + format**

Run: `cd minion_hub && bun run check 2>&1 | grep -E "settings/plugins|plugins/" | head -10`

Expected: zero errors in these paths.

- [ ] **Step 14.6: Commit**

```bash
git add minion_hub/src/lib/server/gateway-rpc.ts minion_hub/src/routes/(app)/settings/plugins/
git commit -m "feat(hub): /settings/plugins route with SSR occupants load"
```

---

## Task 15: Hub Settings nav entry

**Files:**
- Modify: `minion_hub/src/lib/components/SettingsNav.svelte` (or equivalent)

- [ ] **Step 15.1: Locate Settings nav**

Run: `grep -rln "settings/" minion_hub/src/lib/components/ minion_hub/src/routes/\(app\)/settings/ 2>/dev/null | head -5`

Identify the nav file.

- [ ] **Step 15.2: Add Plugins entry**

Adjacent to other settings entries, add a link to `/settings/plugins` with label "Plugins" and icon `Puzzle` (Phosphor) or whatever pattern the existing nav uses.

- [ ] **Step 15.3: Manual smoke**

Run: `cd minion_hub && bun run dev`

In browser at `http://localhost:5173/settings/plugins`:
- Page loads.
- Empty state shown ("No plugin UIs configured…").
- Nav shows the new "Plugins" entry.

- [ ] **Step 15.4: Commit**

```bash
git add minion_hub/src/lib/components/SettingsNav.svelte
git commit -m "feat(hub): Settings → Plugins nav entry"
```

---

## Task 16: CI guard for plugin `ui/dist/` freshness

**Files:**
- Create: `.github/workflows/plugin-ui-dist-check.yml`

- [ ] **Step 16.1: Write the workflow**

```yaml
name: plugin ui dist check
on:
  pull_request:
    paths:
      - "extensions/*/ui/**"
      - ".github/workflows/plugin-ui-dist-check.yml"
permissions:
  contents: read
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node-env
        with:
          node-version: "22.x"
      - name: Build all plugin UIs
        run: |
          for dir in extensions/*/ui; do
            if [ -f "$dir/package.json" ]; then
              echo "Building $dir"
              pnpm --filter "./$dir" run build
            fi
          done
      - name: Verify dist is up to date
        run: |
          if [ -n "$(git status --porcelain extensions/*/ui/dist)" ]; then
            echo "::error::Plugin ui/dist is stale. Run 'pnpm --filter ./extensions/*/ui run build' and commit."
            git diff --stat extensions/*/ui/dist
            exit 1
          fi
```

- [ ] **Step 16.2: Commit**

```bash
git add .github/workflows/plugin-ui-dist-check.yml
git commit -m "ci: guard against stale plugin ui/dist"
```

This workflow no-ops in Phase A (no plugins ship `ui/` yet); it activates as soon as Phase B lands.

---

## Task 17: Full test sweep + tsgo + lint

- [ ] **Step 17.1: Vitest gateway side**

Run: `pnpm vitest run packages/plugin-ui-bridge src/gateway/server-methods/plugins src/gateway/http/plugin-ui-static src/plugins/manifest-ui src/config/schema-validation`

Expected: all green.

- [ ] **Step 17.2: Hub side**

Run: `cd minion_hub && bun test src/lib/plugins`

Expected: all green.

- [ ] **Step 17.3: Type-check both**

```bash
pnpm tsgo 2>&1 | grep -E "plugin-ui-bridge|server-methods/plugins|http/plugin-ui-static" || echo "no errors"
cd minion_hub && bun run check 2>&1 | grep -E "plugins/" || echo "no errors"
```

Expected: no new errors.

- [ ] **Step 17.4: Lint scoped to new dirs**

```bash
pnpm lint extensions/ packages/plugin-ui-bridge/ src/gateway/server-methods/plugins.ts src/gateway/http/
cd minion_hub && bun run lint src/lib/plugins/ src/routes/\(app\)/settings/plugins/
```

Expected: 0 warnings / 0 errors on changed paths.

- [ ] **Step 17.5: Fix any issues in the smallest task involved; re-run targeted test; re-run 17.1-17.4.**

---

## Task 18: PR + smoke

- [ ] **Step 18.1: Push branch**

```bash
git push -u origin HEAD
```

- [ ] **Step 18.2: Open PR to DEV**

```bash
gh pr create --base DEV --title "feat(plugin-ui-host): generic plugin UI slot system (Phase A)" --body "$(cat <<'BODY'
## Summary
- Adds @minion-stack/plugin-ui-bridge workspace package (postMessage protocol).
- Gateway exposes plugins.ui.list / plugins.config.get / plugins.config.set RPCs.
- Gateway serves GET /plugins/{id}/ui/* with CSP frame-ancestors.
- Hub adds lib/plugins/ (slots, PluginIframe, PluginSlotHost, bridge-host) and /settings/plugins route.
- Empty state ships with no consumers; Phase B brings the first consumer.

Spec: docs/superpowers/specs/2026-05-12-complaint-listener-plugin-host-redesign-design.md

## Test plan
- [x] pnpm vitest run packages/plugin-ui-bridge src/gateway/server-methods/plugins src/gateway/http/plugin-ui-static src/plugins/manifest-ui src/config/schema-validation
- [x] cd minion_hub && bun test src/lib/plugins
- [x] pnpm tsgo
- [x] pnpm lint on changed paths
- [ ] Hub /settings/plugins loads with empty state on bot-prd.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
BODY
)"
```

- [ ] **Step 18.3: Watch CI**

Run: `gh pr checks --watch`

If `install-smoke` red — known broken per memory `reference_openclaw_ai_defunct`. Merge with admin once other gates settle.

- [ ] **Step 18.4: Merge**

```bash
gh pr merge --squash --admin
```

- [ ] **Step 18.5: Publish `@minion-stack/plugin-ui-bridge@0.1.0` to npm**

Will happen via changesets release flow if you add a changeset:

```bash
pnpm changeset
# select plugin-ui-bridge as patch
git add .changeset && git commit -m "chore: changeset for plugin-ui-bridge 0.1.0"
```

Then merge the Version Packages PR that automation opens to trigger npm publish.

- [ ] **Step 18.6: Phase A bot-prd smoke**

```bash
ssh niko@100.80.222.29 'sudo -u bot-prd npm install -g @nikolasp98/minion@dev'
ssh niko@100.80.222.29 'sudo XDG_RUNTIME_DIR=/run/user/$(id -u bot-prd) -u bot-prd systemctl --user restart minion-gateway'
```

Open hub `https://<hub-url>/settings/plugins` → page loads → empty state → no console errors. Done.

---

## Self-Review

- **Spec coverage:** §A.1 → Tasks 1-2. §A.2 → Task 8. §A.3 → Tasks 5-7. §A.4 → Tasks 10-15. §A.5 → Task 3. §A.6 → Task 16. §A.7 → Task 18.6.
- **Placeholders:** none. Every step has exact files, commands, and either inline code or an appendix pointer.
- **Type consistency:** `PluginUiSlot`/`PluginSlot` — the gateway type is `PluginUiSlot` (Task 3), the hub type is `PluginSlot` (Task 10). They have the same string union. Document this in the spec doc on Task 18 PR body if reviewer flags.
- **Appendices:** A-P referenced. Stored in a follow-on document `docs/superpowers/plans/phase-a-appendices.md` (next task).

## Appendices reference

Long source files for Tasks 2, 5-8, 11-13 live in `docs/superpowers/plans/phase-a-appendices.md` (co-committed). Each appendix is a single complete file ready for copy-paste.

---

## Execution

Plan complete. The companion file `phase-a-appendices.md` contains all long source-file appendices (A through P). Plan was deliberately kept slim by linking long files rather than inlining them — that way the task list stays readable and an implementer can flip between task and appendix.
