From d853b66b87d260ac4aba4ab031ea8634550adc3a Mon Sep 17 00:00:00 2001 From: Kandev Agent Date: Thu, 16 Jul 2026 18:10:07 +0100 Subject: [PATCH] docs: add plugin system guide, authoring tutorial, and manifest reference Co-Authored-By: Claude Fable 5 --- docs/public/extending-kandev.md | 25 ++- docs/public/meta.json | 3 + docs/public/plugins-authoring.md | 328 +++++++++++++++++++++++++++++++ docs/public/plugins-manifest.md | 196 ++++++++++++++++++ docs/public/plugins.md | 184 +++++++++++++++++ 5 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 docs/public/plugins-authoring.md create mode 100644 docs/public/plugins-manifest.md create mode 100644 docs/public/plugins.md diff --git a/docs/public/extending-kandev.md b/docs/public/extending-kandev.md index 21c54b61b4..e57162ce71 100644 --- a/docs/public/extending-kandev.md +++ b/docs/public/extending-kandev.md @@ -5,7 +5,7 @@ description: "Add an agent, executor, integration, workflow capability, MCP tool # Extending Kandev -Kandev has extension seams for agents, executors, integrations, workflow behavior, MCP tools, and UI settings. An extension is complete only when discovery/configuration, runtime behavior, failure handling, tests, and public documentation agree. +Kandev has extension seams for agents, executors, integrations, workflow behavior, MCP tools, plugins, and UI settings. An extension is complete only when discovery/configuration, runtime behavior, failure handling, tests, and public documentation agree. ## Add an agent CLI @@ -48,6 +48,29 @@ Task/config MCP handlers and server registration live under `internal/mcp/handle Do not trust caller-supplied task identity when the server can inject it. Consider destructive-action confirmation, relationship reachability, pagination, and concurrent task state. Update [Automation and MCP](automation-and-mcp.md) when user capability changes. +## Build a plugin + +Plugins are a peer extension mechanism to the seams above, aimed at +extensions that should ship and version independently of a kandev release. +A plugin backend is a Go binary that kandev spawns and supervises as a +subprocess, communicating over a strict typed gRPC protocol +(`internal/plugins/`, `pkg/pluginsdk`) — it receives bus events, serves +agent-invocable tools, and relays external webhooks, calling back into +kandev through a capability-gated Host RPC service (state, secrets, +cross-plugin events). A plugin may additionally ship an optional **native +frontend bundle** that the SPA loads at boot to register real routes, nav +items, slot components, and WebSocket handlers, sharing kandev's own React +instance and app store. + +Plugins are distributed as a signed-or-unsigned tarball and installed by URL, +manual upload, or filesystem sideload/sync — there is no manifest-paste +registration step and no credentials to issue. The whole system sits behind +the `plugins` feature flag (Settings > System > Feature Toggles), off by +default in production. See [Plugins](plugins.md) for the operator-facing +install/operate flow, [Authoring a plugin](plugins-authoring.md) for the +build tutorial and SDK reference, and the [Plugin manifest +reference](plugins-manifest.md) for the complete `manifest.yaml` schema. + ## Add a settings or workbench surface Backend configuration should have a typed API and durable ownership before a web form is added. Web settings route through `apps/web/src/settings-routes.tsx`; task surfaces live in the relevant workbench components and state slices. diff --git a/docs/public/meta.json b/docs/public/meta.json index 0d167a77eb..ce47f7c60a 100644 --- a/docs/public/meta.json +++ b/docs/public/meta.json @@ -37,6 +37,9 @@ "web-development", "testing", "extending-kandev", + "plugins", + "plugins-authoring", + "plugins-manifest", "add-agent-cli", "release-process", "---Protocol Reference---", diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md new file mode 100644 index 0000000000..b1b12a2fac --- /dev/null +++ b/docs/public/plugins-authoring.md @@ -0,0 +1,328 @@ +--- +title: "Authoring a Plugin" +description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance." +--- + +# Authoring a Plugin + +This is a build tutorial for a kandev plugin: a Go backend spawned by kandev +over gRPC, with an optional native frontend bundle. See [Plugins](plugins.md) +for the operator-facing install/enable/disable flow, and the [Plugin manifest +reference](plugins-manifest.md) for the full `manifest.yaml` schema. + +Two working example plugins accompany this guide: + +- `kandev-plugin-hello` — a nav item, native route, sidebar slot component, a + WS-driven counter, a Host-state-backed event handler, an agent tool, and a + webhook. +- `kandev-plugin-github` — a connector PoC that shells out to the `gh` CLI + from a webhook handler used as a request relay, reads live kanban data from + the host store, and opens kandev's real create-task dialog + (`host.ui.TaskCreateDialog`) prefilled from a pull request. + +## Prerequisites + +- Go (matching the version in `apps/backend/go.mod`). +- The kandev plugin SDK, `github.com/kandev/kandev/pkg/pluginsdk`. It is + **not yet published as a standalone Go module** — it lives inside the + kandev monorepo (`apps/backend/pkg/pluginsdk`), so a plugin repo develops + against a local checkout via a `replace` directive: + + ```go + // go.mod + module my-plugin + + require github.com/kandev/kandev v0.0.0-00010101000000-000000000000 + + replace github.com/kandev/kandev => ../kandev/apps/backend + ``` + + Adjust the relative path to wherever you've checked out the kandev repo. + This is what both example plugins do; see their `go.mod` for the exact + pinned dependency versions. + +## Backend: minimal plugin + +A plugin backend implements `pluginsdk.Plugin` and calls `pluginsdk.Serve`, +which owns the entire go-plugin/gRPC transport — handshake, plugin map, and +Host injection. There is no HTTP server, no listen address, and no +credentials to configure. + +```go +// main.go +package main + +import "github.com/kandev/kandev/pkg/pluginsdk" + +func main() { + pluginsdk.Serve(&myPlugin{}) +} +``` + +```go +// plugin.go +package main + +import ( + "context" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +type myPlugin struct { + pluginsdk.UnimplementedPlugin // embed to get HostSetter + no-op defaults +} + +var _ pluginsdk.Plugin = (*myPlugin)(nil) +``` + +`pluginsdk.Plugin` is the interface kandev calls into: + +```go +type Plugin interface { + // OnEvent handles a single bus event delivery. A non-nil error causes + // kandev to retry (3 retries, 5s/15s/45s backoff). + OnEvent(ctx context.Context, e *Event) error + + // InvokeTool handles an agent-invoked tool call declared in tools: in + // manifest.yaml. + InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) + + // HandleWebhook handles an inbound request relayed from + // POST /api/plugins/{id}/webhooks/{key}. + HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) +} +``` + +Embed `pluginsdk.UnimplementedPlugin` and override only the methods you need +— it's a no-op base that also implements `HostSetter`, so `Serve` injects a +live `Host` into your plugin once the broker connection back to kandev is +established (retrieve it later via `p.Host()`). + +## The Host API + +A plugin calls back into kandev through the injected `Host`: + +```go +type Host interface { + GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error) + SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error + DeleteState(ctx context.Context, scope, scopeID, key string) error + ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error) + RevealSecret(ctx context.Context, ref string) (string, error) + EmitEvent(ctx context.Context, name string, payload map[string]any) error +} +``` + +`scope` is one of `instance`, `workspace`, `task`, `agent` (`scopeID` empty +for `instance`). `EmitEvent` publishes `plugin..` on +kandev's internal event bus for delivery to any subscriber (including other +plugins). + +**Capability gating.** Every Host RPC is checked against your manifest's +`capabilities` before the handler runs: `GetState`/`SetState`/`DeleteState`/ +`ListState` require `capabilities.state: true`, `RevealSecret` requires +`capabilities.secrets: true`. Calling one without the declared capability +returns gRPC `PermissionDenied` with message `capability '' not +declared` — declare what you use. + +**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into +every spawned plugin subprocess — a per-plugin writable directory +(`~/.kandev/plugins//data`) for anything you'd rather keep on disk than +in `Host` state. + +```go +func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { + host := p.Host() + if host == nil { + return nil // broker dial still in progress + } + value, found, err := host.GetState(ctx, "instance", "", "count") + // ... + return host.SetState(ctx, "instance", "", "count", map[string]any{"n": 1}) +} +``` + +## Optional: native UI + +A plugin may ship `ui.bundle` in its manifest: a **hand-written, no-build** +plain-JS ES module. There is no bundler step — kandev serves the file +verbatim from the extracted package directory, so you edit the bundle and +repackage. + +The single entry point: the bundle, once evaluated, calls +`window.registerKandevPlugin(id, { initialize(registry, host), destroy?() })`. +Kandev's frontend host imports the bundle on boot (or on runtime enable), +then calls `initialize(registry, host)`. On disable/uninstall it calls +`destroy?.()` and bulk-revokes every registration the plugin made. + +**Registry surface** (`registry: PluginRegistry`, passed to `initialize`): + +```ts +interface PluginRegistry { + // Top-level SPA route, exact-match against window.location path. + registerRoute(path: string, Component: React.ComponentType): void; + // Sidebar/main nav entry, rendered by . + registerNavItem(item: { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" }): void; + // Route under /settings/plugins/{id}/..., rendered inside the settings shell. + registerSettingsRoute(path: string, Component: React.ComponentType): void; + // Named slot injection. Initial slots: "task-sidebar", "settings-nav", "main-nav-footer". + registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void; + // WS action handler, bridged into the existing lib/ws dispatch. + registerWsHandler(action: string, handler: (payload: unknown) => void): void; +} +``` + +**Host API** (`host: PluginHostApi`, passed to `initialize`): + +```ts +interface PluginHostApi { + pluginId: string; + React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React + jsx: typeof React.createElement; // convenience alias + store: { // kandev's live app store (read-only surface) + getState(): AppState; + setState(partial): void; + subscribe(listener): () => void; + }; + api: { + // fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler. + fetch(path: string, init?: RequestInit): Promise; + }; + ui: Record; // curated @kandev/ui subset (Button, Card, Badge, ... TaskCreateDialog) + theme: "light" | "dark"; +} +``` + +A plugin bundle must render with `host.React` / `host.jsx` — bundling your +own React copy breaks hook identity against the host tree. `host.ui` is a +curated `@kandev/ui` subset plus one piece of first-party app UI, +`TaskCreateDialog`, so a plugin can hand off task creation to kandev's real +create-task flow (repo/branch/agent pickers, validation) instead of POSTing +directly. + +## Three integration patterns + +Both example repos are full, working plugins — read them rather than +copy-pasting fragments: + +1. **Event handling with Host state** (`kandev-plugin-hello/server/plugin.go`). + `OnEvent` receives `task.created` deliveries (declared in + `capabilities.events`) and increments a persistent counter via + `Host.GetState`/`SetState`, scoped `instance`. Demonstrates the full + Host state round trip and idempotent-by-design event handling. + +2. **Live host-store reads from native UI** + (`kandev-plugin-hello/ui/bundle.js`, `useKanbanTasks`). A plugin page + subscribes directly to `host.store` and re-renders on every store change + — reading the exact same live state the first-party kanban UI renders + from, no polling and no extra API calls. + +3. **Webhook-as-relay + `TaskCreateDialog`** + (`kandev-plugin-github/server/plugin.go` and `ui/bundle.js`). The backend + declares a `prs` webhook and, on `HandleWebhook`, shells out to the `gh` + CLI (inheriting the plugin subprocess's own environment for GitHub auth) + and returns JSON. The UI page calls `host.api.fetch("webhooks/prs")` — + which kandev relays over gRPC `HandleWebhook` to the same handler — to use + an external CLI as a same-origin data source without a webhook secret. + Each PR row opens `host.ui.TaskCreateDialog` prefilled from the PR, + reading `workspaceId`/`workflowId`/`steps` from the live host store. + +## Packaging + +A plugin ships as `-.tar.gz`: + +``` +manifest.yaml # authoritative; read BEFORE any code runs +server/plugin--[.exe] # any subset of platforms declared in runtime.executables +ui/bundle.js # optional +ui/*.css / assets/icon.svg # optional +checksums.txt # generated — do not author this file +checksums.txt.sig # optional ed25519 signature +``` + +Cross-compile one executable per platform you declare in +`runtime.executables`, then pack the staged directory with +`github.com/kandev/kandev/cmd/plugin-pack`: + +``` +plugin-pack -dir -out -.tar.gz [-platform-only] +``` + +`plugin-pack` walks the directory, requires `manifest.yaml` to be present, +and **generates `checksums.txt`** covering every other file — pre-supplying +either checksum file yourself is a packaging error. `-platform-only` +restricts the package to the current host's platform, for faster local +iteration than a full multi-platform build. + +A sample `Makefile` (trimmed from `kandev-plugin-hello/Makefile`): + +```makefile +STAGE := .build/stage +VERSION := 1.0.0 +PKG_OUT := my-plugin-$(VERSION).tar.gz + +package: + rm -rf $(STAGE) + mkdir -p $(STAGE)/server + cp manifest.yaml $(STAGE)/manifest.yaml + cp -r ui $(STAGE)/ui + GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server + GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server + GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server + GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server + GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server + go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) + rm -rf $(STAGE) + +# Faster local loop: host platform only. +package-host: + rm -rf $(STAGE) + mkdir -p $(STAGE)/server + cp manifest.yaml $(STAGE)/manifest.yaml + cp -r ui $(STAGE)/ui + go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server + go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only + rm -rf $(STAGE) +``` + +Install the resulting tarball via **Settings > Plugins** (upload) or: + +```bash +curl -F "package=@my-plugin-1.0.0.tar.gz" http://localhost:38429/api/plugins/install +``` + +## Iterate loop + +- **UI-only or backend-only change:** edit the source, repackage + (`make package-host` for a fast host-only build), then reinstall. There is + no kandev rebuild needed for plugin-only changes — the extracted package + and running subprocess are entirely separate from the kandev binary. +- **`registry`/`host.ui`/host-core changes:** these live inside kandev + itself (`apps/web/lib/plugins/`), not the plugin package — changing them + requires a kandev build (`make build-web` / `make build-backend`), not a + plugin repackage. +- **Reinstalling the same version is rejected.** `pkgtar.Install` fails with + `ErrVersionExists` (HTTP 409) if `~/.kandev/plugins///` + already exists. To pick up a rebuilt package during iteration, either bump + `version` in `manifest.yaml` or uninstall the plugin first. + +## Gotchas + +- **`ui.bundle` and `ui.styles` are root-relative paths**, e.g. + `/ui/bundle.js` — not `ui/bundle.js`. Kandev serves them from + `GET /api/plugins/{id}/bundle` and `GET /api/plugins/{id}/ui/*`, resolved + against the extracted package directory. +- **Plugin pages are "unknown" SPA routes, but still get the full app + shell.** `registerRoute` paths are resolved after every static/nested + first-class route and before the kanban catch-all — a plugin can't shadow + a built-in route, but its page still renders inside the normal boot-payload-hydrated + app shell (workspaces, settings, theme), not a bare unstyled page. +- **Version bump or uninstall required to reinstall** — see "Iterate loop" + above. +- **Windows executables need the `.exe` suffix** in both the built file name + and the `runtime.executables` manifest value (e.g. + `windows-amd64: server/plugin-windows-amd64.exe`) — kandev does not append + it for you. + +Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md). diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md new file mode 100644 index 0000000000..01f45a5552 --- /dev/null +++ b/docs/public/plugins-manifest.md @@ -0,0 +1,196 @@ +--- +title: "Plugin Manifest Reference" +description: "Complete field-by-field reference for a kandev plugin's manifest.yaml, including the full event-subscription vocabulary." +--- + +# Plugin Manifest Reference + +`manifest.yaml` is the authoritative description of a plugin — identity, +runtime executables, capabilities, tools, webhooks, config schema, and +optional UI bundle. Kandev parses and validates it **before any plugin code +runs**. See [Authoring a plugin](plugins-authoring.md) for the build +workflow and [Plugins](plugins.md) for install/operate. + +## Annotated example + +```yaml +id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$ +api_version: 1 # must be 1 (the only supported value) +version: "1.0.0" +display_name: "Slack Notifications" +description: "Post to Slack on task events, relay messages to agents" +author: "kandev" +categories: ["connector"] # connector | automation | tools | analytics + +runtime: + type: binary # only supported value today + executables: + linux-amd64: server/plugin-linux-amd64 + linux-arm64: server/plugin-linux-arm64 + darwin-amd64: server/plugin-darwin-amd64 + darwin-arm64: server/plugin-darwin-arm64 + windows-amd64: server/plugin-windows-amd64.exe + # any subset; the host's - key + # is required at install time +min_kandev_version: "0.78.0" # optional + +capabilities: + events: ["task.created", "task.state_changed", "agent.completed"] + api_read: ["tasks", "agents"] # reserved, no Host RPC enforces this yet + api_write: ["tasks", "comments"] # reserved, no Host RPC enforces this yet + state: true + secrets: true + +tools: + - name: "slack_send_message" + display_name: "Send Slack Message" + description: "Posts a message to a Slack channel" + input_schema: + type: object + properties: + channel: { type: string } + text: { type: string } + required: ["channel", "text"] + +webhooks: + - key: "slack-events" + description: "Slack Events API webhook" + method: "POST" # informational only, not enforced + +config_schema: + type: object + properties: + bot_token_secret: { type: string, description: "Secret reference for Slack bot token" } + default_channel: { type: string, description: "Default channel for notifications" } + notify_on_task_created: { type: boolean, default: true } + required: ["bot_token_secret", "default_channel"] + +ui: # optional native frontend plugin + bundle: "/ui/bundle.js" # root-relative + styles: ["/ui/plugin.css"] # optional, root-relative +``` + +## Field reference + +| Field | Required | Type | Notes | +|---|---|---|---| +| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. | +| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. | +| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins///`). | +| `display_name` | no | string | Shown in Settings > Plugins. | +| `description` | no | string | Shown in Settings > Plugins. | +| `author` | no | string | Free-form. | +| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. | +| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | +| `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | +| `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. | +| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | +| `capabilities.api_read` | no | string[] | **Reserved for future Host RPCs.** Declaring values today has no effect — no Host RPC reads kandev's own data (tasks, agents, comments) yet. | +| `capabilities.api_write` | no | string[] | **Reserved for future Host RPCs.** Same status as `api_read` — not enforced by anything today. | +| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | +| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`. Calling it without this set to `true` returns gRPC `PermissionDenied`. | +| `tools[].name` | yes | string | Must be unique within the manifest — a duplicate tool name is a validation error. | +| `tools[].display_name` | no | string | Shown to operators/agents. | +| `tools[].description` | no | string | Shown to operators/agents. | +| `tools[].input_schema` | no | object | Arbitrary JSON Schema describing the tool's input. | +| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | +| `webhooks[].description` | no | string | Free-form. | +| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. | +| `config_schema` | no | object | Arbitrary JSON Schema for the operator-editable config exposed via `PATCH /api/plugins/{id}`. | +| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. | +| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `` tags on load. | +| `ui.pages` | no | object[] | Optional declarative page metadata. Secondary to `ui.bundle` — a native bundle registers its own routes/nav at runtime, so most plugins omit `ui.pages`. | +| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). | +| `ui.pages[].title` | yes* | string | Display title. | +| `ui.pages[].path` | yes* | string | Route path for the page. | +| `ui.pages[].surface` | yes* | string | Where the page mounts. Enum, one of: `settings` · `task-panel` · `main-nav`. Any other value is a validation error. | + +## Managed vs. legacy manifests + +Setting `runtime.type: binary` makes a manifest **runtime-managed**: kandev +spawns and supervises the declared executable itself. A managed manifest +must **not** set `base_url` or an `endpoints` block (`health`/`events`/ +`tools`/`webhooks` paths on a remote service) — those describe the old +remote/operator-hosted tier, and validation rejects a managed manifest that +sets them. + +A manifest with an empty `runtime.type` still *parses* as a legacy remote +manifest (with `base_url`/`endpoints` instead) and passes +`manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar +.Install` requires `manifest.IsManaged()` to be true (`runtime.type: +binary`), so a legacy manifest can never actually be installed via `POST +/api/plugins/install` or a filesystem sideload. The remote tier is +effectively removed in practice, even though the manifest schema still +recognizes its shape. + +## Event subscription vocabulary + +`capabilities.events` entries are bus subjects (e.g. `task.created`) or +wildcard patterns using `*` as a **single dot-segment** wildcard (e.g. +`task.*`, `agent.*`, `github.*`). A pattern segment of `*` matches exactly +one subject segment; every other segment must match literally; and the +**pattern and subject must have the same number of dot-separated segments** +to match at all. `task.*` matches `task.created` (2 segments each) but does +**not** match a three-segment subject such as `shell.output.` +(`shell.*` would not match it either — 2 vs. 3 segments). + +Any subject kandev publishes on its internal event bus is a valid +subscription target — this is not a closed list scoped to any one feature +area. The table below groups every subject defined in +`internal/events/types.go` by domain (some are further suffixed per-session, +e.g. `shell.output.`, `git.event.` — subscribe to the +literal wildcard segment count that matches, e.g. `shell.output.*`). + +| Domain | Events | +|---|---| +| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` | +| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` | +| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` | +| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` | +| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` | +| Task sessions | `task_session.state_changed` | +| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` | +| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` | +| Session turns | `turn.started`, `turn.completed` | +| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` | +| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` | +| Users | `user.settings.updated` | +| System | `system.job.update` | +| Environments | `environment.created`, `environment.updated`, `environment.deleted` | +| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` | +| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` | +| Agent stream | `agent.stream` (per-session: `agent.stream.`), `agent.turn.message_saved` | +| Agent prompts | `permission_request.received` (per-session: `permission_request.received.`) | +| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` | +| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) | +| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) | +| Dev server I/O | `process.output` (per-session), `process.status` (per-session) | +| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) | +| Automations | `automation.triggered`, `automation.run.created` | +| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` | +| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` | +| Jira | `jira.new_issue` | +| Linear | `linear.new_issue` | +| Sentry | `sentry.new_issue` | +| Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` | +| Cross-plugin | `plugin..` — published by `Host.EmitEvent`; subscribe with `plugin..*` to react to another plugin's events. | + +> Event list current as of 2026-07-16; regenerate from +> `apps/backend/internal/events/types.go` if this page drifts from the code. + +## Runtime-managed fields (do not author these) + +Once installed, kandev writes several fields onto the stored record +alongside the parsed manifest. These are **kandev-owned runtime state, not +author-supplied manifest fields** — do not include them in `manifest.yaml`; +they have no effect there and are overwritten on install: + +| Field | Meaning | +|---|---| +| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. | +| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins///`). | +| `signed` | Whether the installed package included a verified `checksums.txt.sig`. | +| `installed_at` | Install timestamp. | +| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. | + +Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md). diff --git a/docs/public/plugins.md b/docs/public/plugins.md new file mode 100644 index 0000000000..d8757cd2c2 --- /dev/null +++ b/docs/public/plugins.md @@ -0,0 +1,184 @@ +--- +title: "Plugins" +description: "Install and manage kandev plugins: Go backends kandev spawns and supervises, with an optional native frontend bundle." +--- + +# Plugins + +Plugins extend kandev without forking core: a plugin ships a **Go backend** +that kandev spawns and supervises as a subprocess over a strict typed gRPC +protocol, and can optionally ship a **native frontend bundle** that kandev +loads into the SPA. This page covers what plugins are, how to install and +manage them, and the current security posture. For building a plugin, see +[Authoring a plugin](plugins-authoring.md). For the manifest schema, see +[Plugin manifest reference](plugins-manifest.md). + +Plugins are an operator-level, instance-wide capability — there is no +per-user plugin access. They are gated behind the `plugins` feature flag +(**Settings > System > Feature Toggles**), off by default in production +builds and on by default in dev/e2e builds. Enabling the flag requires a +backend restart and surfaces **Settings > Plugins** in the sidebar. + +## How it works + +``` + install (URL / upload / filesystem sync) + │ + ▼ + verify checksums.txt, validate manifest.yaml + │ + ▼ + extract to ~/.kandev/plugins/// + │ + ▼ + spawn platform executable as a subprocess (hashicorp/go-plugin) + │ + ┌───────────────┼────────────────────────┐ + ▼ ▼ ▼ + gRPC DeliverEvent gRPC InvokeTool gRPC HandleWebhook + (bus events, at- (agent tool calls) (external webhook + least-once, relayed via + buffered while POST /api/plugins/ + unhealthy) {id}/webhooks/{key}) + │ │ │ + └───────────────┴────────────┬────────────┘ + ▼ + plugin calls back over the + same connection: Host.GetState / + SetState / DeleteState / ListState / + RevealSecret / EmitEvent + │ + ▼ + (optional) SPA loads ui.bundle at boot, + registers native routes/nav/slots/WS handlers +``` + +Kandev owns the whole process lifecycle: it extracts the package, spawns the +binary, completes the go-plugin handshake, health-checks it (`Ping` every +30s), and restarts it on crash or repeated health-check failure (backoff, +max 5 attempts). There is no separate operator-managed plugin process to run +or babysit — install a package and kandev does the rest. + +## Installing a plugin + +Open **Settings > Plugins** and click **Install plugin**. You can install +from a URL (kandev downloads the tarball) or by uploading a `.tar.gz` file +directly. No credentials are ever shown or copied — installing a plugin has +nothing to reveal, unlike a webhook-secret/API-key registration flow. + +The same operations are available over HTTP: + +```bash +# Install from a URL +curl -X POST http://localhost:38429/api/plugins/install \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://example.com/acme-tools-1.0.0.tar.gz"}' + +# Install by uploading a local tarball +curl -X POST http://localhost:38429/api/plugins/install \ + -F "package=@acme-tools-1.0.0.tar.gz" +``` + +Either path runs the same pipeline: + +1. Verify `checksums.txt` covers every other file in the tarball and every + hash matches (always enforced). +2. If `checksums.txt.sig` is present, verify the signature; if absent, + install proceeds with a surfaced "unsigned plugin" warning. +3. Parse and validate `manifest.yaml` **before any code runs**: schema, `id` + pattern, capability vocabulary, and that `runtime.executables` contains + an entry for the host's OS/arch. +4. Extract to `~/.kandev/plugins///` and record the + installation. +5. Spawn the platform-matched binary and complete the go-plugin handshake. + Status is `registered` while this is pending, `active` once the + handshake succeeds, or `error` if spawn/handshake fails (the operator can + retry via **Enable**). + +A successful install that failed to spawn returns HTTP 201 with a +`warning` field rather than failing outright — the package is installed, +just not yet running. + +## Filesystem sideload and Sync + +Besides install-by-URL/upload, an operator with shell access to the host can +place plugin content directly under `~/.kandev/plugins/` without going +through the install endpoint. The **Sync** button in Settings > Plugins (and +`POST /api/plugins/sync`) reconciles kandev's registry with what is actually +on disk: + +- **A dropped directory** — `~/.kandev/plugins///manifest.yaml` + placed manually with no existing record — is validated and registered + with status **`disabled`**, never auto-enabled. Directory sideloads skip + the checksum/integrity gate the URL/upload pipeline runs, so an operator + must explicitly inspect and enable one. If more than one version + directory exists for the same unregistered id, the lexically greatest + version is registered and the others are reported as skipped. +- **A dropped tarball** — any `*.tar.gz` sitting directly in + `~/.kandev/plugins/` — is run through the same verified install pipeline + `POST /api/plugins/install` uses. On success the tarball file is deleted; + on failure it is left in place and the failure is reported. +- **A missing install** — a registered record whose `install_path` no + longer exists on disk — is stopped (if running) and marked `error`. + +At boot, kandev runs only the directory-sideload and missing-install steps +(never the tarball-install step), as part of resuming plugins that were +already active. This is conservative by design: starting up never spawns a +binary an operator hasn't explicitly approved via install or Sync. + +## Enable, disable, uninstall + +- **Disable** stops the subprocess. Config and state are preserved; no + events, tools, or webhooks are delivered while disabled. +- **Enable** respawns the subprocess and re-completes the handshake. +- **Uninstall** stops the subprocess and deletes the plugin's package, + registration record, and all persisted state — there is no grace period. + +## Signed vs. unsigned packages + +Every package's `checksums.txt` is verified at install time — this integrity +gate is always enforced. Signing is a separate, optional layer: if a package +includes a `checksums.txt.sig` (an ed25519 signature over `checksums.txt`), +kandev verifies it and marks the plugin signed. An unsigned package still +installs, with a surfaced warning — signing is not required in v1. + +## On-disk layout + +``` +~/.kandev/plugins/ +├── .yml # registration record (status, install_path, signed, ...) +├── .config.yml # operator-editable config (PATCH /api/plugins/{id}) +└── / + └── / # extracted package (InstallPath) + ├── manifest.yaml + ├── server/plugin--[.exe] + ├── ui/bundle.js # optional + └── data/ # KANDEV_PLUGIN_DATA_DIR for this plugin +``` + +## Security posture + +- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess + itself over a unix domain socket (macOS/Linux) or loopback TCP with + AutoMTLS (Windows) — never a routable network address. There is no + `api_key`, `webhook_secret`, or HMAC signing anywhere in the contract; the + go-plugin handshake plus AutoMTLS authenticate the channel. +- **Capability-based access control.** A plugin can only call the Host RPCs + it declared in its manifest (`state`, `secrets`); an undeclared capability + returns gRPC `PermissionDenied` with a message naming the missing + capability, checked by a server interceptor before the handler runs. +- **Native UI plugins run in-origin with full app-store access.** This is an + accepted tradeoff, not an oversight: a plugin bundle shares the kandev + React instance and Zustand store so it can build UI indistinguishable from + first-party pages. In v1 only **active, operator-installed** plugins load; + a failing bundle or `initialize` is caught and never breaks boot; slot + components render behind error boundaries. Hard sandboxing (a worker or + realm boundary) is explicit future work — see below. +- **Package integrity is always checked; signing is optional.** See + "Signed vs. unsigned packages" above. +- **No plugin marketplace.** Install-by-URL/upload is a manual, single-plugin + action; there is no central catalog, one-click install, or automatic + discovery. + +Related: [Authoring a plugin](plugins-authoring.md), [Plugin manifest +reference](plugins-manifest.md), and [Extending Kandev](extending-kandev.md).