From 9a55fb3bdddc8c89df186a4ebaa0472d237ef159 Mon Sep 17 00:00:00 2001 From: liyb Date: Sun, 23 Aug 2026 16:40:04 +0800 Subject: [PATCH 1/3] feat: add KindBundle capability for Plugin system (Phase 0) Introduce a new capability kind 'bundle' that packages skills (and in later phases: server tools, client UI, hooks) as a single deployable plugin unit. This is Phase 0 of the Plugin platform architecture. Changes: - canonical: add KindBundle constant, BundleSpec struct with inline skills, Validate/UnmarshalJSON branches, ErrInvalidBundle sentinel - render: all 4 targets (ClaudeCode/OpenCode/Codex/Pi) support KindBundle - agentdaemon: resolveBundleCapability extracts inline skills and injects them as append-mode system prompts - store: normalizeCapabilityType and validateImportSpecPreCommit accept 'bundle' - dev routes: POST .../capabilities/plugins/install endpoint for CLI - dev routes: isListedCapabilityType includes 'bundle' so bundles appear in workspace capability listings - CLI: 'parsar plugin add/list/remove' subcommands that read a local plugin directory manifest, embed skill content, and call the server API - frontend: add Plugin tab to capability type filter, CapabilityTypeBadge handles 'bundle' type - examples: customer-service-skin demo plugin (skill-only, changes Agent persona to professional customer service style) Verified end-to-end: install plugin via API -> bind to Agent -> new conversation shows customer-service persona in responses (system prompt injection confirmed in server logs). --- apps/parsar/internal/cli/plugin.go | 300 ++++++++++++++++++ apps/parsar/internal/cli/root.go | 1 + apps/web/src/lib/api-types.ts | 2 +- .../src/pages/admin/capabilities/index.tsx | 4 +- examples/plugins/README.md | 35 ++ .../customer-service-skin/manifest.json | 7 + .../skills/customer-service.md | 29 ++ .../internal/capability/canonical/bundle.go | 95 ++++++ .../capability/canonical/bundle_test.go | 134 ++++++++ .../internal/capability/canonical/errors.go | 1 + server/internal/capability/canonical/spec.go | 32 +- server/internal/capability/render/bundle.go | 40 +++ .../internal/capability/render/claudecode.go | 4 +- server/internal/capability/render/codex.go | 4 +- server/internal/capability/render/opencode.go | 4 +- server/internal/capability/render/pi.go | 4 +- .../agentdaemon/bundle_capability_test.go | 183 +++++++++++ .../agentdaemon/capability_runtime.go | 67 ++++ server/internal/dev/capability_routes.go | 4 +- server/internal/dev/plugin_install_routes.go | 116 +++++++ server/internal/dev/routes.go | 1 + server/internal/store/capabilities.go | 2 +- server/internal/store/capability_import.go | 3 + 23 files changed, 1057 insertions(+), 15 deletions(-) create mode 100644 apps/parsar/internal/cli/plugin.go create mode 100644 examples/plugins/README.md create mode 100644 examples/plugins/customer-service-skin/manifest.json create mode 100644 examples/plugins/customer-service-skin/skills/customer-service.md create mode 100644 server/internal/capability/canonical/bundle.go create mode 100644 server/internal/capability/canonical/bundle_test.go create mode 100644 server/internal/capability/render/bundle.go create mode 100644 server/internal/connector/agentdaemon/bundle_capability_test.go create mode 100644 server/internal/dev/plugin_install_routes.go diff --git a/apps/parsar/internal/cli/plugin.go b/apps/parsar/internal/cli/plugin.go new file mode 100644 index 00000000..0a7da054 --- /dev/null +++ b/apps/parsar/internal/cli/plugin.go @@ -0,0 +1,300 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" +) + +func runPlugin(ctx *runContext, args []string) error { + if len(args) == 0 { + printPluginHelp(ctx.stdout) + return fmt.Errorf("plugin: missing subcommand") + } + if args[0] == "-h" || args[0] == "--help" || args[0] == "help" { + printPluginHelp(ctx.stdout) + return nil + } + for _, sc := range pluginSubcommands { + if sc.name == args[0] { + return sc.run(ctx, args[1:]) + } + } + printPluginHelp(ctx.stderr) + return fmt.Errorf("plugin: unknown subcommand %q", args[0]) +} + +var pluginSubcommands = []command{ + {name: "add", summary: "Install a plugin bundle from a local directory", run: runPluginAdd}, + {name: "list", summary: "List installed plugin bundles", run: runPluginList}, + {name: "remove", summary: "Remove an installed plugin bundle", run: runPluginRemove}, +} + +func printPluginHelp(w io.Writer) { + fmt.Fprintln(w, "Usage: parsar plugin [flags]") + fmt.Fprintln(w) + fmt.Fprintln(w, "Subcommands:") + for _, sc := range pluginSubcommands { + fmt.Fprintf(w, " %-9s %s\n", sc.name, sc.summary) + } +} + +// ----- plugin add ----------------------------------------------------------- + +// pluginManifest mirrors the user-authored manifest.json in a plugin directory. +// Only the fields needed for Phase 0 are defined. +type pluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Server *pluginManifestEntry `json:"server,omitempty"` + Client *pluginManifestEntry `json:"client,omitempty"` + Skills []string `json:"skills,omitempty"` + Tools []string `json:"tools,omitempty"` + Hooks []string `json:"hooks,omitempty"` + Credentials []string `json:"credentials,omitempty"` +} + +type pluginManifestEntry struct { + Entry string `json:"entry,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +// bundleSkillPayload mirrors canonical.BundleSkill for the API request. +type bundleSkillPayload struct { + Slug string `json:"slug"` + Instruction string `json:"instruction"` +} + +func runPluginAdd(ctx *runContext, args []string) error { + fs := newFlagSet("plugin add") + jsonOut := fs.Bool("json", false, "emit JSON of the created capability") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin add: parse flags: %w", err) + } + remaining := fs.Args() + if len(remaining) == 0 { + return fmt.Errorf("plugin add: path to plugin directory is required") + } + pluginDir := remaining[0] + + // Read and parse manifest.json + manifestPath := filepath.Join(pluginDir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("plugin add: read manifest: %w", err) + } + var manifest pluginManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("plugin add: parse manifest: %w", err) + } + if strings.TrimSpace(manifest.Name) == "" { + return fmt.Errorf("plugin add: manifest.name is required") + } + if strings.TrimSpace(manifest.Version) == "" { + return fmt.Errorf("plugin add: manifest.version is required") + } + + // Read skill files and embed content + skills, err := readPluginSkills(pluginDir, manifest.Skills) + if err != nil { + return fmt.Errorf("plugin add: %w", err) + } + + // Build the canonical_spec + bundleSpec := map[string]any{ + "name": manifest.Name, + "version": manifest.Version, + } + if manifest.Description != "" { + bundleSpec["description"] = manifest.Description + } + if manifest.Author != "" { + bundleSpec["author"] = manifest.Author + } + if manifest.Server != nil && manifest.Server.Entry != "" { + bundleSpec["server_entry"] = manifest.Server.Entry + } + if manifest.Client != nil && manifest.Client.Entry != "" { + bundleSpec["client_entry"] = manifest.Client.Entry + } + if len(skills) > 0 { + bundleSpec["skills"] = skills + } + // Collect tools from manifest top-level or server.tools + tools := manifest.Tools + if manifest.Server != nil && len(manifest.Server.Tools) > 0 { + tools = append(tools, manifest.Server.Tools...) + } + if len(tools) > 0 { + bundleSpec["tools"] = tools + } + if len(manifest.Hooks) > 0 { + bundleSpec["hooks"] = manifest.Hooks + } + if len(manifest.Credentials) > 0 { + bundleSpec["credentials"] = manifest.Credentials + } + + canonicalSpec := map[string]any{ + "schema_version": 1, + "kind": "bundle", + "bundle": bundleSpec, + } + + // Build the API request + reqBody := map[string]any{ + "type": "bundle", + "name": manifest.Name, + "description": manifest.Description, + "visibility": "workspace", + "version": manifest.Version, + "canonical_spec": canonicalSpec, + } + + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin add: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin add: PARSAR_WORKSPACE_ID is required") + } + var result map[string]any + if err := newClient(cfg).do(context.Background(), "POST", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/plugins/install", nil, reqBody, &result); err != nil { + return fmt.Errorf("plugin add: %w", err) + } + + if *jsonOut { + return emitJSON(ctx.stdout, result) + } + name := manifest.Name + if id, ok := result["id"].(string); ok { + fmt.Fprintf(ctx.stdout, "plugin %q installed (capability_id=%s)\n", name, id) + } else { + fmt.Fprintf(ctx.stdout, "plugin %q installed\n", name) + } + return nil +} + +// readPluginSkills reads skill markdown files from the plugin directory +// and returns them as inline payloads for the canonical_spec. +func readPluginSkills(pluginDir string, skillPaths []string) ([]bundleSkillPayload, error) { + if len(skillPaths) == 0 { + return nil, nil + } + var skills []bundleSkillPayload + for _, relPath := range skillPaths { + fullPath := filepath.Join(pluginDir, relPath) + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("read skill %s: %w", relPath, err) + } + // Derive slug from filename: "skills/customer-service.md" → "customer-service" + base := filepath.Base(relPath) + slug := strings.TrimSuffix(base, filepath.Ext(base)) + skills = append(skills, bundleSkillPayload{ + Slug: slug, + Instruction: string(content), + }) + } + return skills, nil +} + +// ----- plugin list ---------------------------------------------------------- + +func runPluginList(ctx *runContext, args []string) error { + fs := newFlagSet("plugin list") + jsonOut := fs.Bool("json", false, "emit JSON instead of the table") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin list: parse flags: %w", err) + } + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin list: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin list: PARSAR_WORKSPACE_ID is required") + } + var result struct { + Capabilities []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Version string `json:"latest_version"` + } `json:"capabilities"` + } + if err := newClient(cfg).do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &result); err != nil { + return fmt.Errorf("plugin list: %w", err) + } + if *jsonOut { + return emitJSON(ctx.stdout, result.Capabilities) + } + if len(result.Capabilities) == 0 { + fmt.Fprintln(ctx.stdout, "(no plugins installed)") + return nil + } + tw := tabwriter.NewWriter(ctx.stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tVERSION\tDESCRIPTION") + for _, c := range result.Capabilities { + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Name, c.Version, truncate(c.Description, 50)) + } + return tw.Flush() +} + +// ----- plugin remove -------------------------------------------------------- + +func runPluginRemove(ctx *runContext, args []string) error { + fs := newFlagSet("plugin remove") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin remove: parse flags: %w", err) + } + remaining := fs.Args() + if len(remaining) == 0 { + return fmt.Errorf("plugin remove: plugin name is required") + } + name := remaining[0] + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin remove: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin remove: PARSAR_WORKSPACE_ID is required") + } + + // Resolve plugin name to capability_id via the list endpoint. + var listResult struct { + Capabilities []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"capabilities"` + } + c := newClient(cfg) + if err := c.do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &listResult); err != nil { + return fmt.Errorf("plugin remove: list plugins: %w", err) + } + var capabilityID string + for _, cap := range listResult.Capabilities { + if cap.Name == name { + capabilityID = cap.ID + break + } + } + if capabilityID == "" { + return fmt.Errorf("plugin remove: plugin %q not found", name) + } + + // Delete by capability ID using the existing endpoint. + if err := c.do(context.Background(), "DELETE", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/"+capabilityID, nil, nil, nil); err != nil { + return fmt.Errorf("plugin remove: %w", err) + } + fmt.Fprintf(ctx.stdout, "plugin %q removed\n", name) + return nil +} diff --git a/apps/parsar/internal/cli/root.go b/apps/parsar/internal/cli/root.go index 0e843f0b..0e4fb86b 100644 --- a/apps/parsar/internal/cli/root.go +++ b/apps/parsar/internal/cli/root.go @@ -41,6 +41,7 @@ var commands = []command{ {name: "memory", summary: "Manage user / workspace memories (list / add / edit / rm)", run: runMemory}, {name: "inject", summary: "Print the injection bundle hook scripts stitch into the prompt", run: runInject}, {name: "sync", summary: "Human-readable dump of the current injection snapshot (debug)", run: runSync}, + {name: "plugin", summary: "Manage plugin bundles (add / list / remove)", run: runPlugin}, {name: "version", summary: "Print the CLI version and exit", run: runVersion}, } diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 805201ab..c1c53694 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -245,7 +245,7 @@ export interface DeleteAgentResponse { /* --- Capabilities -------------------------------------------------------- */ -export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt" +export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt" | "bundle" export interface RequiredCredential { kind: string diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 07884cc1..2336839d 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -84,7 +84,7 @@ interface AgentInstallation { latest: boolean } -type CapabilityTypeFilter = "mcp" | "skill" +type CapabilityTypeFilter = "mcp" | "skill" | "bundle" type PageTab = "workspace" | "marketplace" export function CapabilitiesPage() { @@ -533,6 +533,7 @@ function CapabilitiesFilterBar({ MCP Skill + Plugin
@@ -1078,6 +1079,7 @@ export function CapabilityDetailPage({ id }: { id: string }) { export function CapabilityTypeBadge({ type }: { type: Capability["type"] }) { if (type === "skill") return Skill if (type === "plugin") return Plugin + if (type === "bundle") return Plugin Bundle if (type === "system_prompt") return System Prompt return MCP } diff --git a/examples/plugins/README.md b/examples/plugins/README.md new file mode 100644 index 00000000..731d9d3d --- /dev/null +++ b/examples/plugins/README.md @@ -0,0 +1,35 @@ +# Example Plugins + +Demo plugins for validating the Parsar Plugin (KindBundle) system. + +## customer-service-skin + +A "skin change" plugin that transforms an Agent's personality into a +professional customer service representative. Contains only a skill +(no server/client components) — the simplest possible plugin. + +### Install + +```bash +export PARSAR_SERVER_URL=http://localhost:8080 +export PARSAR_RUNNER_TOKEN= +export PARSAR_WORKSPACE_ID= + +parsar plugin add ./examples/plugins/customer-service-skin +``` + +### What It Does + +When bound to an Agent, the skill markdown is injected as a system prompt +addition (append mode). The Agent will adopt a warm, solution-oriented +customer service communication style in all responses. + +### Verify + +1. Install the plugin +2. Bind it to an Agent via the admin UI (Capability page → enable on Agent) +3. Start a new conversation with that Agent +4. The Agent should respond with customer-service-style phrasing: + - Warm greeting + - Acknowledge → Respond → Follow-up structure + - "Is there anything else I can help with?" diff --git a/examples/plugins/customer-service-skin/manifest.json b/examples/plugins/customer-service-skin/manifest.json new file mode 100644 index 00000000..a6824119 --- /dev/null +++ b/examples/plugins/customer-service-skin/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "@internal/customer-service-skin", + "version": "1.0.0", + "description": "Transforms the Agent into a professional customer service representative with warm, solution-oriented communication style.", + "author": "FDE Team", + "skills": ["./skills/customer-service.md"] +} diff --git a/examples/plugins/customer-service-skin/skills/customer-service.md b/examples/plugins/customer-service-skin/skills/customer-service.md new file mode 100644 index 00000000..772fe6d7 --- /dev/null +++ b/examples/plugins/customer-service-skin/skills/customer-service.md @@ -0,0 +1,29 @@ +# Customer Service Persona + +You are a professional customer service representative. Follow these guidelines in every response: + +## Tone and Style + +- Always greet the user warmly and address them by name when available. +- Use polite, empathetic language. Acknowledge the user's feelings before offering solutions. +- Keep responses concise but thorough. Avoid jargon; use plain language. +- End every response with a follow-up question or offer of further assistance. + +## Response Structure + +1. **Acknowledge**: Start by acknowledging what the user said or asked. +2. **Respond**: Provide a clear, helpful answer or solution. +3. **Follow up**: Ask if there's anything else you can help with. + +## Example Phrases + +- "I understand how frustrating that must be. Let me help you with that." +- "Great question! Here's what I'd recommend..." +- "Is there anything else I can assist you with today?" + +## Rules + +- Never blame the user for an issue. +- If you don't know the answer, say so honestly and offer to escalate. +- Always prioritize the user's satisfaction over being technically correct. +- Use positive framing: say "Here's what we can do" instead of "We can't do that". diff --git a/server/internal/capability/canonical/bundle.go b/server/internal/capability/canonical/bundle.go new file mode 100644 index 00000000..d53cd66c --- /dev/null +++ b/server/internal/capability/canonical/bundle.go @@ -0,0 +1,95 @@ +package canonical + +import ( + "fmt" + "strings" +) + +// BundleSpec is the body for Spec{Kind: KindBundle} — a Plugin Bundle that +// packages server tools, client UI, skills, and hooks as a single deployable +// unit. Installed via `parsarctl plugin add`, the bundle lives on the local +// filesystem under the server's plugins/ directory. +type BundleSpec struct { + // Name is the unique plugin identifier, e.g. "@internal/jira-integration". + Name string `json:"name"` + + // Version follows semver, e.g. "1.2.0". + Version string `json:"version"` + + // Description is a human-readable summary for the admin UI. + Description string `json:"description,omitempty"` + + // Author identifies the FDE team or individual who built this plugin. + Author string `json:"author,omitempty"` + + // ServerEntry is the relative path to the Node.js server entry point, + // e.g. "./server/index.js". Empty means the plugin has no server component. + ServerEntry string `json:"server_entry,omitempty"` + + // ClientEntry is the relative path to the built client bundle, + // e.g. "./client/index.js". Empty means the plugin has no UI component. + ClientEntry string `json:"client_entry,omitempty"` + + // Skills holds inline skill definitions. Each entry has a slug (identifier) + // and instruction (markdown body). These are injected as system prompt + // additions (append mode) when the bundle is bound to an agent. + // The `parsarctl plugin add` command reads skill files from disk and + // embeds the content here at install time. + Skills []BundleSkill `json:"skills,omitempty"` + + // Tools lists tool names the server entry exposes via MCP. Informational + // for the admin UI; the actual tool registration happens at runtime. + Tools []string `json:"tools,omitempty"` + + // Hooks lists event hook names the server entry registers. Informational. + Hooks []string `json:"hooks,omitempty"` + + // Credentials lists credential kind codes the plugin requires. + // The admin fills these via the existing credential_ref flow. + Credentials []string `json:"credentials,omitempty"` +} + +// BundleSkill is one skill embedded in a BundleSpec. The content is read +// from the plugin's skills/ directory at install time and stored inline +// in the canonical_spec so the resolver needs no filesystem access. +type BundleSkill struct { + // Slug is the short identifier for the skill, e.g. "jira-workflow". + Slug string `json:"slug"` + + // Instruction is the raw markdown body of the skill. + Instruction string `json:"instruction"` +} + +// maxBundleNameLen limits the name field to a reasonable length. +const maxBundleNameLen = 256 + +// Validate enforces structural sanity. Pure: no DB / network access. +func (b BundleSpec) Validate() error { + name := strings.TrimSpace(b.Name) + if name == "" { + return fmt.Errorf("%w: name is required", ErrInvalidBundle) + } + if len(name) > maxBundleNameLen { + return fmt.Errorf("%w: name is too long (%d bytes, max %d)", ErrInvalidBundle, len(name), maxBundleNameLen) + } + if strings.TrimSpace(b.Version) == "" { + return fmt.Errorf("%w: version is required", ErrInvalidBundle) + } + // At least one of server, client, or skills must be present — otherwise + // the bundle does nothing. + hasServer := strings.TrimSpace(b.ServerEntry) != "" + hasClient := strings.TrimSpace(b.ClientEntry) != "" + hasSkills := len(b.Skills) > 0 + if !hasServer && !hasClient && !hasSkills { + return fmt.Errorf("%w: at least one of server_entry, client_entry, or skills must be set", ErrInvalidBundle) + } + for i, skill := range b.Skills { + if strings.TrimSpace(skill.Slug) == "" { + return fmt.Errorf("%w: skills[%d].slug is required", ErrInvalidBundle, i) + } + if strings.TrimSpace(skill.Instruction) == "" { + return fmt.Errorf("%w: skills[%d].instruction is required", ErrInvalidBundle, i) + } + } + return nil +} diff --git a/server/internal/capability/canonical/bundle_test.go b/server/internal/capability/canonical/bundle_test.go new file mode 100644 index 00000000..b29f0d3c --- /dev/null +++ b/server/internal/capability/canonical/bundle_test.go @@ -0,0 +1,134 @@ +package canonical + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestBundleSpec_Validate_Valid(t *testing.T) { + t.Parallel() + specs := []BundleSpec{ + {Name: "@internal/hotel-ops", Version: "1.0.0", Skills: []BundleSkill{{Slug: "greeting", Instruction: "Hello!"}}}, + {Name: "my-plugin", Version: "0.1.0", ServerEntry: "./server/index.js"}, + {Name: "ui-only", Version: "2.0.0", ClientEntry: "./client/index.js"}, + {Name: "full", Version: "1.0.0", ServerEntry: "./server/index.js", ClientEntry: "./client/index.js", Skills: []BundleSkill{{Slug: "s1", Instruction: "do x"}}}, + } + for _, spec := range specs { + if err := spec.Validate(); err != nil { + t.Errorf("Validate(%q) = %v, want nil", spec.Name, err) + } + } +} + +func TestBundleSpec_Validate_MissingName(t *testing.T) { + t.Parallel() + spec := BundleSpec{Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing name, got %v", err) + } +} + +func TestBundleSpec_Validate_MissingVersion(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Skills: []BundleSkill{{Slug: "s", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing version, got %v", err) + } +} + +func TestBundleSpec_Validate_NoComponents(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "empty", Version: "1.0.0"} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for no components, got %v", err) + } +} + +func TestBundleSpec_Validate_SkillMissingSlug(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Version: "1.0.0", Skills: []BundleSkill{{Slug: "", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing slug, got %v", err) + } +} + +func TestBundleSpec_Validate_SkillMissingInstruction(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: ""}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing instruction, got %v", err) + } +} + +func TestSpec_KindBundle_RoundTrip(t *testing.T) { + t.Parallel() + spec := Spec{ + SchemaVersion: SchemaVersionCurrent, + Kind: KindBundle, + Bundle: &BundleSpec{ + Name: "@internal/jira", + Version: "1.2.0", + Skills: []BundleSkill{{Slug: "jira-workflow", Instruction: "Create issues..."}}, + Tools: []string{"jira_create_issue"}, + }, + } + if err := spec.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var decoded Spec + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if decoded.Kind != KindBundle { + t.Fatalf("decoded.Kind = %q, want %q", decoded.Kind, KindBundle) + } + if decoded.Bundle == nil { + t.Fatal("decoded.Bundle is nil") + } + if decoded.Bundle.Name != "@internal/jira" { + t.Fatalf("decoded.Bundle.Name = %q", decoded.Bundle.Name) + } + if len(decoded.Bundle.Skills) != 1 || decoded.Bundle.Skills[0].Slug != "jira-workflow" { + t.Fatalf("decoded.Bundle.Skills = %+v", decoded.Bundle.Skills) + } +} + +func TestSpec_KindBundle_RejectsCrossBody(t *testing.T) { + t.Parallel() + spec := Spec{ + SchemaVersion: SchemaVersionCurrent, + Kind: KindBundle, + Bundle: &BundleSpec{Name: "x", Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: "i"}}}, + MCP: &MCPSpec{}, + } + if err := spec.Validate(); err == nil { + t.Fatal("expected error for cross-body, got nil") + } +} + +func TestSpec_KindBundle_RejectsMissingBody(t *testing.T) { + t.Parallel() + spec := Spec{SchemaVersion: SchemaVersionCurrent, Kind: KindBundle} + if err := spec.Validate(); err == nil { + t.Fatal("expected error for nil body, got nil") + } +} + +func TestSpec_UnmarshalJSON_KindBundleEmptyBody(t *testing.T) { + t.Parallel() + raw := []byte(`{"schema_version":1,"kind":"bundle"}`) + var spec Spec + if err := json.Unmarshal(raw, &spec); err == nil { + t.Fatal("expected error for empty bundle body, got nil") + } +} diff --git a/server/internal/capability/canonical/errors.go b/server/internal/capability/canonical/errors.go index 09b32129..3f07739b 100644 --- a/server/internal/capability/canonical/errors.go +++ b/server/internal/capability/canonical/errors.go @@ -10,5 +10,6 @@ var ( ErrInvalidSkill = errors.New("canonical: invalid skill spec") ErrInvalidPlugin = errors.New("canonical: invalid plugin spec") ErrInvalidSystemPrompt = errors.New("canonical: invalid system_prompt spec") + ErrInvalidBundle = errors.New("canonical: invalid bundle spec") ErrInvalidEnvValue = errors.New("canonical: invalid env value") ) diff --git a/server/internal/capability/canonical/spec.go b/server/internal/capability/canonical/spec.go index 78ee696c..cd654d55 100644 --- a/server/internal/capability/canonical/spec.go +++ b/server/internal/capability/canonical/spec.go @@ -27,11 +27,12 @@ const ( KindSkill Kind = "skill" KindPlugin Kind = "plugin" KindSystemPrompt Kind = "system_prompt" + KindBundle Kind = "bundle" ) // Spec is the top-level canonical capability description. Exactly one of -// MCP / Skill / Plugin / SystemPrompt is non-nil; the populated branch must -// match Kind. +// MCP / Skill / Plugin / SystemPrompt / Bundle is non-nil; the populated +// branch must match Kind. type Spec struct { SchemaVersion int16 `json:"schema_version"` Kind Kind `json:"kind"` @@ -39,6 +40,7 @@ type Spec struct { Skill *SkillSpec `json:"skill,omitempty"` Plugin *PluginSpec `json:"plugin,omitempty"` SystemPrompt *SystemPromptSpec `json:"system_prompt,omitempty"` + Bundle *BundleSpec `json:"bundle,omitempty"` } // Validate performs structural sanity checks. It does NOT consult external @@ -53,7 +55,7 @@ func (s Spec) Validate() error { if s.MCP == nil { return fmt.Errorf("%w: kind=mcp but mcp body is nil", ErrInvalidSpec) } - if s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil { + if s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=mcp but another body is set", ErrInvalidSpec) } return s.MCP.Validate() @@ -61,7 +63,7 @@ func (s Spec) Validate() error { if s.Skill == nil { return fmt.Errorf("%w: kind=skill but skill body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Plugin != nil || s.SystemPrompt != nil { + if s.MCP != nil || s.Plugin != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=skill but another body is set", ErrInvalidSpec) } return s.Skill.Validate() @@ -69,7 +71,7 @@ func (s Spec) Validate() error { if s.Plugin == nil { return fmt.Errorf("%w: kind=plugin but plugin body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Skill != nil || s.SystemPrompt != nil { + if s.MCP != nil || s.Skill != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=plugin but another body is set", ErrInvalidSpec) } return s.Plugin.Validate() @@ -77,10 +79,18 @@ func (s Spec) Validate() error { if s.SystemPrompt == nil { return fmt.Errorf("%w: kind=system_prompt but system_prompt body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Skill != nil || s.Plugin != nil { + if s.MCP != nil || s.Skill != nil || s.Plugin != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=system_prompt but another body is set", ErrInvalidSpec) } return s.SystemPrompt.Validate() + case KindBundle: + if s.Bundle == nil { + return fmt.Errorf("%w: kind=bundle but bundle body is nil", ErrInvalidSpec) + } + if s.MCP != nil || s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil { + return fmt.Errorf("%w: kind=bundle but another body is set", ErrInvalidSpec) + } + return s.Bundle.Validate() default: return fmt.Errorf("%w: unknown kind %q", ErrInvalidSpec, s.Kind) } @@ -96,6 +106,7 @@ type specWire struct { Skill json.RawMessage `json:"skill,omitempty"` Plugin json.RawMessage `json:"plugin,omitempty"` SystemPrompt json.RawMessage `json:"system_prompt,omitempty"` + Bundle json.RawMessage `json:"bundle,omitempty"` } // UnmarshalJSON only decodes the body matching Kind so a malformed inactive @@ -144,6 +155,15 @@ func (s *Spec) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode system_prompt body: %w", err) } s.SystemPrompt = &body + case KindBundle: + if len(w.Bundle) == 0 { + return fmt.Errorf("%w: kind=bundle but no bundle body", ErrInvalidSpec) + } + var body BundleSpec + if err := json.Unmarshal(w.Bundle, &body); err != nil { + return fmt.Errorf("decode bundle body: %w", err) + } + s.Bundle = &body case "": return fmt.Errorf("%w: missing kind", ErrInvalidSpec) default: diff --git a/server/internal/capability/render/bundle.go b/server/internal/capability/render/bundle.go new file mode 100644 index 00000000..b6ed6165 --- /dev/null +++ b/server/internal/capability/render/bundle.go @@ -0,0 +1,40 @@ +package render + +import ( + "encoding/json" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +// bundleDocument is the wire shape emitted by every scaffold's renderer +// for KindBundle. Like systemPromptDocument, the daemon never consumes +// it — the connector-side resolveBundleCapability reads the spec +// directly. The renderer call exists only so the renderer factory's +// Supports() returns true and the default switch doesn't reject the kind. +type bundleDocument struct { + Name string `json:"name"` + Version string `json:"version"` + Skills []string `json:"skills,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +func renderBundle(b *canonical.BundleSpec) (Output, error) { + if b == nil { + return Output{}, fmt.Errorf("render: nil bundle spec") + } + skills := make([]string, 0, len(b.Skills)) + for _, s := range b.Skills { + skills = append(skills, s.Slug) + } + body, err := json.Marshal(bundleDocument{ + Name: b.Name, + Version: b.Version, + Skills: skills, + Tools: b.Tools, + }) + if err != nil { + return Output{}, fmt.Errorf("render: marshal bundle: %w", err) + } + return Output{Content: body}, nil +} diff --git a/server/internal/capability/render/claudecode.go b/server/internal/capability/render/claudecode.go index 1c8d095a..25039c6a 100644 --- a/server/internal/capability/render/claudecode.go +++ b/server/internal/capability/render/claudecode.go @@ -54,7 +54,7 @@ type claudeCodePluginDocument struct { func (claudeCodeRenderer) Supports(kind canonical.Kind) bool { switch kind { - case canonical.KindMCP, canonical.KindSkill, canonical.KindPlugin, canonical.KindSystemPrompt: + case canonical.KindMCP, canonical.KindSkill, canonical.KindPlugin, canonical.KindSystemPrompt, canonical.KindBundle: return true default: return false @@ -74,6 +74,8 @@ func (claudeCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output return renderClaudeCodePlugin(spec.Plugin) case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("claudecode render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/codex.go b/server/internal/capability/render/codex.go index 5fcb6711..308ec008 100644 --- a/server/internal/capability/render/codex.go +++ b/server/internal/capability/render/codex.go @@ -45,7 +45,7 @@ type codexMCPServer struct { } func (codexRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt + return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (codexRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -65,6 +65,8 @@ func (codexRenderer) Render(_ context.Context, spec canonical.Spec) (Output, err return Output{}, ErrUnsupported case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("codex render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/opencode.go b/server/internal/capability/render/opencode.go index ce630332..dfa85d14 100644 --- a/server/internal/capability/render/opencode.go +++ b/server/internal/capability/render/opencode.go @@ -35,7 +35,7 @@ type openCodeMCPServer struct { } func (openCodeRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt + return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (openCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -54,6 +54,8 @@ func (openCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output, return Output{}, ErrUnsupported case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("opencode render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/pi.go b/server/internal/capability/render/pi.go index 42df3af6..8aeae488 100644 --- a/server/internal/capability/render/pi.go +++ b/server/internal/capability/render/pi.go @@ -22,7 +22,7 @@ type piRenderer struct{} func (piRenderer) Target() Target { return TargetPi } func (piRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindSkill || kind == canonical.KindSystemPrompt + return kind == canonical.KindSkill || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (piRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -34,6 +34,8 @@ func (piRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) return renderClaudeCodeSkill(spec.Skill) case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) case canonical.KindMCP: return Output{}, ErrUnsupported case canonical.KindPlugin: diff --git a/server/internal/connector/agentdaemon/bundle_capability_test.go b/server/internal/connector/agentdaemon/bundle_capability_test.go new file mode 100644 index 00000000..1c4bf421 --- /dev/null +++ b/server/internal/connector/agentdaemon/bundle_capability_test.go @@ -0,0 +1,183 @@ +package agentdaemon + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +func TestResolveCapabilityAdditions_BundleSkillsInjected(t *testing.T) { + t.Parallel() + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/customer-service", + Version: "1.0.0", + Skills: []canonical.BundleSkill{ + {Slug: "greeting", Instruction: "Always greet warmly."}, + {Slug: "closing", Instruction: "End with a follow-up question."}, + }, + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-bundle-1", + Name: "customer-service", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + if len(got.SystemPrompts) != 2 { + t.Fatalf("want 2 system_prompts from bundle skills, got %d: %+v", len(got.SystemPrompts), got.SystemPrompts) + } + // Verify first skill + if got.SystemPrompts[0].Content != "Always greet warmly." { + t.Fatalf("skill[0].Content = %q", got.SystemPrompts[0].Content) + } + if got.SystemPrompts[0].Mode != canonical.SystemPromptModeAppend { + t.Fatalf("skill[0].Mode = %q, want append", got.SystemPrompts[0].Mode) + } + if got.SystemPrompts[0].Name != "bundle:@internal/customer-service/greeting" { + t.Fatalf("skill[0].Name = %q", got.SystemPrompts[0].Name) + } + // Verify second skill + if got.SystemPrompts[1].Content != "End with a follow-up question." { + t.Fatalf("skill[1].Content = %q", got.SystemPrompts[1].Content) + } +} + +func TestResolveCapabilityAdditions_BundleEmptyCanonicalSpecSkipped(t *testing.T) { + t.Parallel() + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-bundle-empty", + Name: "ghost-bundle", + Type: "bundle", + CanonicalSpec: nil, // empty + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.SystemPrompts) != 0 { + t.Fatalf("expected no system_prompts, got %d", len(got.SystemPrompts)) + } +} + +func TestResolveCapabilityAdditions_BundleKindMismatchErrors(t *testing.T) { + t.Parallel() + // Type=bundle but canonical_spec.kind=mcp — should error. + mismatched := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{Name: "x", Command: "true"}}}, + } + raw, err := json.Marshal(mismatched) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "bad-bundle", + Name: "bad", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + _, err = c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err == nil { + t.Fatal("expected error for kind mismatch, got nil") + } +} + +func TestResolveCapabilityAdditions_BundleNoSkillsNoPrompts(t *testing.T) { + t.Parallel() + // A bundle with only server_entry (no skills) — Phase 0 produces no prompts. + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/tools-only", + Version: "1.0.0", + ServerEntry: "./server/index.js", + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-tools-only", + Name: "tools-only", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.SystemPrompts) != 0 { + t.Fatalf("expected 0 prompts for tool-only bundle, got %d", len(got.SystemPrompts)) + } +} + +func TestResolveCapabilityAdditions_BundleWorksOnAllEngines(t *testing.T) { + t.Parallel() + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/universal", + Version: "1.0.0", + Skills: []canonical.BundleSkill{{Slug: "rule", Instruction: "Be helpful."}}, + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-u", + Name: "universal", + Type: "bundle", + CanonicalSpec: raw, + } + for _, engine := range []string{"claude_code", "opencode", "codex", "pi"} { + t.Run(engine, func(t *testing.T) { + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), engine) + if err != nil { + t.Fatalf("engine=%s: %v", engine, err) + } + if len(got.SystemPrompts) != 1 { + t.Fatalf("engine=%s: want 1 prompt, got %d", engine, len(got.SystemPrompts)) + } + }) + } +} diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index d5407a54..06d96903 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -392,6 +392,21 @@ func (c *Connector) resolveCapabilityAdditions(ctx context.Context, in connector continue } result.SystemPrompts = append(result.SystemPrompts, *sp) + case "bundle": + prompts, err := c.resolveBundleCapability(ctx, cap, renderer) + if err != nil { + if errors.Is(err, render.ErrUnsupported) { + c.log.Warn("agent_daemon: bundle capability not supported by agent_kind, skipping", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name, + "agent_kind", agentKind, + "target", string(target)) + result.Disabled = append(result.Disabled, disabledForUnsupportedCapability(cap)) + continue + } + return result, err + } + result.SystemPrompts = append(result.SystemPrompts, prompts...) default: c.log.Warn("agent_daemon: skip unknown capability type", "capability_id", cap.CapabilityID, @@ -1043,6 +1058,58 @@ func (c *Connector) resolveSystemPromptCapability( }, nil } +// resolveBundleCapability extracts inline skills from a KindBundle capability +// and returns them as ResolvedSystemPrompt entries in append mode. Phase 0 +// only injects skills; server tools, client UI, and hooks are handled in +// later phases. +func (c *Connector) resolveBundleCapability( + ctx context.Context, + cap store.EnabledCapabilityRead, + renderer render.Renderer, +) ([]ResolvedSystemPrompt, error) { + resolved := resolveVersionFields(cap) + if len(resolved.CanonicalSpec) == 0 { + c.log.Warn("agent_daemon: bundle capability has empty canonical_spec, skipping", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name) + return nil, nil + } + var spec canonical.Spec + if err := json.Unmarshal(resolved.CanonicalSpec, &spec); err != nil { + return nil, fmt.Errorf("agent_daemon: bundle capability %s canonical_spec decode: %w", cap.CapabilityID, err) + } + if spec.Kind != canonical.KindBundle { + return nil, fmt.Errorf("agent_daemon: capability %s has type=bundle but canonical_spec.kind=%q", cap.CapabilityID, spec.Kind) + } + if spec.Bundle == nil { + return nil, fmt.Errorf("agent_daemon: capability %s canonical_spec.bundle is nil", cap.CapabilityID) + } + // Render call for wire-shape consistency (matches other resolve* funcs). + if _, err := renderer.Render(ctx, spec); err != nil { + return nil, fmt.Errorf("agent_daemon: render bundle %s: %w", cap.CapabilityID, err) + } + // Inject each inline skill as a system prompt in append mode. + var prompts []ResolvedSystemPrompt + for _, skill := range spec.Bundle.Skills { + instruction := strings.TrimSpace(skill.Instruction) + if instruction == "" { + continue + } + prompts = append(prompts, ResolvedSystemPrompt{ + Name: fmt.Sprintf("bundle:%s/%s", spec.Bundle.Name, skill.Slug), + Mode: canonical.SystemPromptModeAppend, + Content: instruction, + }) + } + if len(prompts) > 0 { + c.log.Info("agent_daemon: bundle capability resolved skills", + "capability_id", cap.CapabilityID, + "bundle_name", spec.Bundle.Name, + "skill_count", len(prompts)) + } + return prompts, nil +} + // mergeSkillsIntoOptions folds resolved skill descriptors into // opts["skills"]. Override-wins on collision (mirrors mcp_servers / // plugins precedence). diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 62333081..5624ad35 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -398,7 +398,7 @@ func listMarketplaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { func isListedCapabilityType(capabilityType string) bool { switch strings.ToLower(strings.TrimSpace(capabilityType)) { - case "mcp", "skill": + case "mcp", "skill", "bundle": return true default: return false @@ -683,7 +683,7 @@ func createWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { } body.Type = strings.ToLower(strings.TrimSpace(body.Type)) if !isListedCapabilityType(body.Type) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must be mcp or skill"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must be mcp, skill, or bundle"}) return } input := store.CreateCapabilityInput{WorkspaceID: workspaceID, Type: body.Type, Name: body.Name, Description: body.Description, Visibility: capabilityVisibility(body.Visibility, body.Scope), CreatorID: actorID} diff --git a/server/internal/dev/plugin_install_routes.go b/server/internal/dev/plugin_install_routes.go new file mode 100644 index 00000000..5621363f --- /dev/null +++ b/server/internal/dev/plugin_install_routes.go @@ -0,0 +1,116 @@ +package dev + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type installPluginBody struct { + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + Version string `json:"version"` + CanonicalSpec json.RawMessage `json:"canonical_spec"` +} + +// installPlugin handles POST /api/v1/workspaces/{workspaceID}/capabilities/plugins/install. +// Creates a KindBundle capability + version from the provided canonical_spec. +// The CLI (`parsar plugin add`) reads a local plugin directory, builds the +// canonical_spec with inline skill content, and POSTs it here. +// +// @Summary Install a plugin bundle +// @Description Creates a KindBundle capability and its first version. The canonical_spec must have kind=bundle with inline skills embedded. Owner/admin only. +// @Tags capabilities +// @ID installPluginBundle +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body installPluginBody true "Plugin install payload" +// @Success 201 {object} map[string]interface{} "Created capability and version" +// @Failure 400 {object} map[string]string "Missing name/version, invalid canonical_spec" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" +// @Router /api/v1/workspaces/{workspaceID}/capabilities/plugins/install [post] +func installPlugin(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) + if !ok { + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } + var body installPluginBody + if err := decodeBody(r, &body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(body.Name) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if strings.TrimSpace(body.Version) == "" { + body.Version = "1.0.0" + } + + // Decode and validate the canonical_spec. + if len(body.CanonicalSpec) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "canonical_spec is required"}) + return + } + var spec canonical.Spec + if err := json.Unmarshal(body.CanonicalSpec, &spec); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec decode: %s", err)}) + return + } + if spec.Kind != canonical.KindBundle { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec.kind must be \"bundle\", got %q", spec.Kind)}) + return + } + if err := spec.Validate(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec validation: %s", err)}) + return + } + + visibility := strings.TrimSpace(body.Visibility) + if visibility == "" { + visibility = "workspace" + } + + sourcePayload := json.RawMessage(`{}`) + + result, err := runtimeStore.ImportCapability(r.Context(), store.ImportCapabilityInput{ + WorkspaceID: workspaceID, + Name: strings.TrimSpace(body.Name), + Description: strings.TrimSpace(body.Description), + Visibility: visibility, + Type: "bundle", + CreatorID: actorID, + Version: strings.TrimSpace(body.Version), + SourcePayload: sourcePayload, + Spec: spec, + }) + if err != nil { + if errors.Is(err, store.ErrCapabilityNameTaken) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "a plugin with this name already exists in the workspace"}) + return + } + writeCapabilityError(w, err, "failed to install plugin") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "id": result.Capability.ID, + "name": result.Capability.Name, + "type": result.Capability.Type, + "capability_version": result.CapabilityVersion.ID, + "version": result.CapabilityVersion.Version, + }) + } +} diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index 1ca80232..789d8bc8 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -647,6 +647,7 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro r.Post("/workspaces/{workspaceID}/capabilities/import/preview", previewCapabilityImport(runtimeStore, cfg.blobStore)) r.Post("/workspaces/{workspaceID}/capabilities/import/commit", commitCapabilityImport(runtimeStore, cfg.blobStore)) r.Post("/workspaces/{workspaceID}/skills/install", installSkillFromRegistry(runtimeStore, cfg.blobStore, cfg.skillInstallRunner, cfg.skillInstallHTTPClient)) + r.Post("/workspaces/{workspaceID}/capabilities/plugins/install", installPlugin(runtimeStore)) // Plugin upload presign — browser PUTs the zip directly to // the blob backend, then calls import/commit with the returned // ossKey. presign-download checks ossKey belongs to the calling diff --git a/server/internal/store/capabilities.go b/server/internal/store/capabilities.go index 53ad6491..bfe50d94 100644 --- a/server/internal/store/capabilities.go +++ b/server/internal/store/capabilities.go @@ -977,7 +977,7 @@ func pgNullableText(value string) pgtype.Text { // canonical.Kind MUST be added to this allowlist as well. func normalizeCapabilityType(value string) string { switch strings.TrimSpace(value) { - case "skill", "mcp", "plugin", "system_prompt": + case "skill", "mcp", "plugin", "system_prompt", "bundle": return strings.TrimSpace(value) default: return "mcp" diff --git a/server/internal/store/capability_import.go b/server/internal/store/capability_import.go index 94e21905..60d859c3 100644 --- a/server/internal/store/capability_import.go +++ b/server/internal/store/capability_import.go @@ -682,6 +682,9 @@ func validateImportSpecPreCommit(s canonical.Spec) error { return fmt.Errorf("kind=plugin but another body is set") } return s.Validate() + case canonical.KindBundle: + // Bundle has no inline secrets or env map; full Validate() is sufficient. + return s.Validate() case "": return fmt.Errorf("missing kind") default: From e2c51a3dc8292b6429ddf49c5e05e8c1b1bafdcc Mon Sep 17 00:00:00 2001 From: liyb Date: Sun, 23 Aug 2026 17:48:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(plugin):=20Phase=201=20=E2=80=94=20ser?= =?UTF-8?q?ver=20tools=20via=20MCP=20plugin-host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin bundles with a server_entry now get their tools exposed as an MCP server that the daemon spawns on demand. Architecture: - server/plugin-host/: Node.js MCP server (JSON-RPC 2.0 stdio) loads plugin server modules and exposes ctx.tools.define API - resolveBundleCapability emits {command:'node', args:[...]} when ServerEntry is non-empty and PARSAR_PLUGIN_HOST_PATH is configured - CLI 'parsar plugin add' copies server files to ~/.parsar/plugins/ - CLI 'parsar plugin remove' cleans up on-disk files New env var: PARSAR_PLUGIN_HOST_PATH (absolute path to plugin-host/index.js) Example: examples/plugins/hotel-ops/ with check_room_status and suggest_pricing tools, validated end-to-end. Also fixes Phase 0 web typecheck errors (missing i18n key for bundle type, MarketplaceTab type narrowing). --- CONTRIBUTING.md | 28 +++ apps/parsar/internal/cli/plugin.go | 113 +++++++++- apps/web/src/i18n/locales/en-US/admin.json | 3 +- apps/web/src/i18n/locales/zh-CN/admin.json | 3 +- .../src/pages/admin/capabilities/index.tsx | 2 +- examples/plugins/README.md | 53 +++++ examples/plugins/hotel-ops/manifest.json | 11 + examples/plugins/hotel-ops/package.json | 6 + examples/plugins/hotel-ops/server/index.js | 124 +++++++++++ .../hotel-ops/skills/hotel-workflow.md | 23 ++ scripts/dev-server-up.sh | 1 + server/cmd/server/main.go | 6 +- server/internal/config/config.go | 14 ++ server/internal/config/load.go | 5 + .../agentdaemon/capability_runtime.go | 86 +++++-- .../connector/agentdaemon/connector.go | 16 ++ server/plugin-host/index.js | 33 +++ server/plugin-host/lib/host.js | 99 +++++++++ server/plugin-host/lib/mcp-stdio.js | 209 ++++++++++++++++++ server/plugin-host/lib/sdk.js | 118 ++++++++++ server/plugin-host/package.json | 14 ++ 21 files changed, 946 insertions(+), 21 deletions(-) create mode 100644 examples/plugins/hotel-ops/manifest.json create mode 100644 examples/plugins/hotel-ops/package.json create mode 100644 examples/plugins/hotel-ops/server/index.js create mode 100644 examples/plugins/hotel-ops/skills/hotel-workflow.md create mode 100644 server/plugin-host/index.js create mode 100644 server/plugin-host/lib/host.js create mode 100644 server/plugin-host/lib/mcp-stdio.js create mode 100644 server/plugin-host/lib/sdk.js create mode 100644 server/plugin-host/package.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 826d96ad..3833bcc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,6 +161,34 @@ description and keep ownership on the side listed here. under `~/.parsar/`; never use the repo checkout, container image working directory, or the process CWD as hidden state. +### Plugin Bundle (KindBundle) architecture + +- A Plugin Bundle is a `KindBundle` capability that packages server tools, + client UI, skills, and hooks as a single deployable unit installed via + `parsar plugin add`. +- **Server tools** run inside `server/plugin-host/` — a Node.js process + speaking MCP stdio protocol (JSON-RPC 2.0). The daemon spawns it like + any other MCP server (`{ command: "node", args: [...] }`). +- The plugin-host process is configured via `PARSAR_PLUGIN_HOST_PATH` (env + var pointing at `server/plugin-host/index.js`). When unset, bundles with + `server_entry` are silently skipped with a log warning. +- Plugin server code lives on disk at `/plugins//`. + The CLI copies files during `parsar plugin add`; the server reads them + at prompt time via the plugin-host `--plugins-dir` argument. +- Directory names strip the `@scope/` prefix from bundle names + (`@internal/hotel-ops` → `hotel-ops`). This logic is duplicated in + `apps/parsar/internal/cli/plugin.go` (`pluginDirName`) and + `server/internal/connector/agentdaemon/capability_runtime.go` + (`bundleNameToDirName`) — keep both in sync. +- `resolveBundleCapability` returns a `bundleResolution` struct containing + both system prompt injections (skills) and MCP server configs (tools). + The MCP server name is `"plugin:"`. +- Plugin SDK (`server/plugin-host/lib/sdk.js`) provides + `ctx.tools.define(name, { description, parameters, handler })`. Future + phases will add `ctx.hooks`, `ctx.credentials`, and `ctx.api`. +- Plugin tool handlers have a 30-second timeout. Errors are returned as + MCP tool-level errors (`isError: true`), not JSON-RPC errors. + ### Human interaction lifecycle - `agent_interactions` is the canonical durable record for permission prompts diff --git a/apps/parsar/internal/cli/plugin.go b/apps/parsar/internal/cli/plugin.go index 0a7da054..05f058c3 100644 --- a/apps/parsar/internal/cli/plugin.go +++ b/apps/parsar/internal/cli/plugin.go @@ -47,7 +47,6 @@ func printPluginHelp(w io.Writer) { // ----- plugin add ----------------------------------------------------------- // pluginManifest mirrors the user-authored manifest.json in a plugin directory. -// Only the fields needed for Phase 0 are defined. type pluginManifest struct { Name string `json:"name"` Version string `json:"version"` @@ -158,6 +157,16 @@ func runPluginAdd(ctx *runContext, args []string) error { "canonical_spec": canonicalSpec, } + // Phase 1: if the plugin has a server entry, copy the plugin directory + // to the plugins storage dir BEFORE the API call. If copy fails, we + // leave harmless files on disk rather than a DB record with no loadable + // server code (which would cause runtime spawn errors). + if manifest.Server != nil && manifest.Server.Entry != "" { + if err := copyPluginToStorage(pluginDir, manifest.Name); err != nil { + return fmt.Errorf("plugin add: copy server files: %w", err) + } + } + cfg, err := ctx.resolveConfig() if err != nil { return fmt.Errorf("plugin add: %w", err) @@ -295,6 +304,108 @@ func runPluginRemove(ctx *runContext, args []string) error { if err := c.do(context.Background(), "DELETE", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/"+capabilityID, nil, nil, nil); err != nil { return fmt.Errorf("plugin remove: %w", err) } + + // Clean up on-disk plugin files (best-effort; failure is logged but + // doesn't fail the command since the DB record is already gone). + if pluginsDir, err := resolvePluginsDir(); err == nil { + dirName := pluginDirName(name) + _ = os.RemoveAll(filepath.Join(pluginsDir, dirName)) + } + fmt.Fprintf(ctx.stdout, "plugin %q removed\n", name) return nil } + +// ----- plugin storage ------------------------------------------------------- + +// resolvePluginsDir determines the plugins storage directory. +// Reads PARSAR_DATA_DIR (same env the server uses), defaults to ~/.parsar. +func resolvePluginsDir() (string, error) { + dataDir := strings.TrimSpace(os.Getenv("PARSAR_DATA_DIR")) + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + dataDir = filepath.Join(home, ".parsar") + } + return filepath.Join(dataDir, "plugins"), nil +} + +// pluginDirName converts a bundle name (possibly scoped) to the directory +// name under plugins/. Strips the "@scope/" prefix. +// NOTE: duplicated in server/internal/connector/agentdaemon/capability_runtime.go +// (bundleNameToDirName). Keep both in sync until a shared package is extracted. +func pluginDirName(name string) string { + if idx := strings.LastIndex(name, "/"); idx >= 0 { + return name[idx+1:] + } + return name +} + +// copyPluginToStorage copies the plugin source directory into +// //, creating the target if needed. Existing +// contents are replaced (simple rm + copy). +func copyPluginToStorage(srcDir, pluginName string) error { + pluginsDir, err := resolvePluginsDir() + if err != nil { + return err + } + dirName := pluginDirName(pluginName) + dstDir := filepath.Join(pluginsDir, dirName) + + // Remove previous install (idempotent upgrade). + _ = os.RemoveAll(dstDir) + + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("create plugin dir: %w", err) + } + + return copyDir(srcDir, dstDir) +} + +// copyDir recursively copies src into dst. Both must exist. +// Skips node_modules, .git, and symlinks. +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + // Skip symlinks — avoid traversing outside the plugin tree. + if entry.Type()&os.ModeSymlink != 0 { + continue + } + + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + // Skip node_modules — never copy dependency trees. + if entry.Name() == "node_modules" || entry.Name() == ".git" { + continue + } + if err := os.MkdirAll(dstPath, 0o755); err != nil { + return err + } + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + // Preserve execute bit for scripts. + info, _ := entry.Info() + mode := os.FileMode(0o644) + if info != nil && info.Mode()&0o111 != 0 { + mode = 0o755 + } + if err := os.WriteFile(dstPath, data, mode); err != nil { + return err + } + } + } + return nil +} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index b7ef6ea9..360fdb12 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1423,7 +1423,8 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", - "system_prompt": "System Prompt" + "system_prompt": "System Prompt", + "bundle": "Plugin Bundle" } }, "builtin": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 8b1bd91e..0f0921a9 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1423,7 +1423,8 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", - "system_prompt": "System Prompt" + "system_prompt": "System Prompt", + "bundle": "插件包" } }, "builtin": { diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 2336839d..df6451ca 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -279,7 +279,7 @@ export function CapabilitiesPage() { navigate("capabilities", { tab: "marketplace", item })} diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 731d9d3d..ade3b3b1 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -33,3 +33,56 @@ customer service communication style in all responses. - Warm greeting - Acknowledge → Respond → Follow-up structure - "Is there anything else I can help with?" + +## hotel-ops (Phase 1) + +A server-tools plugin that demonstrates Phase 1 capabilities. The Agent +gets two tools (`check_room_status`, `suggest_pricing`) backed by a +Node.js handler running inside the plugin-host MCP server. + +### Prerequisites + +- Node.js >= 20 available on the server +- `PARSAR_PLUGIN_HOST_PATH` set to the absolute path of + `server/plugin-host/index.js` + +### Install + +```bash +export PARSAR_SERVER_URL=http://localhost:8080 +export PARSAR_RUNNER_TOKEN= +export PARSAR_WORKSPACE_ID= + +parsar plugin add ./examples/plugins/hotel-ops +``` + +### What It Does + +- **check_room_status**: Returns mock PMS room data (occupied/vacant/ + maintenance). Supports querying a specific room or filtering by status. +- **suggest_pricing**: Calculates a price suggestion based on current + occupancy rate for a room type (standard/deluxe/suite). +- **hotel-workflow skill**: Instructs the Agent on how to use the tools + and present results. + +### Verify + +1. Install the plugin +2. Set `PARSAR_PLUGIN_HOST_PATH` and restart the server +3. Bind it to an Agent via the admin UI +4. Start a new conversation and ask "What's the room status?" +5. The Agent should call `check_room_status` and return occupancy data +6. Ask "What price should I set for deluxe rooms?" +7. The Agent should call `suggest_pricing` with `room_type=deluxe` + +### End-to-End Flow + +``` +parsar plugin add → API creates KindBundle capability + → CLI copies plugin to ~/.parsar/plugins/hotel-ops/ +Server restart → resolveBundleCapability sees server_entry + → emits MCP server entry: node plugin-host.js --plugin @internal/hotel-ops +Daemon prompt → spawns plugin-host → loads server/index.js → tools registered +Agent calls tool → MCP tools/call → handler runs → result returned +``` + diff --git a/examples/plugins/hotel-ops/manifest.json b/examples/plugins/hotel-ops/manifest.json new file mode 100644 index 00000000..d1f7f01f --- /dev/null +++ b/examples/plugins/hotel-ops/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "@internal/hotel-ops", + "version": "1.0.0", + "description": "Hotel operations plugin — check room status, suggest pricing adjustments based on occupancy.", + "author": "FDE Team", + "server": { + "entry": "./server/index.js", + "tools": ["check_room_status", "suggest_pricing"] + }, + "skills": ["./skills/hotel-workflow.md"] +} diff --git a/examples/plugins/hotel-ops/package.json b/examples/plugins/hotel-ops/package.json new file mode 100644 index 00000000..9967474c --- /dev/null +++ b/examples/plugins/hotel-ops/package.json @@ -0,0 +1,6 @@ +{ + "name": "@internal/hotel-ops", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/examples/plugins/hotel-ops/server/index.js b/examples/plugins/hotel-ops/server/index.js new file mode 100644 index 00000000..23c03882 --- /dev/null +++ b/examples/plugins/hotel-ops/server/index.js @@ -0,0 +1,124 @@ +// Hotel Operations Plugin — server tools +// Demonstrates Phase 1 plugin server capabilities: ctx.tools.define + +// Mock PMS data (in production this would call the hotel's PMS API). +const MOCK_ROOMS = { + '101': { number: '101', type: 'standard', status: 'occupied', guest: 'Zhang Wei', checkout: '2026-08-25' }, + '102': { number: '102', type: 'standard', status: 'vacant', guest: null, checkout: null }, + '201': { number: '201', type: 'deluxe', status: 'occupied', guest: 'Li Ming', checkout: '2026-08-24' }, + '202': { number: '202', type: 'deluxe', status: 'maintenance', guest: null, checkout: null }, + '301': { number: '301', type: 'suite', status: 'vacant', guest: null, checkout: null }, + '302': { number: '302', type: 'suite', status: 'occupied', guest: 'Wang Fang', checkout: '2026-08-26' }, +}; + +const BASE_PRICES = { + standard: 388, + deluxe: 588, + suite: 988, +}; + +export default (ctx) => { + ctx.tools.define('check_room_status', { + description: 'Check the current status of hotel rooms. Can query a specific room or get an overview of all rooms.', + parameters: { + type: 'object', + properties: { + room_number: { + type: 'string', + description: 'Specific room number to check (e.g. "101"). Omit to get all rooms.', + }, + filter: { + type: 'string', + enum: ['all', 'vacant', 'occupied', 'maintenance'], + description: 'Filter rooms by status. Defaults to "all".', + }, + }, + }, + handler: async (args) => { + const { room_number, filter = 'all' } = args; + + if (room_number) { + const room = MOCK_ROOMS[room_number]; + if (!room) { + return { value: { error: `Room ${room_number} not found` } }; + } + return { + value: room, + presentation: { kind: 'room-status-card', data: room }, + }; + } + + // Return filtered room list. + let rooms = Object.values(MOCK_ROOMS); + if (filter !== 'all') { + rooms = rooms.filter((r) => r.status === filter); + } + + const summary = { + total: Object.keys(MOCK_ROOMS).length, + vacant: Object.values(MOCK_ROOMS).filter((r) => r.status === 'vacant').length, + occupied: Object.values(MOCK_ROOMS).filter((r) => r.status === 'occupied').length, + maintenance: Object.values(MOCK_ROOMS).filter((r) => r.status === 'maintenance').length, + rooms, + }; + + return { + value: summary, + presentation: { kind: 'room-overview', data: summary }, + }; + }, + }); + + ctx.tools.define('suggest_pricing', { + description: 'Suggest room pricing adjustments based on current occupancy rate and room type.', + parameters: { + type: 'object', + properties: { + room_type: { + type: 'string', + enum: ['standard', 'deluxe', 'suite'], + description: 'Room type to get pricing suggestion for.', + }, + date: { + type: 'string', + description: 'Target date (YYYY-MM-DD). Defaults to today.', + }, + }, + required: ['room_type'], + }, + handler: async (args) => { + const { room_type, date } = args; + const basePrice = BASE_PRICES[room_type]; + if (!basePrice) { + return { value: { error: `Unknown room type: ${room_type}` } }; + } + + // Simple occupancy-based pricing: high occupancy → higher price. + const totalRooms = Object.values(MOCK_ROOMS).filter((r) => r.type === room_type).length; + const occupiedRooms = Object.values(MOCK_ROOMS).filter( + (r) => r.type === room_type && r.status === 'occupied' + ).length; + const occupancyRate = totalRooms > 0 ? occupiedRooms / totalRooms : 0; + + let multiplier = 1.0; + if (occupancyRate >= 0.8) multiplier = 1.3; + else if (occupancyRate >= 0.5) multiplier = 1.1; + else if (occupancyRate < 0.3) multiplier = 0.85; + + const suggested = Math.round(basePrice * multiplier); + + return { + value: { + room_type, + date: date || new Date().toISOString().slice(0, 10), + base_price: basePrice, + occupancy_rate: `${Math.round(occupancyRate * 100)}%`, + multiplier, + suggested_price: suggested, + recommendation: + multiplier > 1 ? 'Raise price (high demand)' : multiplier < 1 ? 'Lower price (low occupancy)' : 'Keep current price', + }, + }; + }, + }); +}; diff --git a/examples/plugins/hotel-ops/skills/hotel-workflow.md b/examples/plugins/hotel-ops/skills/hotel-workflow.md new file mode 100644 index 00000000..f678517b --- /dev/null +++ b/examples/plugins/hotel-ops/skills/hotel-workflow.md @@ -0,0 +1,23 @@ +# Hotel Operations Workflow + +You are a hotel operations assistant with access to the property management system. + +## Room Status Queries + +When the user asks about room status, availability, or occupancy: +1. Use `check_room_status` without a room_number to get an overview +2. If asking about a specific room, pass the room_number +3. Present the results clearly: room number, type, status, and guest info if occupied + +## Pricing Suggestions + +When the user asks about pricing or revenue optimization: +1. Use `suggest_pricing` with the relevant room_type +2. Explain the occupancy rate and how it affects the recommendation +3. Present the base price, suggested price, and reasoning + +## General Guidelines + +- Always provide actionable summaries, not raw data dumps +- When occupancy is low, proactively suggest pricing adjustments +- When a guest checkout is today, mention it as a room about to become available diff --git a/scripts/dev-server-up.sh b/scripts/dev-server-up.sh index bfcb9112..5d8753fa 100755 --- a/scripts/dev-server-up.sh +++ b/scripts/dev-server-up.sh @@ -203,6 +203,7 @@ TMUX_ENV="PARSAR_ADDR=:${PORT} DATABASE_URL='${DATABASE_URL}' PARSAR_DEV_AUTH=${ [[ -n "${AGENT_DAEMON_SANDBOX_TTL:-}" ]] && TMUX_ENV+=" AGENT_DAEMON_SANDBOX_TTL='${AGENT_DAEMON_SANDBOX_TTL}'" [[ -n "${AGENT_DAEMON_SANDBOX_AUTO_RENEW:-}" ]] && TMUX_ENV+=" AGENT_DAEMON_SANDBOX_AUTO_RENEW='${AGENT_DAEMON_SANDBOX_AUTO_RENEW}'" [[ -n "${AGENT_DAEMON_SANDBOX_TTL_HOURS:-}" ]] && TMUX_ENV+=" AGENT_DAEMON_SANDBOX_TTL_HOURS='${AGENT_DAEMON_SANDBOX_TTL_HOURS}'" +[[ -n "${PARSAR_PLUGIN_HOST_PATH:-}" ]] && TMUX_ENV+=" PARSAR_PLUGIN_HOST_PATH='${PARSAR_PLUGIN_HOST_PATH}'" tmux new-session -d -s "${TMUX_SESSION}" \ "${TMUX_ENV} '${BIN_PATH}' 2>&1 | tee '${LOG_PATH}'" diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 2a4465ee..e16d92c0 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -472,7 +472,11 @@ func main() { // generic "Agent not bound to Runtime" hint into a precise // spawning / failed / never-attempted message. SandboxBindingReader: dbStore, - Log: log.Bg(), + // Plugin host: when set, bundles with server_entry resolve + // into MCP server entries that spawn this script. + PluginHostPath: cfg.Server.PluginHostPath, + PluginsDir: cfg.PluginsDir(), + Log: log.Bg(), } // The daemon only needs a short-lived GET URL per capability ref. // blobDownloadAdapter bridges blob.Store onto the daemon's existing diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 15b23640..89e755d9 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -22,6 +22,7 @@ import ( "fmt" "net" "net/url" + "path/filepath" "strings" "time" ) @@ -51,6 +52,13 @@ type ServerConfig struct { // outside the repo / CWD. Default "~/.parsar" expanded at // load time. Env PARSAR_DATA_DIR. DataDir string `yaml:"data_dir"` + + // PluginHostPath is the absolute path to the Node.js plugin-host + // entry point (server/plugin-host/index.js). The daemon spawns + // this as an MCP server for bundles that declare a server_entry. + // Empty means plugin server tools are disabled. Env + // PARSAR_PLUGIN_HOST_PATH. + PluginHostPath string `yaml:"plugin_host_path"` } type DatabaseConfig struct { @@ -283,6 +291,12 @@ func isLoopback(publicURL string) bool { return ip != nil && ip.IsLoopback() // 127.0.0.0/8 and ::1 } +// PluginsDir returns the on-disk directory where plugin bundles are stored. +// Derived from DataDir: /plugins/. +func (c Config) PluginsDir() string { + return filepath.Join(c.Server.DataDir, "plugins") +} + // BuildPublicURL returns an absolute URL for a Parsar-owned path. func (c Config) BuildPublicURL(path string) string { publicURL := strings.TrimSpace(c.Server.PublicURL) diff --git a/server/internal/config/load.go b/server/internal/config/load.go index 81f2c250..1be4dc0e 100644 --- a/server/internal/config/load.go +++ b/server/internal/config/load.go @@ -43,6 +43,10 @@ const ( EnvOpenCodeBin = "PARSAR_OPENCODE_BIN" EnvOpenCodeRunner = "PARSAR_OPENCODE_RUNNER" + // EnvPluginHostPath is the absolute path to the Node.js + // plugin-host entry point. Empty disables plugin server tools. + EnvPluginHostPath = "PARSAR_PLUGIN_HOST_PATH" + // EnvPlatformAdminUserIDs lists user UUIDs that bypass workspace // membership checks. Comma-separated. Empty disables. EnvPlatformAdminUserIDs = "PARSAR_PLATFORM_ADMIN_USER_IDS" @@ -187,6 +191,7 @@ func applyEnv(cfg *Config, env EnvFunc) { stringSetter(EnvOpenCodeBin, &cfg.Model.OpenCodeBin) stringSetter(EnvOpenCodeRunner, &cfg.Sandbox.Runner) + stringSetter(EnvPluginHostPath, &cfg.Server.PluginHostPath) boolSetter(EnvAuditOTLPEnabled, &cfg.Audit.OTLP.Enabled) stringSetter(EnvAuditOTLPAddr, &cfg.Audit.OTLP.Addr) diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index 06d96903..91e1c749 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -393,7 +393,7 @@ func (c *Connector) resolveCapabilityAdditions(ctx context.Context, in connector } result.SystemPrompts = append(result.SystemPrompts, *sp) case "bundle": - prompts, err := c.resolveBundleCapability(ctx, cap, renderer) + bundle, err := c.resolveBundleCapability(ctx, cap, renderer) if err != nil { if errors.Is(err, render.ErrUnsupported) { c.log.Warn("agent_daemon: bundle capability not supported by agent_kind, skipping", @@ -406,7 +406,15 @@ func (c *Connector) resolveCapabilityAdditions(ctx context.Context, in connector } return result, err } - result.SystemPrompts = append(result.SystemPrompts, prompts...) + result.SystemPrompts = append(result.SystemPrompts, bundle.SystemPrompts...) + if len(bundle.MCPServers) > 0 { + if result.MCPServers == nil { + result.MCPServers = map[string]any{} + } + for name, config := range bundle.MCPServers { + result.MCPServers[name] = config + } + } default: c.log.Warn("agent_daemon: skip unknown capability type", "capability_id", cap.CapabilityID, @@ -1059,55 +1067,101 @@ func (c *Connector) resolveSystemPromptCapability( } // resolveBundleCapability extracts inline skills from a KindBundle capability -// and returns them as ResolvedSystemPrompt entries in append mode. Phase 0 -// only injects skills; server tools, client UI, and hooks are handled in -// later phases. +// and returns them as ResolvedSystemPrompt entries in append mode. When the +// bundle declares a server_entry and the plugin-host path is configured, it +// also returns an MCP server config that spawns the plugin-host for this +// bundle's tools. func (c *Connector) resolveBundleCapability( ctx context.Context, cap store.EnabledCapabilityRead, renderer render.Renderer, -) ([]ResolvedSystemPrompt, error) { +) (bundleResolution, error) { + var res bundleResolution resolved := resolveVersionFields(cap) if len(resolved.CanonicalSpec) == 0 { c.log.Warn("agent_daemon: bundle capability has empty canonical_spec, skipping", "capability_id", cap.CapabilityID, "capability_name", cap.Name) - return nil, nil + return res, nil } var spec canonical.Spec if err := json.Unmarshal(resolved.CanonicalSpec, &spec); err != nil { - return nil, fmt.Errorf("agent_daemon: bundle capability %s canonical_spec decode: %w", cap.CapabilityID, err) + return res, fmt.Errorf("agent_daemon: bundle capability %s canonical_spec decode: %w", cap.CapabilityID, err) } if spec.Kind != canonical.KindBundle { - return nil, fmt.Errorf("agent_daemon: capability %s has type=bundle but canonical_spec.kind=%q", cap.CapabilityID, spec.Kind) + return res, fmt.Errorf("agent_daemon: capability %s has type=bundle but canonical_spec.kind=%q", cap.CapabilityID, spec.Kind) } if spec.Bundle == nil { - return nil, fmt.Errorf("agent_daemon: capability %s canonical_spec.bundle is nil", cap.CapabilityID) + return res, fmt.Errorf("agent_daemon: capability %s canonical_spec.bundle is nil", cap.CapabilityID) } // Render call for wire-shape consistency (matches other resolve* funcs). if _, err := renderer.Render(ctx, spec); err != nil { - return nil, fmt.Errorf("agent_daemon: render bundle %s: %w", cap.CapabilityID, err) + return res, fmt.Errorf("agent_daemon: render bundle %s: %w", cap.CapabilityID, err) } // Inject each inline skill as a system prompt in append mode. - var prompts []ResolvedSystemPrompt for _, skill := range spec.Bundle.Skills { instruction := strings.TrimSpace(skill.Instruction) if instruction == "" { continue } - prompts = append(prompts, ResolvedSystemPrompt{ + res.SystemPrompts = append(res.SystemPrompts, ResolvedSystemPrompt{ Name: fmt.Sprintf("bundle:%s/%s", spec.Bundle.Name, skill.Slug), Mode: canonical.SystemPromptModeAppend, Content: instruction, }) } - if len(prompts) > 0 { + if len(res.SystemPrompts) > 0 { c.log.Info("agent_daemon: bundle capability resolved skills", "capability_id", cap.CapabilityID, "bundle_name", spec.Bundle.Name, - "skill_count", len(prompts)) + "skill_count", len(res.SystemPrompts)) + } + // Phase 1: when the bundle has a server_entry AND the plugin-host + // is configured, emit an MCP server entry that spawns plugin-host + // for this bundle. + serverEntry := strings.TrimSpace(spec.Bundle.ServerEntry) + if serverEntry != "" && c.pluginHostPath != "" && c.pluginsDir != "" { + // Derive the on-disk plugin directory name from the bundle name. + // e.g. "@internal/hotel-ops" → "hotel-ops" (strip scope prefix). + dirName := bundleNameToDirName(spec.Bundle.Name) + mcpName := "plugin:" + spec.Bundle.Name + res.MCPServers = map[string]any{ + mcpName: map[string]any{ + "command": "node", + "args": []any{c.pluginHostPath, "--plugins-dir", c.pluginsDir, "--plugin", spec.Bundle.Name}, + }, + } + c.log.Info("agent_daemon: bundle capability resolved server tools as MCP", + "capability_id", cap.CapabilityID, + "bundle_name", spec.Bundle.Name, + "mcp_server_name", mcpName, + "plugin_dir_name", dirName) + } else if serverEntry != "" && c.pluginHostPath == "" { + c.log.Warn("agent_daemon: bundle has server_entry but PARSAR_PLUGIN_HOST_PATH is not configured; server tools skipped", + "capability_id", cap.CapabilityID, + "bundle_name", spec.Bundle.Name) + } + return res, nil +} + +// bundleResolution holds the outputs of resolveBundleCapability: skill +// injections (system prompts) and optional MCP server configs for bundles +// that declare a server_entry. +type bundleResolution struct { + SystemPrompts []ResolvedSystemPrompt + MCPServers map[string]any // server_name → {command, args, env} +} + +// bundleNameToDirName converts a bundle name (possibly scoped with @) +// to the directory name used under plugins/. Strips the "@scope/" prefix. +// NOTE: duplicated in apps/parsar/internal/cli/plugin.go (pluginDirName). +// Keep both in sync until a shared package is extracted. +func bundleNameToDirName(name string) string { + // "@internal/hotel-ops" → "hotel-ops" + if idx := strings.LastIndex(name, "/"); idx >= 0 { + return name[idx+1:] } - return prompts, nil + return name } // mergeSkillsIntoOptions folds resolved skill descriptors into diff --git a/server/internal/connector/agentdaemon/connector.go b/server/internal/connector/agentdaemon/connector.go index 7a743897..d1ab231d 100644 --- a/server/internal/connector/agentdaemon/connector.go +++ b/server/internal/connector/agentdaemon/connector.go @@ -151,6 +151,18 @@ type Config struct { // generic line. Nil keeps legacy behaviour for tests. SandboxBindingReader SandboxBindingReader + // PluginHostPath is the absolute path to the Node.js plugin-host + // entry point (server/plugin-host/index.js). When non-empty, + // bundles with a server_entry are resolved into MCP server + // entries that spawn this script. Empty disables plugin server + // tools. + PluginHostPath string + + // PluginsDir is the on-disk directory where installed plugin + // bundles are stored (/plugins/). Required when + // PluginHostPath is set. + PluginsDir string + // Log is the structured logger; nil falls back to slog.Default(). Log *slog.Logger } @@ -176,6 +188,8 @@ type Connector struct { oss OSSPresigner systemMessages CapabilitySystemMessageStore sandboxBindings SandboxBindingReader + pluginHostPath string + pluginsDir string imHistoryEndpoint string imHistoryToken func(conversationID string) string log *slog.Logger @@ -259,6 +273,8 @@ func New(cfg Config) *Connector { oss: cfg.OSS, systemMessages: cfg.SystemMessages, sandboxBindings: cfg.SandboxBindingReader, + pluginHostPath: cfg.PluginHostPath, + pluginsDir: cfg.PluginsDir, imHistoryEndpoint: cfg.IMHistoryEndpoint, imHistoryToken: cfg.IMHistoryTokenSigner, log: cfg.Log, diff --git a/server/plugin-host/index.js b/server/plugin-host/index.js new file mode 100644 index 00000000..0add4a3e --- /dev/null +++ b/server/plugin-host/index.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Parsar Plugin Host +// Loads plugin server modules from --plugins-dir and exposes their tools +// via MCP stdio protocol (JSON-RPC 2.0 over stdin/stdout). +// +// Usage: +// node index.js --plugins-dir /path/to/plugins [--plugin ] +// +// If --plugin is provided, only that specific plugin is loaded. +// Otherwise all plugins in the directory with a server entry are loaded. + +import { parseArgs } from 'node:util'; +import { createPluginHost } from './lib/host.js'; +import { startMCPStdio } from './lib/mcp-stdio.js'; + +const { values } = parseArgs({ + options: { + 'plugins-dir': { type: 'string' }, + 'plugin': { type: 'string' }, + }, + strict: false, +}); + +const pluginsDir = values['plugins-dir']; +const pluginFilter = values['plugin']; + +if (!pluginsDir) { + process.stderr.write('plugin-host: --plugins-dir is required\n'); + process.exit(1); +} + +const host = await createPluginHost(pluginsDir, { pluginFilter }); +startMCPStdio(host); diff --git a/server/plugin-host/lib/host.js b/server/plugin-host/lib/host.js new file mode 100644 index 00000000..ff6e5545 --- /dev/null +++ b/server/plugin-host/lib/host.js @@ -0,0 +1,99 @@ +// Plugin Host — discovers, loads, and manages plugin server modules. + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createPluginContext } from './sdk.js'; + +/** + * @typedef {Object} ToolDefinition + * @property {string} name + * @property {string} description + * @property {Object} parameters - JSON Schema object + * @property {Function} handler - async (args) => result + * @property {string} pluginName - owning plugin + */ + +/** + * @typedef {Object} PluginHost + * @property {Map} tools + * @property {string[]} loadedPlugins + */ + +/** + * Creates a plugin host by scanning the plugins directory and loading + * server modules. + * + * @param {string} pluginsDir - path to the plugins directory + * @param {Object} [opts] + * @param {string} [opts.pluginFilter] - load only this plugin name + * @returns {Promise} + */ +export async function createPluginHost(pluginsDir, opts = {}) { + const tools = new Map(); + const loadedPlugins = []; + const { pluginFilter } = opts; + + let entries; + try { + entries = await readdir(pluginsDir, { withFileTypes: true }); + } catch (err) { + process.stderr.write(`plugin-host: cannot read plugins-dir: ${err.message}\n`); + return { tools, loadedPlugins }; + } + + const dirs = entries.filter((e) => e.isDirectory()); + + for (const dir of dirs) { + const pluginDir = join(pluginsDir, dir.name); + const manifestPath = join(pluginDir, 'manifest.json'); + + let manifest; + try { + const raw = await readFile(manifestPath, 'utf8'); + manifest = JSON.parse(raw); + } catch { + // No manifest or invalid JSON — skip silently. + continue; + } + + // Skip plugins without a server entry. + if (!manifest.server?.entry) { + continue; + } + + // If filtering to a specific plugin, skip non-matching. + if (pluginFilter && manifest.name !== pluginFilter) { + continue; + } + + const serverEntry = join(pluginDir, manifest.server.entry); + + try { + const ctx = createPluginContext(manifest.name, tools); + const moduleURL = pathToFileURL(serverEntry).href; + const mod = await import(moduleURL); + const setup = mod.default ?? mod; + + if (typeof setup === 'function') { + await setup(ctx); + } + + loadedPlugins.push(manifest.name); + process.stderr.write( + `plugin-host: loaded "${manifest.name}" (${ctx.tools._count()} tools)\n` + ); + } catch (err) { + process.stderr.write( + `plugin-host: error loading "${manifest.name}": ${err.message}\n` + ); + // Continue loading other plugins — one failure doesn't block others. + } + } + + process.stderr.write( + `plugin-host: ready (${loadedPlugins.length} plugins, ${tools.size} tools)\n` + ); + + return { tools, loadedPlugins }; +} diff --git a/server/plugin-host/lib/mcp-stdio.js b/server/plugin-host/lib/mcp-stdio.js new file mode 100644 index 00000000..27178ef4 --- /dev/null +++ b/server/plugin-host/lib/mcp-stdio.js @@ -0,0 +1,209 @@ +// MCP stdio transport — implements the Model Context Protocol (JSON-RPC 2.0) +// over stdin/stdout for tool invocation. +// +// Protocol reference: https://modelcontextprotocol.io/specification +// +// Supported methods: +// initialize → server info + capabilities +// tools/list → list all registered tools +// tools/call → invoke a tool handler +// notifications/initialized → client ack (no response) +// ping → pong + +import { createInterface } from 'node:readline'; + +const JSONRPC_VERSION = '2.0'; + +const SERVER_INFO = { + name: 'parsar-plugin-host', + version: '0.1.0', +}; + +const SERVER_CAPABILITIES = { + tools: {}, +}; + +/** + * Start the MCP stdio transport loop. + * @param {import('./host.js').PluginHost} host + */ +export function startMCPStdio(host) { + const rl = createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', async (line) => { + if (!line.trim()) return; + + let request; + try { + request = JSON.parse(line); + } catch { + writeLine(makeError(null, -32700, 'Parse error')); + return; + } + + // Notifications have no id — no response needed. + if (request.id === undefined || request.id === null) { + // Accept notifications silently. + return; + } + + try { + const result = await handleRequest(host, request); + writeLine({ jsonrpc: JSONRPC_VERSION, id: request.id, result }); + } catch (err) { + if (err.code) { + writeLine(makeError(request.id, err.code, err.message)); + } else { + writeLine(makeError(request.id, -32603, err.message ?? 'Internal error')); + } + } + }); + + rl.on('close', () => { + process.exit(0); + }); +} + +/** + * Route a JSON-RPC request to the appropriate handler. + */ +async function handleRequest(host, request) { + const { method, params } = request; + + switch (method) { + case 'initialize': + return handleInitialize(); + case 'ping': + return {}; + case 'tools/list': + return handleToolsList(host); + case 'tools/call': + return handleToolsCall(host, params); + default: { + const err = new Error(`Method not found: ${method}`); + err.code = -32601; + throw err; + } + } +} + +function handleInitialize() { + return { + protocolVersion: '2024-11-05', + serverInfo: SERVER_INFO, + capabilities: SERVER_CAPABILITIES, + }; +} + +function handleToolsList(host) { + const tools = []; + for (const [, def] of host.tools) { + tools.push({ + name: def.name, + description: def.description, + inputSchema: def.parameters, + }); + } + return { tools }; +} + +async function handleToolsCall(host, params) { + if (!params?.name) { + const err = new Error('tools/call: missing params.name'); + err.code = -32602; + throw err; + } + + const def = host.tools.get(params.name); + if (!def) { + const err = new Error(`tools/call: unknown tool "${params.name}"`); + err.code = -32602; + throw err; + } + + const args = params.arguments ?? {}; + const timeoutMs = 30_000; + + let result; + try { + result = await Promise.race([ + def.handler(args), + timeout(timeoutMs, `Tool "${params.name}" timed out after ${timeoutMs}ms`), + ]); + } catch (handlerErr) { + // Handler errors are returned as tool-level errors (isError: true), + // not JSON-RPC errors, per MCP spec. + return { + content: [ + { + type: 'text', + text: `Error: ${handlerErr.message ?? String(handlerErr)}`, + }, + ], + isError: true, + }; + } + + // Normalize handler return value into MCP content format. + return normalizeToolResult(result); +} + +/** + * Normalize a plugin handler's return value into MCP-compatible content array. + * + * Accepted return shapes: + * { value: any, presentation?: any } → canonical plugin result + * string → text content + * any other → JSON serialized text content + */ +function normalizeToolResult(result) { + if (result === null || result === undefined) { + return { content: [{ type: 'text', text: '' }] }; + } + + if (typeof result === 'string') { + return { content: [{ type: 'text', text: result }] }; + } + + // Canonical plugin result: { value, presentation? } + if (typeof result === 'object' && 'value' in result) { + const text = + typeof result.value === 'string' + ? result.value + : JSON.stringify(result.value, null, 2); + + const content = [{ type: 'text', text }]; + + // Stash presentation metadata in a second content block so the + // Parsar server can extract it for client-side rendering (Phase 2). + if (result.presentation) { + content.push({ + type: 'text', + text: JSON.stringify({ __parsar_presentation: result.presentation }), + }); + } + + return { content }; + } + + // Fallback: serialize as JSON. + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; +} + +function timeout(ms, message) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); +} + +function makeError(id, code, message) { + return { + jsonrpc: JSONRPC_VERSION, + id, + error: { code, message }, + }; +} + +function writeLine(obj) { + process.stdout.write(JSON.stringify(obj) + '\n'); +} diff --git a/server/plugin-host/lib/sdk.js b/server/plugin-host/lib/sdk.js new file mode 100644 index 00000000..64df1065 --- /dev/null +++ b/server/plugin-host/lib/sdk.js @@ -0,0 +1,118 @@ +// Plugin Server SDK — provides the PluginServerContext (ctx) passed to each +// plugin's server/index.js default export. + +/** + * Creates a PluginServerContext for one plugin. + * + * @param {string} pluginName - the plugin's name from manifest.json + * @param {Map} toolRegistry - shared registry (name → ToolDefinition) + * @returns {Object} ctx - the plugin server context + */ +export function createPluginContext(pluginName, toolRegistry) { + let toolCount = 0; + + const tools = { + /** + * Register a tool handler. + * + * @param {string} name - tool name (must be unique across all plugins) + * @param {Object} definition + * @param {string} definition.description - human-readable description + * @param {Object} definition.parameters - JSON Schema for the tool's input + * @param {Function} definition.handler - async (args) => { value, presentation? } + */ + define(name, definition) { + if (!name || typeof name !== 'string') { + throw new Error(`[${pluginName}] tools.define: name must be a non-empty string`); + } + if (toolRegistry.has(name)) { + throw new Error( + `[${pluginName}] tools.define: tool "${name}" is already registered` + ); + } + if (typeof definition.handler !== 'function') { + throw new Error( + `[${pluginName}] tools.define("${name}"): handler must be a function` + ); + } + + const params = normalizeParameters(definition.parameters ?? {}); + + toolRegistry.set(name, { + name, + description: definition.description ?? '', + parameters: params, + handler: definition.handler, + pluginName, + }); + toolCount++; + }, + + /** @internal — used by host for logging */ + _count() { + return toolCount; + }, + }; + + // Future phases will add ctx.hooks, ctx.credentials, ctx.api, etc. + // For Phase 1, only ctx.tools is implemented. + return { + tools, + // Placeholder for future phases — calling these throws NotImplemented. + hooks: notImplementedProxy('hooks'), + credentials: notImplementedProxy('credentials'), + api: notImplementedProxy('api'), + }; +} + +/** + * Normalize the parameters definition into JSON Schema "object" format. + * Supports shorthand like { title: 'string', project: 'string' } + * as well as full JSON Schema objects. + */ +function normalizeParameters(params) { + // Already valid JSON Schema object. + if (params.type === 'object' && params.properties) { + return params; + } + + // Shorthand: key → type-string + const properties = {}; + const required = []; + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + properties[key] = { type: value }; + required.push(key); + } else if (typeof value === 'object' && value !== null) { + properties[key] = value; + if (!value.optional) { + required.push(key); + } + } + } + + return { + type: 'object', + properties, + required: required.length > 0 ? required : undefined, + }; +} + +/** + * Returns a Proxy that throws NotImplementedError for any property access. + */ +function notImplementedProxy(namespace) { + return new Proxy( + {}, + { + get(_, prop) { + if (prop === Symbol.toPrimitive || prop === 'then') return undefined; + return () => { + throw new Error( + `ctx.${namespace}.${String(prop)} is not implemented yet (planned for a future phase)` + ); + }; + }, + } + ); +} diff --git a/server/plugin-host/package.json b/server/plugin-host/package.json new file mode 100644 index 00000000..a12cb985 --- /dev/null +++ b/server/plugin-host/package.json @@ -0,0 +1,14 @@ +{ + "name": "@parsar/plugin-host", + "version": "0.1.0", + "private": true, + "description": "Parsar Plugin Host — loads plugin server modules and exposes tools via MCP stdio protocol", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "engines": { + "node": ">=20.0.0" + } +} From 6d13eb63b62d92e4757667f481c3fe1009c9dae5 Mon Sep 17 00:00:00 2001 From: liyb Date: Sun, 23 Aug 2026 19:49:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(plugin):=20Phase=202=20=E2=80=94=20cli?= =?UTF-8?q?ent=20UI=20slot=20system=20+=20hotel=20workspace=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the client-side plugin system: plugins can register React components to named slots in the web UI, replacing or extending any section of the interface. Architecture: - Slot registry (plugin-slots.ts): single/list/chain slot types with version-counting useSyncExternalStore integration - SlotRenderer components: ToolCardSlot, ListSlot, SingleSlot with PluginErrorBoundary isolation - Plugin loader: fetches /api/v1/plugins/{name}/client.js, executes via new Function(), idempotent load/unload on capability changes - window.__PARSAR_PLUGIN_API__ exposes shared React + definePlugin API - CLI auto-builds client TSX with esbuild during plugin add - Go endpoint serves built client.js from plugins directory Slot extension points placed in: - App.tsx: workspace.main (full-page takeover) - ConversationsPage: workspace.content, conversation.header.actions, conversation.input.dock, conversation.tool-card - AdminLayout: layout.header.actions, layout.nav.bottom Example: hotel-ops plugin extended with full workspace UI (KPI cards, room grid, events panel) + custom tool-result cards (RoomStatusCard, PricingCard). Also bumps Go 1.25.12 → 1.25.13 to fix 7 stdlib vulnerabilities (GO-2026-6218 through GO-2026-5026). --- CONTRIBUTING.md | 27 + apps/parsar/internal/cli/plugin.go | 61 +++ apps/web/src/App.tsx | 12 +- .../web/src/components/layout/AdminLayout.tsx | 3 + .../src/components/plugin/SlotRenderer.tsx | 139 +++++ apps/web/src/lib/api-capabilities.ts | 2 + apps/web/src/lib/plugin-init.ts | 20 + apps/web/src/lib/plugin-loader.ts | 79 +++ apps/web/src/lib/plugin-slots.ts | 201 ++++++++ apps/web/src/lib/use-plugins.ts | 77 +++ apps/web/src/main.tsx | 1 + .../web/src/pages/admin/ConversationsPage.tsx | 66 ++- examples/plugins/hotel-ops/client/index.tsx | 341 +++++++++++++ examples/plugins/hotel-ops/manifest.json | 3 + examples/plugins/hotel-ops/server/index.js | 13 + go.mod | 2 +- go.work | 2 +- server/cmd/server/main.go | 1 + server/internal/dev/plugin_client_routes.go | 82 +++ server/internal/dev/routes.go | 6 + server/plugin-host/build-client.js | 61 +++ server/plugin-host/package-lock.json | 475 ++++++++++++++++++ server/plugin-host/package.json | 3 + 23 files changed, 1671 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/plugin/SlotRenderer.tsx create mode 100644 apps/web/src/lib/plugin-init.ts create mode 100644 apps/web/src/lib/plugin-loader.ts create mode 100644 apps/web/src/lib/plugin-slots.ts create mode 100644 apps/web/src/lib/use-plugins.ts create mode 100644 examples/plugins/hotel-ops/client/index.tsx create mode 100644 server/internal/dev/plugin_client_routes.go create mode 100644 server/plugin-host/build-client.js create mode 100644 server/plugin-host/package-lock.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3833bcc0..93138c19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,6 +188,33 @@ description and keep ownership on the side listed here. phases will add `ctx.hooks`, `ctx.credentials`, and `ctx.api`. - Plugin tool handlers have a 30-second timeout. Errors are returned as MCP tool-level errors (`isError: true`), not JSON-RPC errors. +- **Client UI** uses a slot-based extension system + (`apps/web/src/lib/plugin-slots.ts`). Plugins register React components + to named slots via `ctx.slots.register(slotId, { key, component, match? })`. +- Slot types: `single` (last registration replaces), `list` (all render + in order), `chain` (first match wins — used for tool-card rendering). +- Client bundles are built by the CLI during `parsar plugin add` using + esbuild (`server/plugin-host/build-client.js`). Output goes to + `//dist/client.js`. Served via + `GET /api/v1/plugins/{name}/client.js`. +- React is shared via `window.__PARSAR_PLUGIN_API__` (exposed in + `plugin-init.ts`). Plugins must NOT bundle their own React. +- Plugin client bundles use IIFE format with a `require()` shim and an + esbuild `externalize-react` plugin. Standard `import React` works; + `react-dom` specific APIs (`createPortal`, etc.) are not yet supported. +- The frontend loads plugin clients on page load via `usePluginClients` + hook. Binding/unbinding a capability triggers an immediate reload + through React Query invalidation. +- Predefined slot IDs (add new ones as FDE needs arise): + `workspace.main`, `workspace.content`, `layout.header.actions`, + `layout.nav.bottom`, `conversation.tool-card`, + `conversation.header.actions`, `conversation.input.dock`, + `conversation.composer.left/right`, `agent.workspace`, + `agent.settings.section`. +- Adding a new slot point: wrap the target area with + `} />` or insert + `` at the desired position. Each new slot is + 3–5 lines of code. ### Human interaction lifecycle diff --git a/apps/parsar/internal/cli/plugin.go b/apps/parsar/internal/cli/plugin.go index 05f058c3..37e6cd9f 100644 --- a/apps/parsar/internal/cli/plugin.go +++ b/apps/parsar/internal/cli/plugin.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + osexec "os/exec" "path/filepath" "strings" "text/tabwriter" @@ -167,6 +168,14 @@ func runPluginAdd(ctx *runContext, args []string) error { } } + // Phase 2: if the plugin has a client entry, build it with esbuild + // and copy the built bundle to the plugins storage dir. + if manifest.Client != nil && manifest.Client.Entry != "" { + if err := buildAndCopyClient(pluginDir, manifest.Name, manifest.Client.Entry); err != nil { + return fmt.Errorf("plugin add: build client: %w", err) + } + } + cfg, err := ctx.resolveConfig() if err != nil { return fmt.Errorf("plugin add: %w", err) @@ -409,3 +418,55 @@ func copyDir(src, dst string) error { } return nil } + +// ----- client build --------------------------------------------------------- + +// buildAndCopyClient builds the plugin's client entry with esbuild and +// copies the output to //dist/client.js. +// +// Requires: node + esbuild available (esbuild is loaded as ESM import in +// the build-client.js script). The build script lives next to plugin-host. +func buildAndCopyClient(pluginDir, pluginName, clientEntry string) error { + pluginsDir, err := resolvePluginsDir() + if err != nil { + return err + } + dirName := pluginDirName(pluginName) + dstDir := filepath.Join(pluginsDir, dirName, "dist") + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("create dist dir: %w", err) + } + + entryPath := filepath.Join(pluginDir, clientEntry) + outPath := filepath.Join(dstDir, "client.js") + + // Locate the build-client.js script. It lives alongside plugin-host. + // Try PARSAR_PLUGIN_HOST_PATH directory first, then fallback to relative. + buildScript := resolveBuildScript() + if buildScript == "" { + return fmt.Errorf("cannot locate build-client.js; ensure PARSAR_PLUGIN_HOST_PATH is set") + } + + // Run: node build-client.js + cmd := osexec.Command("node", buildScript, entryPath, outPath) + cmd.Dir = pluginDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("esbuild failed: %s\n%s", err, string(output)) + } + return nil +} + +// resolveBuildScript finds the build-client.js script path. +func resolveBuildScript() string { + // From PARSAR_PLUGIN_HOST_PATH (same dir as plugin-host/index.js). + hostPath := strings.TrimSpace(os.Getenv("PARSAR_PLUGIN_HOST_PATH")) + if hostPath != "" { + dir := filepath.Dir(hostPath) + candidate := filepath.Join(dir, "build-client.js") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c5773455..0a345af4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,9 @@ import { InviteAcceptPage } from "./pages/InviteAcceptPage" import { AuthProvider, useAuth } from "./lib/auth-context" import { ThemeProvider } from "./lib/theme-provider" import { useMyWorkspaces } from "./lib/api-workspaces" +import { SingleSlot } from "./components/plugin/SlotRenderer" +import { usePluginClients } from "./lib/use-plugins" +import { useWorkspaceId } from "./lib/workspace" function LoadingScreen({ message }: { message: string }) { return ( @@ -21,6 +24,8 @@ function LoadingScreen({ message }: { message: string }) { function AuthedRoot() { const { t } = useTranslation("common") const wsQuery = useMyWorkspaces() + const wsId = useWorkspaceId() + usePluginClients(wsId) if (wsQuery.isLoading) { return @@ -28,7 +33,12 @@ function AuthedRoot() { if ((wsQuery.data?.workspaces.length ?? 0) === 0) { return } - return + // workspace.main slot: when a plugin registers here, it takes over + // the entire page (full-screen). No navigation, no sidebar — the + // plugin owns everything. + return ( + } /> + ) } function Root() { diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx index 10200af5..941885f9 100644 --- a/apps/web/src/components/layout/AdminLayout.tsx +++ b/apps/web/src/components/layout/AdminLayout.tsx @@ -19,6 +19,7 @@ import { WorkspaceSwitcher } from "./WorkspaceSwitcher" import { ThemeMenu } from "./ThemeMenu" import { UserMenu } from "./UserMenu" import { useTheme } from "../../lib/theme" +import { ListSlot } from "../plugin/SlotRenderer" interface AdminLayoutProps { children: ReactNode @@ -102,6 +103,7 @@ export function AdminLayout({
+
@@ -162,6 +164,7 @@ export function AdminLayout({ ))} + )} diff --git a/apps/web/src/components/plugin/SlotRenderer.tsx b/apps/web/src/components/plugin/SlotRenderer.tsx new file mode 100644 index 00000000..f1f05158 --- /dev/null +++ b/apps/web/src/components/plugin/SlotRenderer.tsx @@ -0,0 +1,139 @@ +/** + * SlotRenderer — renders plugin-registered components at a named slot. + * + * Usage: + * } /> + * + */ + +import { Component, type ReactNode, useSyncExternalStore } from "react" +import { slotRegistry, type SlotRegistration } from "../../lib/plugin-slots" + +// ─── Hook: subscribe to slot registry ─────────────────────────────────────── + +const subscribe = (cb: () => void) => slotRegistry.subscribe(cb) +const getSnapshot = () => slotRegistry.getVersion() + +function useSlotRegistrations(slotId: string): SlotRegistration[] { + // Subscribe to version changes; derive the list from the stable cache. + useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + return slotRegistry.getRegistrations(slotId) +} + +// ─── ErrorBoundary ────────────────────────────────────────────────────────── + +interface ErrorBoundaryProps { + pluginName: string + children: ReactNode +} +interface ErrorBoundaryState { + error: Error | null +} + +class PluginErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + render() { + if (this.state.error) { + return ( +
+ Plugin "{this.props.pluginName}" crashed: {this.state.error.message} +
+ ) + } + return this.props.children + } +} + +// ─── ToolCardSlot (chain type) ────────────────────────────────────────────── + +interface ToolCardSlotProps { + /** The presentation metadata from the tool result. */ + presentation?: { kind?: string; data?: unknown } | null + /** Raw tool result content (fallback text). */ + content?: string + /** Rendered when no plugin claims the presentation. */ + fallback?: ReactNode +} + +/** + * Renders a plugin-registered tool card if a plugin's match() claims the + * presentation. Falls back to the default rendering otherwise. + */ +export function ToolCardSlot({ presentation, content, fallback }: ToolCardSlotProps) { + if (!presentation?.kind) return <>{fallback} + + const match = slotRegistry.matchChain("conversation.tool-card", { presentation, content }) + if (!match) return <>{fallback} + + const { registration, data } = match + const PluginComponent = registration.component + + return ( + + + + ) +} + +// ─── ListSlot ─────────────────────────────────────────────────────────────── + +interface ListSlotProps { + slotId: string + /** Extra props passed to every registered component. */ + context?: Record +} + +/** + * Renders all plugin-registered components for a "list" slot, in order. + */ +export function ListSlot({ slotId, context }: ListSlotProps) { + const registrations = useSlotRegistrations(slotId) + if (registrations.length === 0) return null + + return ( + <> + {registrations.map((reg) => { + const PluginComponent = reg.component + return ( + + + + ) + })} + + ) +} + +// ─── SingleSlot ───────────────────────────────────────────────────────────── + +interface SingleSlotProps { + slotId: string + /** Extra props passed to the registered component. */ + context?: Record + /** Rendered when no plugin has registered for this slot. */ + fallback?: ReactNode +} + +/** + * Renders the last-registered plugin component for a "single" slot. + * Falls back to children when no registration exists. + */ +export function SingleSlot({ slotId, context, fallback }: SingleSlotProps) { + const registrations = useSlotRegistrations(slotId) + if (registrations.length === 0) return <>{fallback} + + // Single slot: last registration wins. + const reg = registrations[registrations.length - 1] + const PluginComponent = reg.component + + return ( + + + + ) +} diff --git a/apps/web/src/lib/api-capabilities.ts b/apps/web/src/lib/api-capabilities.ts index 753a38ee..9050b01d 100644 --- a/apps/web/src/lib/api-capabilities.ts +++ b/apps/web/src/lib/api-capabilities.ts @@ -328,6 +328,7 @@ export function useEnableAgentCapabilityMutation( onSuccess: () => { if (workspaceID && agentID) { qc.invalidateQueries({ queryKey: KEY_AGENT_CAPABILITIES(workspaceID, agentID) }) + qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceID] }) } }, }) @@ -377,6 +378,7 @@ export function useDeleteAgentCapabilityMutation( onSuccess: () => { if (workspaceID && agentID) { qc.invalidateQueries({ queryKey: KEY_AGENT_CAPABILITIES(workspaceID, agentID) }) + qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceID] }) } }, }) diff --git a/apps/web/src/lib/plugin-init.ts b/apps/web/src/lib/plugin-init.ts new file mode 100644 index 00000000..96e71cac --- /dev/null +++ b/apps/web/src/lib/plugin-init.ts @@ -0,0 +1,20 @@ +/** + * Plugin system initialization — exposes the shared React instance and + * plugin registration API on window so client bundles can access them. + * + * Must be imported early in main.tsx (before any plugin loading happens). + */ + +import * as React from "react" +import { createPluginClientContext, type ParsarPluginAPI } from "./plugin-slots" + +const api: ParsarPluginAPI = { + React, + createContext: createPluginClientContext, + definePlugin(pluginName, setup) { + const ctx = createPluginClientContext(pluginName) + setup(ctx) + }, +} + +window.__PARSAR_PLUGIN_API__ = api diff --git a/apps/web/src/lib/plugin-loader.ts b/apps/web/src/lib/plugin-loader.ts new file mode 100644 index 00000000..cb77ce6c --- /dev/null +++ b/apps/web/src/lib/plugin-loader.ts @@ -0,0 +1,79 @@ +/** + * Plugin Client Loader — fetches plugin client.js bundles and executes them. + * + * On page load (or when the agent's enabled plugins change), this module: + * 1. Fetches the plugin list for the current agent/workspace + * 2. For each plugin with a client_entry, fetches GET /api/v1/plugins/{name}/client.js + * 3. Executes the bundle in a function scope with access to window.__PARSAR_PLUGIN_API__ + * + * Each plugin bundle is expected to call: + * const { React, definePlugin } = window.__PARSAR_PLUGIN_API__ + * definePlugin("@internal/my-plugin", (ctx) => { ctx.slots.register(...) }) + */ + +import { slotRegistry } from "./plugin-slots" + +interface PluginManifest { + name: string + client_entry?: string +} + +const loadedPlugins = new Set() + +/** + * Load a single plugin's client bundle by name. + * Idempotent: skips if already loaded. + */ +export async function loadPluginClient(pluginName: string): Promise { + if (loadedPlugins.has(pluginName)) return + + // Derive URL-safe name (strip @scope/ prefix for the path segment). + const dirName = pluginName.includes("/") + ? pluginName.slice(pluginName.lastIndexOf("/") + 1) + : pluginName + + const url = `/api/v1/plugins/${encodeURIComponent(dirName)}/client.js` + + try { + const resp = await fetch(url) + if (!resp.ok) { + console.warn(`[plugin-loader] failed to fetch client for "${pluginName}": ${resp.status}`) + return + } + const code = await resp.text() + + // Execute the plugin code. It should call window.__PARSAR_PLUGIN_API__.definePlugin() + // or access the API directly. + const fn = new Function(code) + fn() + + loadedPlugins.add(pluginName) + console.info(`[plugin-loader] loaded client for "${pluginName}"`) + } catch (err) { + console.error(`[plugin-loader] error loading "${pluginName}":`, err) + } +} + +/** + * Load all plugin clients that have a client_entry. + * Called from the conversation view when plugins are resolved. + */ +export async function loadAllPluginClients(plugins: PluginManifest[]): Promise { + const withClient = plugins.filter((p) => p.client_entry) + await Promise.allSettled(withClient.map((p) => loadPluginClient(p.name))) +} + +/** + * Unload a plugin (remove its slot registrations). + */ +export function unloadPlugin(pluginName: string): void { + slotRegistry.unregisterPlugin(pluginName) + loadedPlugins.delete(pluginName) +} + +/** + * Check if a plugin client is already loaded. + */ +export function isPluginLoaded(pluginName: string): boolean { + return loadedPlugins.has(pluginName) +} diff --git a/apps/web/src/lib/plugin-slots.ts b/apps/web/src/lib/plugin-slots.ts new file mode 100644 index 00000000..09542c06 --- /dev/null +++ b/apps/web/src/lib/plugin-slots.ts @@ -0,0 +1,201 @@ +/** + * Plugin Slot Registry — the core client-side plugin system. + * + * Plugins register React components to named "slots" in the UI. The main + * app renders SlotRenderer at each slot position, which queries this registry + * and renders the appropriate plugin component. + * + * Slot types: + * single — only the last registration wins (replace entire area) + * list — all registrations render in order + * chain — first registration whose `match` returns truthy wins + * + * Standard slot IDs: + * workspace.main — replace entire workspace (single) + * agent.workspace — replace agent right panel (single) + * conversation.tool-card — custom tool result card (chain) + * conversation.header.actions — header action buttons (list) + * conversation.input.dock — above-input panel (list) + * conversation.composer.left — left of input (list) + * conversation.composer.right — right of input (list) + * agent.settings.section — agent settings extensions (list) + */ + +import type { ComponentType } from "react" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type SlotType = "single" | "list" | "chain" + +export interface SlotRegistration { + /** Unique key for this registration (plugin dedup). */ + key: string + /** The owning plugin name. */ + pluginName: string + /** The React component to render. */ + component: ComponentType + /** For "chain" slots: return truthy data to claim rendering. */ + match?: (props: any) => any + /** Sort order for "list" slots. Lower = earlier. Default 0. */ + order?: number +} + +export interface SlotDefinition { + id: string + type: SlotType +} + +// ─── Registry ─────────────────────────────────────────────────────────────── + +/** Predefined slot definitions. */ +export const SLOT_DEFINITIONS: Record = { + // Full-page slots + "workspace.main": { id: "workspace.main", type: "single" }, + "workspace.content": { id: "workspace.content", type: "single" }, + "agent.workspace": { id: "agent.workspace", type: "single" }, + // Layout extension slots + "layout.header.actions": { id: "layout.header.actions", type: "list" }, + "layout.nav.bottom": { id: "layout.nav.bottom", type: "list" }, + // Conversation slots + "conversation.tool-card": { id: "conversation.tool-card", type: "chain" }, + "conversation.header.actions": { id: "conversation.header.actions", type: "list" }, + "conversation.input.dock": { id: "conversation.input.dock", type: "list" }, + "conversation.composer.left": { id: "conversation.composer.left", type: "list" }, + "conversation.composer.right": { id: "conversation.composer.right", type: "list" }, + // Agent extension slots + "agent.settings.section": { id: "agent.settings.section", type: "list" }, +} + +class PluginSlotRegistry { + private slots = new Map() + private sortedCache = new Map() + private listeners = new Set<() => void>() + private version = 0 + + /** Register a component to a slot. */ + register(slotId: string, reg: Omit, pluginName: string): void { + const full: SlotRegistration = { ...reg, pluginName } + const list = this.slots.get(slotId) ?? [] + + // Dedup by key: replace if same key exists. + const idx = list.findIndex((r) => r.key === full.key) + if (idx >= 0) { + list[idx] = full + } else { + list.push(full) + } + + this.slots.set(slotId, list) + this.sortedCache.delete(slotId) + this.notify() + } + + /** Remove all registrations from a specific plugin. */ + unregisterPlugin(pluginName: string): void { + let changed = false + for (const [slotId, list] of this.slots) { + const filtered = list.filter((r) => r.pluginName !== pluginName) + if (filtered.length !== list.length) { + this.slots.set(slotId, filtered) + this.sortedCache.delete(slotId) + changed = true + } + } + if (changed) this.notify() + } + + /** Get all registrations for a slot, sorted by order. Cached for React stability. */ + getRegistrations(slotId: string): SlotRegistration[] { + const cached = this.sortedCache.get(slotId) + if (cached) return cached + + const list = this.slots.get(slotId) ?? [] + const sorted = [...list].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + this.sortedCache.set(slotId, sorted) + return sorted + } + + /** For chain slots: find the first registration whose match() returns truthy. */ + matchChain(slotId: string, props: any): { registration: SlotRegistration; data: any } | null { + const regs = this.getRegistrations(slotId) + for (const reg of regs) { + if (!reg.match) continue + const data = reg.match(props) + if (data) return { registration: reg, data } + } + return null + } + + /** Subscribe to registry changes (for React re-renders). */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + /** Get a version number for useSyncExternalStore snapshot comparison. */ + getVersion(): number { + return this.version + } + + private notify(): void { + this.version++ + for (const fn of this.listeners) fn() + } +} + +/** The singleton slot registry instance. */ +export const slotRegistry = new PluginSlotRegistry() + +// ─── Plugin API (exposed on window) ──────────────────────────────────────── + +/** + * The PluginClientContext provided to each plugin's client entry. + * Plugin code calls ctx.slots.register(...) to inject UI. + */ +export interface PluginClientContext { + slots: { + register( + slotId: string, + registration: { + key: string + component: ComponentType + match?: (props: any) => any + order?: number + } + ): void + } +} + +/** + * Creates a PluginClientContext for a specific plugin. + * Called by the plugin loader before executing each plugin's client.js. + */ +export function createPluginClientContext(pluginName: string): PluginClientContext { + return { + slots: { + register(slotId, registration) { + slotRegistry.register(slotId, registration, pluginName) + }, + }, + } +} + +// ─── Window global API ────────────────────────────────────────────────────── + +export interface ParsarPluginAPI { + /** React library — shared with plugins so they don't bundle their own. */ + React: typeof import("react") + /** Create a plugin context (used internally by the loader). */ + createContext: (pluginName: string) => PluginClientContext + /** Convenience: directly register a plugin's default export. */ + definePlugin: ( + pluginName: string, + setup: (ctx: PluginClientContext) => void + ) => void +} + +declare global { + interface Window { + __PARSAR_PLUGIN_API__?: ParsarPluginAPI + } +} diff --git a/apps/web/src/lib/use-plugins.ts b/apps/web/src/lib/use-plugins.ts new file mode 100644 index 00000000..01848c71 --- /dev/null +++ b/apps/web/src/lib/use-plugins.ts @@ -0,0 +1,77 @@ +/** + * React hook for loading plugin client bundles for the current workspace. + * Fetches the plugin list from the capabilities API and loads any that + * have client_entry defined. + * + * Plugins are loaded once on page load. After binding or unbinding a + * capability, refresh the page to pick up the change (same as DSH). + */ + +import { useEffect, useRef } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { apiRequest, noUnreachableRetry } from "./api-client" +import { loadAllPluginClients, unloadPlugin, isPluginLoaded } from "./plugin-loader" + +interface PluginCapability { + id: string + name: string + type: string +} + +interface PluginListResponse { + capabilities: PluginCapability[] +} + +async function fetchBundleCapabilities(wsId: string): Promise { + const resp = await apiRequest( + `/api/v1/workspaces/${encodeURIComponent(wsId)}/capabilities`, + { query: { type: "bundle" } } + ) + return resp.capabilities ?? [] +} + +export function usePluginClients(workspaceId: string | null) { + const prevPluginsRef = useRef>(new Set()) + + const { data: capabilities } = useQuery({ + queryKey: ["plugins", "bundles", workspaceId ?? "_none"], + queryFn: () => { + if (!workspaceId) return [] + return fetchBundleCapabilities(workspaceId) + }, + enabled: !!workspaceId, + retry: noUnreachableRetry, + staleTime: Infinity, + }) + + useEffect(() => { + if (!capabilities) return + + const currentNames = new Set( + capabilities.filter((c) => c.type === "bundle").map((c) => c.name) + ) + + // Unload plugins that were previously loaded but are no longer in the list. + for (const name of prevPluginsRef.current) { + if (!currentNames.has(name)) { + unloadPlugin(name) + } + } + + // Load new plugins that aren't loaded yet. + const toLoad = [...currentNames].filter((name) => !isPluginLoaded(name)) + if (toLoad.length > 0) { + void loadAllPluginClients(toLoad.map((name) => ({ name, client_entry: "yes" }))) + } + + prevPluginsRef.current = currentNames + }, [capabilities]) +} + +/** Invalidate the plugin bundles query to trigger reload/unload. */ +export function useInvalidatePlugins() { + const qc = useQueryClient() + return (workspaceId: string) => { + void qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceId] }) + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 288fb3ae..f6b1c77d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from './App' import { bootstrapWorkspace } from './lib/bootstrap' import { prefetchProviderCatalog } from './lib/model-presets' +import './lib/plugin-init' // expose window.__PARSAR_PLUGIN_API__ before plugins load import './style.css' import './i18n' // bootstrap i18next import './i18n/types' // type-augment t() keys diff --git a/apps/web/src/pages/admin/ConversationsPage.tsx b/apps/web/src/pages/admin/ConversationsPage.tsx index 5120b13c..94a9e49a 100644 --- a/apps/web/src/pages/admin/ConversationsPage.tsx +++ b/apps/web/src/pages/admin/ConversationsPage.tsx @@ -68,6 +68,7 @@ import { writeConversationViewState, } from "../../lib/conversation-view-state" import { credentialKindLabel } from "./capability-ui" +import { ToolCardSlot, SingleSlot, ListSlot } from "../../components/plugin/SlotRenderer" const FOLD_KEY = "parsar:conv:sidebarFolded" @@ -715,6 +716,23 @@ function ConversationMain(p: MainProps) { const err = p.convError const isUnreachable = err instanceof ApiError && err.envelope.unreachable + // workspace.content slot: plugin can replace the conversation content + // area while keeping the navigation sidebar intact. + return ( + + } + /> + ) +} + +function ConversationMainInner(p: MainProps & { err: unknown; isUnreachable: boolean }) { + const { t } = useTranslation("admin") + const { err, isUnreachable } = p + return (
{p.folded && ( @@ -1021,6 +1039,7 @@ function ChatStream({ {t("conversations.detail.cancelAll", { defaultValue: "Cancel all" })} )} +
@@ -1121,6 +1140,7 @@ function ChatStream({
+ {chatToast && setChatToast(null)} />} (s.status === "running" ? { ...s, status: "failed" as const } : s)) }) const failedRun = (outputRuns ?? []).find((r) => r.status === "failed") + // Extract presentation from: 1) message metadata, or 2) tool step results. + // Plugin-host embeds __parsar_presentation in the MCP tool_result content + // blocks; the daemon forwards it in the step result.content array. + const presentation = (metadata?.presentation as { kind?: string; data?: unknown } | undefined) + ?? extractPresentationFromSteps(allSteps) return (
{agentName || "Agent"}
-
-

{content}

-
+ +

{content}

+
+ } + /> {allSteps.length > 0 && } {failedRun && onOpenRun && (