diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index adb6038f..3a576cad 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -64,6 +64,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index c1a67354..1c5659f6 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -149,6 +149,7 @@ import {
Clapperboard,
GitBranch,
Store,
+ Puzzle,
} from 'lucide-react';
/**
@@ -181,6 +182,8 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
+ // Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
+ { label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
];
/**
diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx
new file mode 100644
index 00000000..240bc4c3
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx
@@ -0,0 +1,25 @@
+import type { LayoutNode } from 'officerdev';
+import { WorkspaceView } from 'officerdev';
+import { useDashboardState } from 'state/useDashboardState';
+import { defaultLayout } from './defaultLayout';
+
+// /plugins — what is in the tree, what is installed, and the four verbs that change it.
+//
+// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
+// handler. This screen is the courtesy half of that.
+//
+// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
+// questions; this installs plugins from `platform/plugins/`, and asks nothing.
+export const PluginsScreen = () => {
+ const workspace = useDashboardState('screens/plugins', defaultLayout);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts
new file mode 100644
index 00000000..044aab24
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts
@@ -0,0 +1,14 @@
+import type { LayoutNode } from 'officerdev';
+
+// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
+// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
+// and destroy the side-by-side. See docs/navigation-audit.md.
+export const defaultLayout: LayoutNode = {
+ type: 'group',
+ id: 'plugins-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
+ { node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
+ ],
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx b/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx
new file mode 100644
index 00000000..b191bc22
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx
@@ -0,0 +1 @@
+export * from './PluginsScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index edc23121..51a8304a 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -1,4 +1,5 @@
export * from './AppStore';
+export * from './Plugins';
export * from './Layout';
export * from './Home';
export * from './PasskeyGate';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 3660b0e0..9219b8a1 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
+ { match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index 5a2ba79a..e1dae034 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -1,4 +1,5 @@
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
+import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
@@ -45,6 +46,7 @@ export const apps = [
...qrTransferMetas,
...davMetas,
...appStoreMetas,
+ ...pluginsMetas,
];
/**
diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx
new file mode 100644
index 00000000..74c1e2b2
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx
@@ -0,0 +1,131 @@
+import { useSearchParams } from 'react-router';
+import { usePlugins, type PluginItem } from './usePlugins';
+
+// The right panel: one plugin, and the four verbs.
+//
+// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
+// anything, so they cannot disagree.
+
+const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
+
+
+ {/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
+ leaving someone to guess whether the button destroys their data. */}
+ {plugin.installed ? (
+
+ Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes
+ anything the plugin stored.
+
+ ) : null}
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx
new file mode 100644
index 00000000..57d17daf
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx
@@ -0,0 +1,63 @@
+import { Link, useSearchParams } from 'react-router';
+import { Puzzle, AlertTriangle } from 'lucide-react';
+import { usePlugins, type PluginItem } from './usePlugins';
+
+// The left panel: every plugin in the tree, installed or not.
+//
+// Rows are real ``s carrying `?selected=`, not buttons with the name in a closure — so cmd-click,
+// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told.
+// See docs/navigation-audit.md on the opaque-click anti-pattern.
+
+const Status = ({ plugin }: { plugin: PluginItem }) => {
+ if (!plugin.installed) return not installed;
+ if (!plugin.enabled) return disabled;
+ if (plugin.outdated) return update available;
+ return enabled;
+};
+
+export const PluginsList = () => {
+ const { plugins, broken, isLoading } = usePlugins();
+ const [params] = useSearchParams();
+ const selected = params.get('selected');
+
+ if (isLoading) return
+
+
+ ))}
+
+ {/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
+ manifest looks exactly like a plugin nobody wrote. */}
+ {broken.map((b) => (
+
+
+
+
{b.appName}
+
{b.error}
+
+
+ ))}
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Plugins/index.ts b/src/workspaces/officerdev/src/apps/Plugins/index.ts
new file mode 100644
index 00000000..f95c5c1c
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Plugins/index.ts
@@ -0,0 +1,17 @@
+import { Puzzle } from 'lucide-react';
+import type { AppRegistryMeta } from '../../AppRegistry';
+import { PluginsList } from './PluginsList';
+import { PluginDetail } from './PluginDetail';
+
+export { PluginsList } from './PluginsList';
+export { PluginDetail } from './PluginDetail';
+export { usePlugins } from './usePlugins';
+export type { PluginItem, PluginPermission } from './usePlugins';
+
+// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and
+// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
+// /plugins, together.
+export const appRegistryMetas: AppRegistryMeta[] = [
+ { key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
+ { key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
+];
diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts
new file mode 100644
index 00000000..fe20fc3e
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts
@@ -0,0 +1,81 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useClient } from 'hooks/useClient';
+
+// Reading and driving the plugin system. One query, four verbs.
+//
+// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
+// asking questions; this installs plugins from the tree and asks nothing.
+
+export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
+
+export type PluginItem = {
+ appName: string;
+ /** Where its routes live. `/offscale` for ours, `/p//` for everyone else. */
+ prefix: string;
+ label: string;
+ summary: string;
+ icon: string;
+ color: string;
+ publisher: string;
+ version: string;
+ platform: string;
+ permissions: PluginPermission[];
+ /** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
+ has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
+ installed: boolean;
+ enabled: boolean;
+ installedVersion: string | null;
+ /** The code on disk moved after it was installed — normal while developing, and worth seeing. */
+ outdated: boolean;
+};
+
+export type BrokenPlugin = { appName: string; error: string };
+
+const PLUGINS_KEY = ['plugins'];
+
+export function usePlugins() {
+ const client = useClient();
+ const queryClient = useQueryClient();
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: PLUGINS_KEY,
+ queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
+ });
+
+ // Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
+ // and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
+ queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
+ };
+
+ // Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a
+ // helper is a rules-of-hooks violation even when the call order happens to be stable.
+ const install = useMutation({
+ mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}),
+ onSuccess: invalidate,
+ });
+ const uninstall = useMutation({
+ mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}),
+ onSuccess: invalidate,
+ });
+ const enable = useMutation({
+ mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}),
+ onSuccess: invalidate,
+ });
+ const disable = useMutation({
+ mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}),
+ onSuccess: invalidate,
+ });
+
+ return {
+ plugins: data?.plugins ?? [],
+ broken: data?.broken ?? [],
+ isLoading,
+ error,
+ install,
+ uninstall,
+ enable,
+ disable,
+ };
+}