Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
94b126d
feat: add managed Agent Plugin install registry schema (plugins section)
ThomasK33 Aug 8, 2026
d24dc83
feat: AgentPluginInstallService + plugins oRPC namespace
ThomasK33 Aug 8, 2026
4f015c0
feat: Settings β†’ Plugins section + palette command
ThomasK33 Aug 8, 2026
3b9d724
docs: describe Settings β†’ Plugins install/update/uninstall flow
ThomasK33 Aug 8, 2026
b9c9a10
fix: address Codex review round 1
ThomasK33 Aug 8, 2026
edcdfa0
fix: address Codex review round 2
ThomasK33 Aug 8, 2026
a6503d2
fix: address Codex review round 3
ThomasK33 Aug 8, 2026
bc68e77
fix: address Codex review round 4
ThomasK33 Aug 8, 2026
5a2bd10
fix: address Codex review round 5
ThomasK33 Aug 8, 2026
0443e1a
fix: address Codex review round 6
ThomasK33 Aug 8, 2026
dc141c4
fix: address Codex review round 7
ThomasK33 Aug 8, 2026
b6daf6a
fix: address Codex review round 8
ThomasK33 Aug 8, 2026
f1fd47e
fix: address Codex review round 9
ThomasK33 Aug 8, 2026
b6965bc
fix: address Codex review round 10
ThomasK33 Aug 8, 2026
e576538
fix: address Codex review round 11
ThomasK33 Aug 9, 2026
8581764
fix: address Codex review round 12
ThomasK33 Aug 9, 2026
4b19552
fix: use a static type import in the pruning-failure test (lint)
ThomasK33 Aug 9, 2026
a22a9e0
fix: address Codex review round 13
ThomasK33 Aug 9, 2026
09fbbc2
fix: address Codex review round 14
ThomasK33 Aug 9, 2026
d005e8c
fix: address Codex review round 15
ThomasK33 Aug 9, 2026
28d8bc6
fix: address Codex review round 16
ThomasK33 Aug 9, 2026
c62f934
fix: address Codex review round 17
ThomasK33 Aug 9, 2026
a7e59c1
fix: address Codex review round 18
ThomasK33 Aug 9, 2026
9e9c94e
fix: address Codex review round 19 (P1)
ThomasK33 Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Enable the **Agent Plugins** experiment (Settings β†’ Experiments) to also disco

Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment).

Global plugins can be installed from git via **Settings β†’ Plugins** (paste a git URL or `owner/repo[@ref]`); the install preview lists every skill the plugin would contribute before anything is written.

## Skill layout

A skill is a directory named after the skill:
Expand Down
2 changes: 2 additions & 0 deletions docs/config/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ With the **Agent Plugins** experiment enabled (Settings β†’ Experiments), MCP se

Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.

**Settings β†’ Plugins** installs plugins from git into `~/.mux/plugins` (paste a git URL or `owner/repo[@ref]`). Before anything is written, a consent preview lists the plugin's manifest, every skill, and every MCP server command line. Installs are pinned to the resolved commit; update checks compare the tracked branch or tag against the pinned commit and never auto-apply. Applying an update replaces the plugin directory wholesale β€” local edits to a managed plugin directory are discarded β€” and restarts that plugin's running MCP servers. Uninstalling removes the directory, the registry entry, and the plugin's per-workspace server overrides, but keeps `~/.mux/plugin-data/` unless you opt in to deleting it.

## Behavior

- **Hot reload** β€” Config changes apply on your next message (no restart needed)
Expand Down
2 changes: 2 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ function AppInner() {

const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false);
const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES);
const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS);

// Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally;
// collapse remains a separate toggle and the drag handle is hidden in mobile-touch overlay mode.
Expand Down Expand Up @@ -992,6 +993,7 @@ function AppInner() {
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette,
multiProjectWorkspacesEnabled,
agentPluginsEnabled,
onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette,
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
Expand Down
17 changes: 13 additions & 4 deletions src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
// State for project servers and workspace overrides
const [servers, setServers] = useState<Record<string, MCPServerInfo>>({});
const [overrides, setOverrides] = useState<WorkspaceMCPOverrides>({});
// Revision of the loaded overrides snapshot. Saves pass it back so the
// backend can reject stale snapshots (e.g. after a plugin uninstall pruned
// this workspace's plugin: keys while the dialog was open).
const [overridesRevision, setOverridesRevision] = useState<string | null>(null);
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
Expand Down Expand Up @@ -66,7 +70,8 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
api.workspace.mcp.get({ workspaceId }),
]);
setServers(projectServers ?? {});
setOverrides(workspaceOverrides ?? {});
setOverrides(workspaceOverrides.overrides ?? {});
setOverridesRevision(workspaceOverrides.revision);
Comment thread
ThomasK33 marked this conversation as resolved.
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load MCP configuration");
} finally {
Expand Down Expand Up @@ -235,11 +240,15 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({

// Save overrides
const handleSave = useCallback(async () => {
if (!api) return;
if (!api || overridesRevision === null) return;
setSaving(true);
setError(null);
try {
const result = await api.workspace.mcp.set({ workspaceId, overrides });
const result = await api.workspace.mcp.set({
workspaceId,
overrides,
expectedRevision: overridesRevision,
});
if (!result.success) {
setError(result.error);
} else {
Expand All @@ -250,7 +259,7 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
} finally {
setSaving(false);
}
}, [api, workspaceId, overrides, onOpenChange]);
}, [api, workspaceId, overrides, overridesRevision, onOpenChange]);

const serverEntries = Object.entries(servers);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { useRef } from "react";
import type { FC, ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "@storybook/test";

import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
import { APIProvider, type APIClient } from "@/browser/contexts/API";
import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext";
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
import { createMockORPCClient, type MockORPCClientOptions } from "@/browser/stories/mocks/orpc";
import type { AgentPluginListItem } from "@/common/orpc/schemas/agentPlugins";

import { PluginsSettingsSection } from "./PluginsSettingsSection";

const MANAGED_ITEM: AgentPluginListItem = {
name: "grill",
managed: true,
present: true,
location: "~/.mux/plugins/grill",
version: "1.2.0",
description: "Relentlessly grills your plans before you commit to them.",
source: {
type: "git",
url: "https://github.com/example/grill.git",
ref: "main",
refType: "branch",
},
lockedSha: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
installedAt: "2026-08-01T12:00:00.000Z",
skillCount: 3,
mcpServerCount: 1,
};

const PINNED_ITEM: AgentPluginListItem = {
name: "deploy-tools",
managed: true,
present: true,
location: "~/.mux/plugins/deploy-tools",
version: "2.0.0",
source: {
type: "git",
url: "git@git.corp:infra/deploy-tools.git",
ref: "v2.0.0",
refType: "tag",
},
lockedSha: "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1",
installedAt: "2026-07-15T09:30:00.000Z",
skillCount: 0,
mcpServerCount: 2,
};

const UNMANAGED_ITEM: AgentPluginListItem = {
name: "handmade",
managed: false,
present: true,
location: "~/.agents/plugins/handmade",
description: "Copied into the container by hand; Mux lists it read-only.",
skillCount: 1,
mcpServerCount: 0,
};

const MISSING_ITEM: AgentPluginListItem = {
name: "vanished",
managed: true,
present: false,
location: "~/.mux/plugins/vanished",
version: "0.4.0",
source: {
type: "git",
url: "https://github.com/example/vanished.git",
ref: "main",
refType: "branch",
},
lockedSha: "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2",
installedAt: "2026-06-01T00:00:00.000Z",
skillCount: 0,
mcpServerCount: 0,
};

/** Valid max-length (64-char, separator-free) name: the worst case for narrow-width wrapping. */
const MAX_LENGTH_NAME = "a".repeat(64);
const MAX_LENGTH_ITEM: AgentPluginListItem = {
name: MAX_LENGTH_NAME,
managed: true,
present: true,
location: `~/.mux/plugins/${MAX_LENGTH_NAME}`,
version: "1.0.0",
source: {
type: "git",
url: `https://github.com/example/${MAX_LENGTH_NAME}.git`,
ref: "main",
refType: "branch",
},
lockedSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3",
installedAt: "2026-08-01T12:00:00.000Z",
skillCount: 1,
mcpServerCount: 0,
};

const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ({
options,
children,
}) => {
const clientRef = useRef<APIClient | null>(null);
clientRef.current ??= createMockORPCClient(options);

return (
<ThemeProvider>
<TooltipProvider>
<APIProvider client={clientRef.current}>
<ExperimentsProvider>{children}</ExperimentsProvider>
</APIProvider>
</TooltipProvider>
</ThemeProvider>
);
};

const meta: Meta<typeof PluginsSettingsSection> = {
title: "Features/Settings/Sections/PluginsSettingsSection",
component: PluginsSettingsSection,
parameters: {
layout: "fullscreen",
},
Comment thread
ThomasK33 marked this conversation as resolved.
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
render: () => (
<PluginsSectionStoryShell options={{ agentPlugins: { items: [] } }}>
<PluginsSettingsSection />
</PluginsSectionStoryShell>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Installed plugins");
await canvas.findByText("No plugins installed yet.");
},
};

export const InstalledWithUpdateStates: Story = {
render: () => (
<PluginsSectionStoryShell
options={{
agentPlugins: {
items: [MANAGED_ITEM, PINNED_ITEM, UNMANAGED_ITEM, MISSING_ITEM],
updateChecks: [
{
name: "grill",
status: "update-available",
remoteSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3",
},
{ name: "deploy-tools", status: "tag-moved" },
{ name: "vanished", status: "error", message: "Could not reach the remote." },
],
},
}}
>
<PluginsSettingsSection />
</PluginsSectionStoryShell>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await canvas.findByText("grill");
await canvas.findByText("update available");
await canvas.findByText("tag moved");
await canvas.findByText("unmanaged");
await canvas.findByText("missing");
// Update action appears only for rows whose tracking ref moved.
await canvas.findAllByRole("button", { name: /Update/ });
},
};

/**
* Pinned phone viewport for the row layout: long repo paths, badge clusters,
* and the action group must not overflow the right edge or starve each other
* at narrow widths (AGENTS.md Storybook responsive rule).
*/
export const InstalledPhoneViewport: Story = {
globals: {
viewport: { value: "mobile1", isRotated: false },
},
parameters: {
layout: "fullscreen",
pixel: {
matrix: { themes: ["dark"], viewports: ["phone"] },
},
},
render: () => (
<PluginsSectionStoryShell
options={{
agentPlugins: {
items: [MANAGED_ITEM, PINNED_ITEM, UNMANAGED_ITEM, MAX_LENGTH_ITEM],
updateChecks: [
{
name: "grill",
status: "update-available",
remoteSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3",
},
],
},
}}
>
{/* Fixed phone width so the play's overflow assertion holds in the CI
test-runner too, which ignores viewport globals (AGENTS.md). */}
<div style={{ width: 390 }}>
<PluginsSettingsSection />
</div>
</PluginsSectionStoryShell>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("grill");
await canvas.findByText("update available");
await canvas.findByRole("button", { name: /Update/ });
// Max-length separator-free names must wrap instead of overflowing the
// card's right edge at phone width.
const maxRow = await canvas.findByText(MAX_LENGTH_NAME);
const card = maxRow.closest("div[class*='rounded-md']");
if (card instanceof HTMLElement && card.scrollWidth > card.clientWidth + 1) {
throw new Error("Max-length plugin row overflows its card at phone width");
}
},
};

export const UninstallConfirmation: Story = {
render: () => (
<PluginsSectionStoryShell options={{ agentPlugins: { items: [MANAGED_ITEM] } }}>
<PluginsSettingsSection />
</PluginsSectionStoryShell>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

const uninstallButton = await canvas.findByRole("button", { name: /Uninstall grill/ });
await userEvent.click(uninstallButton);

// Preserve-by-default: the plugin-data checkbox starts unchecked.
await canvas.findByText(/Also delete stored plugin data/);
const checkbox = await canvas.findByRole("checkbox");
if (checkbox.getAttribute("data-state") !== "unchecked") {
throw new Error("Plugin-data checkbox must start unchecked (preserve by default)");
}
},
};

export const AddPluginConsentPreview: Story = {
render: () => (
<PluginsSectionStoryShell
options={{
agentPlugins: {
items: [],
preview: {
source: {
type: "git",
url: "https://github.com/example/grill.git",
ref: "main",
refType: "branch",
},
lockedSha: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
manifest: {
name: "grill",
version: "1.2.0",
description: "Relentlessly grills your plans.",
authorName: "Example Co",
license: "MIT",
},
skills: [
{ name: "grill", description: "Stress-test a plan before committing to it." },
{ name: "grill-lite", description: "A gentler grilling for small changes." },
],
mcpServers: [
{
serverName: "grill-db",
transport: "stdio",
summary: "node ~/.mux/plugins/grill/server.js --db ${PLUGIN_DATA}/state.sqlite",
},
],
warnings: ["Unknown top-level field 'hooks' ignored"],
targetPath: "~/.mux/plugins/grill",
},
},
}}
>
<PluginsSettingsSection />
</PluginsSectionStoryShell>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await userEvent.click(await canvas.findByRole("button", { name: /Add plugin/ }));
await userEvent.type(await canvas.findByLabelText(/Git URL or owner\/repo/), "example/grill");
await userEvent.click(await canvas.findByRole("button", { name: /Preview/ }));

// Consent card: manifest + every skill + every MCP command line before install.
await canvas.findByText("Skills (2)");
await canvas.findByText("grill-lite");
await canvas.findByText("MCP servers (1)");
await canvas.findByText(/server\.js --db/);
await canvas.findByText(/Unknown top-level field 'hooks' ignored/);
await canvas.findByRole("button", { name: /Install/ });
},
};
Loading
Loading