"use client"; import { useCallback, useMemo, useState } from "react"; import { Schema } from "effect"; import { PLUGIN_TEMPLATE, PluginSourceResponseSchema, PluginsResponseSchema, isValidPluginId, type PluginRow, } from "@local-studio/agent-runtime/plugin-contract"; import { Alert, Button, FormField, Input, RefreshIconButton, SearchInput, StatusPill } from "@/ui"; import { Plus, Trash2 } from "@/ui/icon-registry"; import { ResourceDrawer, ResourceDrawerSection, ResourceFact } from "@/ui/resource-drawer"; import { ResourceLogo } from "@/ui/resource-logo"; import { DataRow, EndCell, HeadCell, IdentityCell, RowAction, StatusText, TableFrame, TableNotice, TableSection, TextCell, } from "@/features/recipes/recipes-content/catalog-table-shell"; import { useMountSubscription } from "@/hooks/use-mount-subscription"; import { jsonBody, requestAgentJson } from "./agent-json"; /** * Plugins the user writes, in the directory the agent already reads. * * A plugin here is a pi extension: one TypeScript module that registers tools * onto every session. There is no registry file and no install step — the list * below *is* the contents of `/extensions`, so what the table says is * loaded and what is loaded cannot disagree. Disabling renames the file rather * than recording a flag somewhere only this app can see. * * The honesty this surface owes the user is different from the connectors tab's. * A connector runs a command in a child process; a plugin runs *inside* the * agent, so it is not sandboxed from anything the agent can touch. Saving still * executes nothing — the file is picked up when the next session is built — * but "next message" is a short fuse and the drawer says so plainly. */ const decodePlugins = Schema.decodeUnknownSync(PluginsResponseSchema); const decodeSource = Schema.decodeUnknownSync(PluginSourceResponseSchema); const CODE_CLASS = "w-full rounded-[var(--ui-radius)] border border-(--ui-separator) bg-(--ui-surface) px-3 py-2 font-mono text-[length:var(--fs-sm)] leading-5 text-(--ui-fg) focus:border-(--ui-accent)/60 focus:outline-none"; const formatBytes = (bytes: number): string => bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 102.4) / 10} KB`; const PluginBadge = ({ plugin }: { plugin: PluginRow | null }) => plugin ? ( {plugin.enabled ? "loaded" : "disabled"} ) : null; const PluginFacts = ({ plugin }: { plugin: PluginRow }) => ( ); /** * The source lives in the section, not in here. * * Opening a plugin fetches its file, so the text arrives one tick after the * drawer does. A local `useState(initialSource)` would latch the empty string * it opened with and there is no effect to re-seed it from — so the section * owns the buffer and this component only edits it. */ function PluginEditorDrawer({ plugin, source, onSourceChange, loadingSource, onClose, onChanged, }: { /** Null while composing a new plugin — the id is still being chosen. */ plugin: PluginRow | null; source: string; onSourceChange: (next: string) => void; loadingSource: boolean; onClose: () => void; onChanged: (plugins: readonly PluginRow[]) => void; }) { const [id, setId] = useState(plugin?.id ?? ""); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const creating = plugin === null; const named = id.trim(); const idError = creating && named && !isValidPluginId(named) ? "Use lowercase letters, digits, and hyphens." : ""; const save = async () => { setSaving(true); setError(""); try { const { plugins } = await requestAgentJson( "/api/agent/plugins", decodePlugins, jsonBody({ id: named, source }), ); onChanged(plugins); onClose(); } catch (saveError) { setError(saveError instanceof Error ? saveError.message : "Plugin save failed"); } finally { setSaving(false); } }; return ( } badge={} status={plugin?.path ?? "Saved as a .ts file in the agent's extensions directory"} footer={ <> } onClose={onClose} width={860} > A plugin runs inside the agent process with your user account — the same reach as the agent itself, with no sandbox between them. Saving writes the file and nothing more; the code first runs when your next message rebuilds the session. {creating ? (
setId(event.target.value)} placeholder="my-tools" className="font-mono" />
) : null}

Source

TypeScript, compiled by the agent on load. Imports resolve against the agent’s own dependencies.