279e15b318
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment) The api_write manifest capability was a stored no-op. Wire the deferred phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task, Slack slash-command task creation, refactor fan-out) can create and update tasks and post comments. - SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer; CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk). - Gating: api_write:tasks gates Create/UpdateTask, api_write:comments gates CreateComment; undeclared -> gRPC PermissionDenied, per-method and independent of the read caps on the same resource. - Writes route through the first-party service layer (task service for tasks, office service for comments) via backendapp adapters, never a repository -- so task.*/comment-created events fire and WS updates. Server stamps source="plugin:<id>"; CreateTask resolves workspace/ workflow defaults and best-effort honors start_agent; UpdateTask uses a conservative title/description/state/workflow_step_id mask. - Fixture plugins exercise a CreateTask+CreateComment round-trip; in-process, real-broker wire, and subprocess tests cover the round-trip and per-capability denial. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved writes deferred -> implemented; ADR 0043 amended with the concrete service-layer routing decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment) The office TaskComment write was the wrong primitive — it's an office/ dashboard construct, not a way to message the agent working a task, and it only did anything under office reactivity. Replace the CreateComment write RPC with SendMessage, which delivers a prompt to a task session through the orchestrator's real delivery path (the same one message_task uses). - Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest) returns SendMessageResponse{session_id, status}; regenerated stubs. - SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID, text) added (pairs with Messages().List), returning MessageDispatch. - Gating: api_write:comments dropped; SendMessage gated api_write:messages (independent of api_read:messages on the same accessor, like Tasks()). - Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the target session (explicit id verified against the task, or the primary session), records a user message stamped source="plugin:<id>", then dispatches by state — running → queue; idle/completed → prompt (resume if the agent process is gone); never-started → launch; failed dispatch deletes the recorded message. Wired via SetWriteDeps (live-read). - Tests: adapter state-machine unit tests (queue/start/prompt/resume/ delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process gating, wire, and subprocess round-trips updated to SendMessage. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the ADR 0043 amendment updated from comment-create to message-send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address PR review — nil guards, detached contexts, state validation Follow-up fixes from the #1970 review (Claude + CodeRabbit bots): - grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned value before toProto() so a custom/test Host returning (nil, nil) yields a gRPC Internal error, not a server panic (matches GetTask's pattern). - host.go: fix stale server doc comment (Comments()/CreateComment → Messages()/SendMessage). - startTaskBestEffort and the post-resume wait+retry now run on context.WithoutCancel(ctx): the task/resume is already committed, so a plugin's incoming deadline must not silently abort the launch/resume. - pluginsTaskWriterAdapter.UpdateTask validates the state string against the known task-state enum (excluding orchestrator-owned SCHEDULING) and returns InvalidArgument, so a plugin typo can't forward a bogus state to the service. - Tests: add the resume-wait-timeout path (message deleted so a retry can't duplicate), and a dedicated pluginsTaskWriterAdapter test covering source→metadata mapping and state validation (both adapters now depend on narrow interfaces for testability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): launch start_agent asynchronously so CreateTask doesn't block Review nit follow-up: startTaskBestEffort called StartTask synchronously, so with the detached context the plugin's CreateTask RPC blocked on the orchestrator's full inline launch path (office lookup, state update, workflow step move, task fetch) with no bound — and the "asynchronous" comment was wrong. Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak), mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and StartTask returns immediately. The plugin host stays a simple synchronous call (its taskStarter contract now documents "returns promptly, launches async"). Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch completes, detaches from a cancelled caller context, and swallows launch errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup Addresses the final PR #1970 review nits: - adapters_plugin_starter_test.go: register the block-channel release via t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md. - GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules (title required, id required, NotFound on missing task, invalid-state reject). - spec.md: document that a failed SendMessage dispatch deletes the recorded message, plus the required-field/NotFound/terminal-session rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
778 lines
44 KiB
Markdown
778 lines
44 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-04-26
|
|
owner: cfl
|
|
---
|
|
|
|
# Plugin System
|
|
|
|
The plugin system is a **platform-level capability of kandev**, not tied to any single
|
|
feature area. It lets third parties (and kandev itself) extend the product — backend
|
|
behavior and native frontend UI — through a stable contract, without forking or
|
|
modifying core.
|
|
|
|
The gRPC/go-plugin transport, package format, and RPC surface described below are
|
|
frozen in `docs/plans/plugins/GRPC-CONTRACT.md`; that file is the authoritative
|
|
wire-level reference. The native frontend contract is frozen in
|
|
`docs/plans/plugins/PLUGIN-API.md`. This spec describes the resulting product
|
|
behavior.
|
|
|
|
## Why
|
|
|
|
Kandev keeps growing external integrations and surface-specific behavior directly in
|
|
the core codebase: source-control sync, issue-tracker browsing, notification providers,
|
|
and planned channel types (Slack, Discord, Telegram, email). Each one adds
|
|
platform-specific logic — API clients, webhook handlers, payload formatting, OAuth
|
|
flows, secret management, and bespoke UI — to the Go backend and the SPA. This creates
|
|
three problems:
|
|
|
|
1. **Core bloat.** Every new integration increases the surface area of core. Adding more
|
|
at similar scale makes the codebase unmaintainable.
|
|
2. **Release coupling.** Fixing a bug in one integration requires a full kandev release,
|
|
and users who don't use it still receive the code. Integration authors cannot ship
|
|
independently of the core release cycle.
|
|
3. **Extensibility ceiling.** Users and third parties cannot add their own integrations
|
|
or UI without forking kandev.
|
|
|
|
A plugin system decouples extensions from core. Plugin **backends** are Go binaries
|
|
that kandev spawns and supervises as subprocesses, communicating over a strict typed
|
|
gRPC protocol; plugins may additionally ship a **native frontend bundle** that kandev
|
|
loads into the SPA. Both extend kandev through well-defined, capability-gated
|
|
surfaces. The core stays small; the ecosystem grows independently.
|
|
|
|
## What
|
|
|
|
- Plugin **backends** are **Go binaries** distributed inside a release tarball
|
|
(per-platform executables) that kandev **spawns and supervises as subprocesses**
|
|
via `hashicorp/go-plugin`, speaking a strict typed **gRPC protocol**
|
|
(`kandev.plugin.v1`) over a unix domain socket (macOS/Linux) or loopback TCP with
|
|
AutoMTLS (Windows). No in-process backend loading, no separately-managed operator
|
|
process, no HTTP transport for the backend contract.
|
|
- A plugin MAY additionally ship a **native frontend bundle** (`ui.bundle`) that kandev
|
|
loads into the SPA to register native routes/nav/components (see "Frontend plugin
|
|
runtime"). This is the one in-process surface; the backend stays out-of-process
|
|
(but is now kandev-managed, not operator-managed).
|
|
- A plugin manifest declares identity, runtime executables (per OS/arch), capabilities,
|
|
declared webhooks, config schema, and optional UI bundle.
|
|
- Plugins SHALL receive events, expose proxied external webhook
|
|
endpoints, and read/write a plugin-scoped KV state — all over gRPC.
|
|
- Plugins are distributed as a signed-or-unsigned release **tarball** and installed
|
|
either by **URL** (kandev downloads it) or by **manual upload** (multipart file).
|
|
There is no manifest-paste registration step.
|
|
- Capability-based access control: a plugin can only call Host RPCs it declared in its
|
|
manifest; undeclared capabilities are rejected with a gRPC `PermissionDenied` status.
|
|
- **Kandev owns the plugin process lifecycle**: it extracts the package, spawns the
|
|
binary, performs the go-plugin handshake, health-checks it (`Ping`), and restarts it
|
|
on crash or health-check failure. Operators no longer run or manage plugin processes
|
|
themselves. The remote/self-hosted tier (`base_url` registration of an
|
|
operator-run process kandev never spawns) is removed; see "Out of scope".
|
|
|
|
## Data model
|
|
|
|
### Plugin package format
|
|
|
|
A plugin ships as a release tarball, `<id>-<version>.tar.gz`:
|
|
|
|
```
|
|
manifest.yaml # authoritative; read BEFORE any code runs
|
|
server/plugin-<goos>-<goarch>[.exe] # any subset of platforms; host key required at install
|
|
ui/bundle.js # optional (frontend half)
|
|
ui/*.css / assets/icon.svg # optional
|
|
checksums.txt # "sha256 path" for every other file
|
|
checksums.txt.sig # OPTIONAL ed25519 signature (unsigned → warn, not blocked)
|
|
```
|
|
|
|
`manifest.yaml` declares identity, capabilities, webhooks, config schema, and
|
|
an optional UI bundle, plus a `runtime` block naming the per-platform executables
|
|
(replaces the old `base_url`/`endpoints` block, which is removed entirely):
|
|
|
|
```yaml
|
|
id: "kandev-plugin-slack" # Unique, pattern: ^[a-z0-9][a-z0-9._-]*$
|
|
api_version: 1
|
|
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
|
|
executables:
|
|
linux-amd64: server/plugin-linux-amd64
|
|
darwin-arm64: server/plugin-darwin-arm64
|
|
# ... any subset; kandev requires the running host's platform key at install time
|
|
min_kandev_version: "0.78.0" # optional
|
|
|
|
capabilities:
|
|
events: ["task.created", "task.state_changed", "agent.completed"]
|
|
api_read: ["tasks", "sessions"] # Host data API reads (see below); live now
|
|
api_write: ["tasks", "messages"] # Host data API writes (CreateTask/UpdateTask/SendMessage); live now
|
|
state: true
|
|
secrets: true
|
|
auth: true # establish a login session for an
|
|
# external (OIDC/SAML) identity — see ADR 0050
|
|
|
|
webhooks:
|
|
- key: "slack-events"
|
|
description: "Slack Events API webhook"
|
|
method: "POST"
|
|
|
|
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: # Native frontend plugin (see "Frontend plugin runtime")
|
|
bundle: "ui/bundle.js" # ES module extracted alongside the package
|
|
styles: ["ui/plugin.css"] # optional stylesheets
|
|
|
|
# Runtime fields managed by kandev (not authored):
|
|
status: "active"
|
|
version: "1.0.0"
|
|
install_path: "~/.kandev/plugins/kandev-plugin-slack/1.0.0"
|
|
installed_at: "2026-04-26T10:00:00Z"
|
|
restart_count: 0
|
|
```
|
|
|
|
`capabilities.api_read` / `capabilities.api_write` gate the **Host data API** Host
|
|
RPCs (both reads and writes live now) — the vocabulary is a list of
|
|
resource names: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`,
|
|
`agent_profiles`, `repositories` for `api_read`, plus `tasks` (CreateTask/
|
|
UpdateTask) and `messages` (SendMessage) for `api_write`. See "Host data API".
|
|
|
|
**Declaring data access.** Listing a resource under `api_read` grants the
|
|
corresponding Host data reads for that resource only — e.g. `api_read:
|
|
["sessions"]` unlocks `Host.Sessions().List(...)` and
|
|
`Host.Sessions().CodeStats(...)` (backed by `ListSessions` /
|
|
`ListSessionCodeStats`) but not `Host.Tasks()`. A resource left off the list
|
|
still resolves to a reader/accessor (no nil pointer), but every method on it
|
|
returns gRPC `PermissionDenied` with message `capability 'api_read:<resource>'
|
|
not declared`. Writes work the same way under `api_write` (message
|
|
`capability 'api_write:<resource>' not declared`), and reads and writes gate
|
|
independently on the same resource — a plugin may declare `api_read:tasks`
|
|
without `api_write:tasks`, or vice versa (see "Host data API writes").
|
|
|
|
**External login (`auth`).** An `auth`-capable plugin can log a visitor in
|
|
against an external IdP (OIDC/SAML): its webhook (the callback / SAML ACS)
|
|
validates the token, then asserts the identity to kandev via the reserved
|
|
`X-Kandev-Auth-Login` response header (`{provider, subject, email,
|
|
display_name}`). The host maps it to a user (link-by-email or just-in-time
|
|
member provisioning), mints the session, and sets the `kandev_session` cookie
|
|
itself — the plugin never receives the raw token. Requires auth enabled; new
|
|
users are members, and the host never creates an admin nor auto-links to an
|
|
existing admin account. The plugin **must** only assert IdP-verified emails (an
|
|
unverified email claim is an account-takeover vector the host cannot detect).
|
|
This is the highest-privilege capability; grant it only to trusted plugins.
|
|
See [ADR 0050](../../decisions/0050-plugin-external-auth-capability.md).
|
|
|
|
### Install pipeline
|
|
|
|
There is no manifest-paste registration step. A plugin is installed from a URL or an
|
|
uploaded tarball:
|
|
|
|
1. Operator calls `POST /api/plugins/install` with JSON `{"url": "..."}` (kandev
|
|
downloads the tarball) or a multipart `package` field (direct upload).
|
|
2. Kandev verifies `checksums.txt` covers every other file in the tarball and every
|
|
hash matches (integrity gate, always enforced).
|
|
3. If `checksums.txt.sig` is present, kandev verifies the ed25519 signature; if
|
|
absent, install proceeds with a surfaced "unsigned plugin" warning (signing is not
|
|
required in v1 — see "Out of scope").
|
|
4. Kandev parses and validates `manifest.yaml` **before any code runs**: schema, `id`
|
|
pattern, capability vocabulary, and that `runtime.executables` contains an entry
|
|
for the host's OS/arch.
|
|
5. Kandev extracts the package to `~/.kandev/plugins/<id>/<version>/` and records the
|
|
installation (`id`, `version`, `install_path`, capabilities, status).
|
|
6. Kandev spawns the platform-matched binary via `hashicorp/go-plugin`, completes the
|
|
handshake (§2 of GRPC-CONTRACT.md) — status `registered` while this is pending.
|
|
7. Handshake succeeds → status `active`. Handshake/spawn failure → status `error`
|
|
(restart retried with backoff; see "State machine").
|
|
|
|
Uninstall stops the subprocess and removes the record, all installed versions, and
|
|
plugin state (no 24-hour grace period in v1). `POST /api/plugins/register` is removed;
|
|
there is no operator-supplied manifest, no generated credentials, and no cleartext
|
|
secret returned at install time.
|
|
|
|
### `plugin_state` (SQLite)
|
|
|
|
```sql
|
|
CREATE TABLE plugin_state (
|
|
id TEXT PRIMARY KEY,
|
|
plugin_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL DEFAULT 'instance', -- instance | workspace | task | agent
|
|
scope_id TEXT, -- NULL for instance scope
|
|
state_key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (plugin_id, scope, scope_id, state_key)
|
|
);
|
|
```
|
|
|
|
## API surface
|
|
|
|
### Plugin management API (operator -> kandev, HTTP)
|
|
|
|
```
|
|
POST /api/plugins/install # Install a plugin: JSON {"url": "..."} or multipart `package`
|
|
POST /api/plugins/sync # Reconcile the registry with the plugins directory on disk
|
|
GET /api/plugins # List installed plugins
|
|
GET /api/plugins/{id} # Get plugin detail
|
|
GET /api/plugins/{id}/config # Stored operator config; secret values masked
|
|
# (secret fields live in the encrypted vault; the
|
|
# config file persists only a vault reference)
|
|
PATCH /api/plugins/{id} # Update plugin config (masked secrets keep stored values; restarts a running plugin)
|
|
DELETE /api/plugins/{id} # Uninstall plugin (stops subprocess, removes package + state)
|
|
POST /api/plugins/{id}/enable
|
|
POST /api/plugins/{id}/disable
|
|
GET /api/plugins/{id}/bundle # Frontend bundle, served from the extracted package dir
|
|
GET /api/plugins/{id}/ui/* # Frontend bundle assets, served from the extracted package dir
|
|
```
|
|
|
|
`POST /api/plugins/sync` is documented in full under "Filesystem sideloading & sync"
|
|
below.
|
|
|
|
`GET /api/plugins/{id}/bundle` and `/ui/*` are served directly by kandev **from the
|
|
extracted package directory** (`~/.kandev/plugins/<id>/<version>/ui/...`) — there is
|
|
no reverse proxy and no upstream plugin process involved in serving frontend assets,
|
|
since the files are already on local disk after install.
|
|
|
|
Enable/disable/uninstall act on the supervised subprocess: disable stops it (state and
|
|
config preserved); enable respawns it; uninstall stops it and deletes its package,
|
|
record, and state.
|
|
|
|
### Event delivery (kandev -> plugin, gRPC `DeliverEvent`)
|
|
|
|
Kandev calls the plugin's `Plugin.DeliverEvent` RPC (unary) with an `Event` message:
|
|
|
|
```proto
|
|
message Event {
|
|
string event_id = 1; // fresh uuid per delivery
|
|
string event_type = 2; // bus subject, e.g. "task.created"
|
|
string occurred_at = 3; // RFC3339 UTC
|
|
string workspace_id = 4; // empty if not derivable
|
|
google.protobuf.Struct payload = 5; // marshaled bus event.Data
|
|
}
|
|
```
|
|
|
|
Expected response: `EventAck{}`. A non-nil gRPC error or a timeout counts as failure.
|
|
|
|
Delivery semantics (unchanged from earlier design, now carried over gRPC):
|
|
- **At-least-once.** Plugins must be idempotent (dedup by `event_id`).
|
|
- **Timeout:** 10 seconds. Up to 3 retries with exponential backoff (5s, 15s, 45s).
|
|
- **Sequential per plugin** — no concurrent delivery to the same plugin. Plugins
|
|
needing parallel processing queue internally.
|
|
- **Buffering while unhealthy:** events are held in a ring buffer (100 events, 5-minute
|
|
TTL) and flushed in order once the plugin recovers (health/Ping succeeds again).
|
|
|
|
Event types: any subject kandev publishes on its internal event bus
|
|
(`internal/events/types.go`). Plugins subscribe to whatever they need; the catalog
|
|
below is a non-exhaustive sample across feature areas — the plugin system is not tied to
|
|
any one of them.
|
|
|
|
| Category | Events |
|
|
|----------|--------|
|
|
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved` |
|
|
| Sessions | `task_session.state_changed`, `turn.started`, `turn.completed` |
|
|
| Agents | `agent.started`, `agent.completed`, `agent.failed`, `agent.stopped` |
|
|
| Other feature areas (examples) | Any additional subjects emitted by feature areas such as office/agents (e.g. `office.comment.created`, `office.approval.created`) — plugins may subscribe to these, but the plugin system does not depend on them |
|
|
| GitHub | `github.pr_state_changed`, `github.pr_feedback`, `github.new_issue` |
|
|
| Plugin | `plugin.<plugin_id>.<name>` (cross-plugin events) |
|
|
|
|
Wildcard subscriptions: `task.*`, `agent.*`, `<feature>.*` (any subject prefix).
|
|
|
|
### External webhook proxy (external -> kandev -> plugin)
|
|
|
|
```
|
|
POST /api/plugins/{plugin_id}/webhooks/{webhook_key}
|
|
```
|
|
|
|
This remains kandev's one plugin-facing **HTTP** endpoint (external systems like Slack
|
|
or Jira cannot speak gRPC). Kandev validates the plugin is active and the webhook key
|
|
is declared, converts the HTTP request into a `WebhookRequest` gRPC message, and calls
|
|
the plugin's `Plugin.HandleWebhook` RPC:
|
|
|
|
```proto
|
|
message WebhookRequest {
|
|
string webhook_key = 1;
|
|
string method = 2;
|
|
string path = 3; // remainder after the key
|
|
string query = 4;
|
|
map<string, string> headers = 5; // single-valued; multi joined by ", "
|
|
bytes body = 6;
|
|
}
|
|
message WebhookResponse { int32 status = 1; map<string, string> headers = 2; bytes body = 3; }
|
|
```
|
|
|
|
The `WebhookResponse` is relayed back as the HTTP response. The plugin verifies the
|
|
external system's signature (Slack signing secret, GitHub webhook secret, etc.) itself.
|
|
|
|
### Host gRPC service (plugin -> kandev)
|
|
|
|
There is no plugin-facing HTTP API for state, secrets, or cross-plugin events. Instead,
|
|
kandev implements a `Host` gRPC service and serves it back to the plugin over the
|
|
go-plugin broker (the plugin is the gRPC client for this service):
|
|
|
|
```proto
|
|
service Host {
|
|
rpc GetState(GetStateRequest) returns (GetStateResponse);
|
|
rpc SetState(SetStateRequest) returns (SetStateResponse);
|
|
rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse);
|
|
rpc ListState(ListStateRequest) returns (ListStateResponse);
|
|
rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse);
|
|
rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse);
|
|
rpc InvokeUtilityAgent(InvokeUtilityAgentRequest) returns (InvokeUtilityAgentResponse);
|
|
}
|
|
```
|
|
|
|
- `GetState`/`SetState`/`DeleteState`/`ListState` operate on `plugin_state`, scoped by
|
|
`scope` (`instance`, `workspace`, `task`, `agent`) and `scope_id`. Plugins cannot
|
|
read other plugins' state — the Host service instance handed to a plugin's
|
|
subprocess is bound to that plugin's own ID at spawn time, so there is no
|
|
plugin-supplied ID to spoof.
|
|
- `RevealSecret(ref)` resolves a secret reference through kandev's `internal/secrets/`
|
|
package. Requires `capabilities.secrets: true`.
|
|
- `EmitEvent(event_name, payload)` publishes `plugin.<plugin_id>.<event_name>` on the
|
|
internal event bus for delivery to subscribers (replaces the old
|
|
`POST /api/plugins/{plugin_id}/events/emit` HTTP endpoint).
|
|
- `InvokeUtilityAgent(prompt)` runs a one-shot, non-interactive completion using
|
|
the utility agent selected in the plugin's `utility_agent` config field and
|
|
returns its text. Plugins declaring `capabilities.agent_invoke: true` must
|
|
declare that field in `config_schema` with `type: string` and
|
|
`format: utility-agent`; Settings > Plugins then renders a picker containing
|
|
the configured built-in and custom utility agents. The picker displays the
|
|
agent name and persists its stable ID. It reuses kandev's
|
|
sessionless host-utility inference tier (ADR 0002) — no task, session, or
|
|
workspace — so a plugin can delegate a lightweight LLM step without holding a
|
|
provider API key. Returns gRPC `FailedPrecondition` when no utility agent is
|
|
configured, selected agent was deleted, or it is disabled. See
|
|
[ADR 0048](../../decisions/0048-plugin-host-utility-agent-invoke.md).
|
|
|
|
Every Host RPC is capability-gated: `GetState`/`SetState`/`DeleteState`/`ListState`
|
|
check `capabilities.state`, `RevealSecret` checks `capabilities.secrets`,
|
|
`InvokeUtilityAgent` checks `capabilities.agent_invoke`, and each Host data API
|
|
read RPC checks `capabilities.api_read` for its resource, and each Host data API
|
|
write RPC checks `capabilities.api_write` for its resource (see "Host data API"
|
|
below) — all before the handler runs, returning gRPC status `PermissionDenied`
|
|
with message `capability '<name>' not declared` on a miss. `EmitEvent` is
|
|
ungated (no boolean capability applies).
|
|
|
|
### Host data API (plugin -> kandev, gRPC)
|
|
|
|
Plugins read and write kandev's own domain data — tasks,
|
|
sessions, workspaces, workflows, agent profiles, repositories, messages — over the
|
|
same capability-gated Host gRPC channel they use for state and secrets, instead of
|
|
opening the kandev database file. The wire contract is the `kandev.plugin.v1`
|
|
Host data RPCs; DTOs are hand-mapped, versioned proto messages, never internal
|
|
domain structs. See [ADR 0043](../../decisions/0043-plugin-host-data-api.md) and
|
|
`docs/plans/plugins/HOST-DATA-API.proto`.
|
|
|
|
**Readable resources (v1).** Each is gated by an `api_read:<resource>` capability:
|
|
|
|
| RPC | Capability | Returns |
|
|
|---|---|---|
|
|
| `ListTasks` / `GetTask` | `api_read:tasks` | Tasks (id, workspace, workflow, title, description, state, priority, timestamps, parent, identifier, repositories, metadata) |
|
|
| `ListWorkspaces` | `api_read:workspaces` | Workspaces (id, name, owner, defaults, timestamps) |
|
|
| `ListWorkflows` | `api_read:workflows` | Workflows for a workspace |
|
|
| `ListWorkflowSteps` | `api_read:workflows` | Steps for a workflow (id, name, position, stage type) |
|
|
| `ListAgentProfiles` | `api_read:agent_profiles` | Agent profiles (id, agent id, display name, model, mode) |
|
|
| `ListRepositories` | `api_read:repositories` | Repositories for a workspace (id, name, default branch) |
|
|
| `ListSessions` | `api_read:sessions` | Session identity + agent context (id, task, agent profile, resolved display name + model, `acp_session_id`, state, timestamps) |
|
|
| `ListSessionCodeStats` | `api_read:sessions` | **Computed** per-session code metrics: committed lines added/deleted, peak pending-diff lines added/deleted |
|
|
| `ListMessages` | `api_read:messages` | Historical conversation content (id, session, task, turn, `author_type` (user/agent), `content`, `type`, `created_at`), filterable by session ids, task ids, a `created_at` range (`since`/`until`), and types. See "Conversation content" below. |
|
|
|
|
`acp_session_id` on a session is the external usage-attribution join key (e.g.
|
|
`tokscale`): kandev exposes the session identity and code stats but stays out of
|
|
the token business. `SessionCodeStats` is a deliberately computed shape — the
|
|
aggregate the agent-stats plugin previously re-derived by hand from
|
|
`task_session_commits` and `task_session_git_snapshots` — so plugins never touch
|
|
those raw rows.
|
|
|
|
**Conversation content (`api_read:messages`, ADR 0047).** `ListMessages` reads
|
|
historical user/agent message content — the data a "summarize yesterday"
|
|
plugin needs, which the `message.added` bus event alone (live-only,
|
|
post-install-only) cannot provide. `MessageFilter` narrows by `session_ids`,
|
|
`task_ids`, a `created_at` window (`since` inclusive / `until` exclusive,
|
|
RFC3339), and message `types`; results are ordered oldest-first with opaque
|
|
cursor pagination. `content` is sanitized the same way the event path is —
|
|
kandev-injected `<kandev-system>` blocks are stripped via
|
|
`sysprompt.StripSystemContent`, and raw system content is never exposed.
|
|
`author_type` is only `user` or `agent`: kandev has no `system` author, since
|
|
system context is inline markup removed at read time. Reads route through the
|
|
task service's `ListMessagesForPlugin` (a single filtered
|
|
session/task/time/type query), never a repository or the DB file directly.
|
|
|
|
**Host data API writes.** `CreateTask`, `UpdateTask`, and `SendMessage` are
|
|
implemented, each gated by `api_write:<resource>` and routed through the
|
|
first-party service layer (never a repository) so the corresponding events fire
|
|
and WS clients update. The server stamps `source = "plugin:<id>"` — a plugin
|
|
cannot set provenance itself.
|
|
|
|
`Host.Tasks().Create`/`Update` (capability `api_write:tasks`) go through
|
|
`internal/task/service` so `task.*` events fire. `CreateTask` fills sane
|
|
placement defaults when the plugin omits them (single workspace; that
|
|
workspace's first workflow — ambiguous → `InvalidArgument`), accepts an optional
|
|
`start_agent` that best-effort auto-launches an agent, and requires a title.
|
|
`UpdateTask` accepts a conservative field mask — `title` / `description` /
|
|
`state` / `workflow_step_id`, each optional (a nil field is left unchanged); a
|
|
missing task returns gRPC `NotFound`.
|
|
|
|
`Host.Messages().Send` (capability `api_write:messages`) delivers a prompt to a
|
|
task session through the orchestrator's real delivery path — the same one
|
|
`message_task` uses — so the message reaches the agent and drives a turn (it is
|
|
*not* an office comment). It resolves the target session (an explicit
|
|
`session_id`, verified to belong to the task, or the task's primary session),
|
|
records a user message stamped with the plugin source, and dispatches by session
|
|
state: a running session queues the prompt (`status: queued`); an idle/completed
|
|
one is prompted, resuming the agent process if it has gone (`sent`); a
|
|
never-started one is launched with the prompt as its first turn (`started`). If
|
|
dispatch fails after the user message was recorded, the recorded message is
|
|
deleted so a failed delivery leaves no durable user message (and a retry can't
|
|
stack a duplicate prompt). `task_id` and `text` are required (`InvalidArgument`
|
|
otherwise); a session that doesn't belong to the task, or a task with no active
|
|
session, returns `NotFound`; a terminal (failed/cancelled) session is rejected.
|
|
|
|
**Conventions.**
|
|
|
|
- **Pagination** is opaque-cursor based: a request carries `Page{limit, cursor}`
|
|
and a list response carries `PageInfo{next_cursor, has_more}`. `limit: 0` means
|
|
the server default; the server caps the maximum. An empty cursor is the first
|
|
page; echoing `next_cursor` continues. Plugins MUST NOT interpret cursor
|
|
contents.
|
|
- **Timestamps** are RFC3339 strings.
|
|
- **Nullable** string fields use proto `optional`, so absent (NULL) is
|
|
distinguishable from empty.
|
|
- **Scoping (v1):** reads are global to the kandev instance (plugins are
|
|
instance-global; see "Permissions"). Filters (`workspace_ids`, `task_ids`,
|
|
`states`) narrow results but do not confer or restrict visibility. A
|
|
server-side scoping hook is reserved for a future per-plugin/per-user
|
|
restriction without a contract change.
|
|
- **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the
|
|
request sets `include_ephemeral`.
|
|
|
|
Every Host data RPC is capability-gated the same way as state/secrets: an
|
|
undeclared capability returns gRPC `PermissionDenied` with message
|
|
`capability '<name>' not declared` before the handler runs. Because the Host
|
|
service instance is bound to the plugin's own ID at spawn time, the check
|
|
evaluates directly against that plugin's installed manifest.
|
|
|
|
## Filesystem sideloading & sync
|
|
|
|
Besides the URL/upload install pipeline, an operator with shell access to the host
|
|
can place plugin content directly under the plugins directory
|
|
(`~/.kandev/plugins/`) without going through `POST /api/plugins/install`. `POST
|
|
/api/plugins/sync` (and the Settings > Plugins **Sync** button, which calls it and
|
|
refreshes the list) reconciles the registry with whatever is actually on disk:
|
|
|
|
1. **Directory sideloads.** For every `<pluginsDir>/<id>/<version>/manifest.yaml`
|
|
found with no existing `{id}.yml` record, kandev parses and validates the
|
|
manifest, requires it to be runtime-managed (`runtime.type: binary`) and its `id`
|
|
field to match the `<id>` directory name, and registers it — always with status
|
|
**`disabled`**, never `active`. Sideloads are unverified (no checksum, no
|
|
integrity gate the URL/upload pipeline runs) and are never auto-spawned; an
|
|
operator must explicitly enable one after inspecting it. 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, not registered.
|
|
2. **Dropped tarballs.** Every `*.tar.gz` file sitting directly in the plugins
|
|
directory is run through the same verified install pipeline `POST
|
|
/api/plugins/install` uses (checksum verification, manifest validation, platform
|
|
executable check, extraction, spawn, activate). On success the tarball file is
|
|
deleted. On a validation failure the file is left in place (not retried
|
|
automatically) and the failure is reported.
|
|
3. **Missing installs.** Every registered record whose `install_path` no longer
|
|
exists on disk (deleted out from under kandev) is stopped, if its process is
|
|
running, and transitioned to status `error`.
|
|
|
|
`POST /api/plugins/sync` returns a `SyncResult`: which plugin ids were newly
|
|
sideloaded (`added`), which were installed from a dropped tarball (`installed`),
|
|
which were marked missing (`missing`), and a list of per-item `errors` (path +
|
|
reason) for anything rejected or skipped along the way. A single item's failure
|
|
never aborts the rest of the scan. Concurrent sync calls are serialized so an
|
|
operator double-clicking Sync (or a sync racing the boot scan) cannot double-install
|
|
the same dropped tarball.
|
|
|
|
At **boot**, kandev runs only the directory-sideload and missing-install steps
|
|
(never the tarball-install step) as part of resuming previously-active plugins —
|
|
conservative by design: starting up should never itself spawn a binary an operator
|
|
has not explicitly approved via install or Sync. What the boot scan found is logged;
|
|
an operator triggers the full sync (including tarball installs) explicitly via the
|
|
Sync button or the API.
|
|
|
|
## Frontend plugin runtime (native JS UI plugins)
|
|
|
|
Plugins may extend the SPA with **native** React UI — routes, nav items, slot
|
|
components, and WebSocket handlers that run inside the kandev frontend (the
|
|
Mattermost-webapp model), not iframes. The full contract lives in
|
|
`docs/plans/plugins/PLUGIN-API.md`; summary:
|
|
|
|
- **Manifest:** a plugin declares `ui.bundle` (a path inside the extracted package,
|
|
e.g. `ui/bundle.js`) and optional `ui.styles`.
|
|
- **Bundle delivery:** kandev serves the bundle at `GET /api/plugins/{id}/bundle`
|
|
(and any assets under `GET /api/plugins/{id}/ui/*`) directly from the extracted
|
|
package directory on local disk, forcing `Content-Type: text/javascript` and
|
|
stripping frame-blocking headers. No reverse proxy and no live upstream process are
|
|
involved in serving these assets — the plugin subprocess only needs to be running
|
|
to serve gRPC calls, not the UI bundle.
|
|
- **Boot payload:** the SPA boot payload carries
|
|
`plugins: [{ id, name, bundleUrl, styleUrls }]` for every **active** plugin that
|
|
declares a bundle (gated on the `plugins` feature flag).
|
|
- **Loading:** on boot (and on runtime enable), the frontend host dynamically
|
|
`import()`s each `bundleUrl`. The bundle calls
|
|
`window.registerKandevPlugin(id, { initialize(registry, host), destroy? })`.
|
|
`host` shares the kandev React instance, the app store, a plugin-scoped
|
|
`api.fetch`, a curated `@kandev/ui` subset, and the theme — so a plugin can build
|
|
a page indistinguishable from first-party UI (e.g. a native `/jira` page).
|
|
- **Registry surface:** `registerRoute(path, C)`, `registerNavItem(item)`,
|
|
`registerSettingsRoute(path, C)`, `registerComponent(slot, C)` (including
|
|
`app-status-bar-left` and `app-status-bar-right`), `registerWsHandler(action, fn)`.
|
|
Status-slot components receive the exact `AppStatusBarSlotProps` contract in
|
|
`PLUGIN-API.md`: current path/context plus placement and presentation. The host
|
|
renders one responsive presentation at once — 24 px bar on tablet/desktop or
|
|
phone Status drawer — so a plugin must tolerate remounting and adapt its own UI.
|
|
A status slot chooses the contribution's default side; portable user order may
|
|
move either contribution across the desktop spacer and determines drawer order.
|
|
Registrations are namespaced per plugin and bulk-revoked on disable/uninstall.
|
|
- **Isolation (v1):** only active, operator-installed plugins load; a failing
|
|
bundle/`initialize` is caught and never breaks boot; slot components render behind
|
|
error boundaries. Plugin JS otherwise runs with full in-origin store access —
|
|
hard sandboxing (workers/realms) is future work (see Out of scope).
|
|
- **Keybindings:** a plugin declares `ui.keybindings: [{ id, default, description }]`
|
|
in its manifest (`default` is a combo string like `mod+shift+k`, validated at
|
|
registration time). `registry.registerKeybinding(id, handler)` binds a JS handler
|
|
to a declared id; the host resolves the effective combo (a user override from
|
|
Settings > Keyboard Shortcuts if set, else the manifest `default`) and dispatches
|
|
it globally, skipping editable targets the same way core app shortcuts do. User
|
|
overrides are namespaced `plugin:{pluginId}:{id}` so they survive independently
|
|
per plugin.
|
|
- **Modals:** `host.openModal({ title?, content, size?, dismissible? })` imperatively
|
|
opens a modal rendered by the host's `<PluginModalHost/>` (mounted once at the app
|
|
root, isolated behind its own error boundary) and returns `{ close() }` to close
|
|
that instance. `content` reuses the slot-component contract (rendered with the
|
|
host React instance). Independent of keybindings — any plugin code path may call
|
|
it, including a keybinding handler.
|
|
|
|
## State machine
|
|
|
|
```
|
|
registered -> active -> disabled -> uninstalled
|
|
| |
|
|
+-> error -+
|
|
```
|
|
|
|
| State | Meaning |
|
|
|---|---|
|
|
| `registered` | Package extracted and record written; go-plugin spawn/handshake pending or in flight |
|
|
| `active` | Handshake succeeded and health (`Ping`) passes; events delivered, webhooks proxied |
|
|
| `error` | 3 consecutive `Ping` failures (30s interval, injectable), or the subprocess crashed and restart attempts (backoff, max 5) are exhausted. Events buffered (ring buffer, 100 events, 5-minute TTL). Webhooks return 503 |
|
|
| `disabled` | Operator explicitly disabled. Subprocess stopped. No events, no webhooks. State and config preserved |
|
|
| `uninstalled` | Subprocess stopped, package/record/state deleted (no grace period in v1) |
|
|
|
|
Health monitoring: kandev's go-plugin client calls `Ping()` on the plugin every 30
|
|
seconds (injectable). 3 consecutive failures -> `error` + inbox item + restart attempt
|
|
with backoff. A subprocess crash (unexpected process exit) triggers an immediate
|
|
restart with backoff (max 5 attempts, then `error`). Next successful handshake/`Ping`
|
|
-> `active`, queued events delivered in order.
|
|
|
|
## Permissions
|
|
|
|
- Plugins are global to the kandev instance, installed by the operator. There is no per-user plugin access in v1.
|
|
- Capability-based access control: undeclared capabilities on a Host RPC return gRPC
|
|
status `PermissionDenied` with message `capability '<name>' not declared`.
|
|
- Each plugin's Host service instance is bound to its own plugin ID at spawn time —
|
|
there is no plugin-supplied ID to check, so capability checks evaluate directly
|
|
against that plugin's installed manifest on every RPC.
|
|
|
|
## Security
|
|
|
|
- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess itself, so
|
|
there is no separate credential to issue, store, or leak: the go-plugin handshake
|
|
(magic cookie) plus AutoMTLS (mutual TLS negotiated per-launch, transparent to
|
|
plugin authors) authenticate the channel. There is no `api_key`, no
|
|
`webhook_secret`, and no HMAC signing anywhere in the contract.
|
|
- **Package integrity.** `checksums.txt` is verified for every file at install time.
|
|
An optional ed25519 signature (`checksums.txt.sig`) is verified when present;
|
|
unsigned packages install with a surfaced warning rather than being blocked (signing
|
|
is not required in v1 — see "Out of scope").
|
|
- **Capability-based access control** evaluated per Host RPC via a server interceptor.
|
|
- **Network**: the plugin subprocess talks to kandev over a unix domain socket
|
|
(macOS/Linux) or loopback TCP with AutoMTLS (Windows) — never a routable network
|
|
address. There is no remote/operator-hosted plugin tier in v1; every plugin backend
|
|
is a binary kandev spawns and supervises on the same host. See "Out of scope".
|
|
|
|
## Failure modes
|
|
|
|
- **3 consecutive `Ping` failures (90s), or crash with restart attempts exhausted
|
|
(max 5, backoff)**: status -> `error`. Events buffered (100, 5min TTL). Webhooks
|
|
return 503. Inbox item created.
|
|
- **Buffer overflows (>100 events or >5min)**: oldest events dropped and logged.
|
|
- **Plugin returns a gRPC error (or times out) on `DeliverEvent`**: retry up to 3 times
|
|
with exponential backoff (5s, 15s, 45s). After exhaustion, event is logged as failed
|
|
and dropped.
|
|
- **External webhook hits a disabled/error plugin**: kandev returns 503.
|
|
- **Undeclared capability access attempt on a Host RPC**: gRPC `PermissionDenied` with
|
|
a message naming the missing capability.
|
|
- **Checksum mismatch or unresolvable host-platform executable at install time**:
|
|
install is rejected before any code runs.
|
|
|
|
## Persistence guarantees
|
|
|
|
- Plugin installation records (`id`, `version`, `install_path`, capabilities, status)
|
|
persist to disk under `~/.kandev/plugins/<id>/` and survive backend restarts.
|
|
- Extracted plugin packages persist at `~/.kandev/plugins/<id>/<version>/` until
|
|
uninstall.
|
|
- Plugin state in SQLite survives restarts.
|
|
- Event delivery buffer is in-memory; events in the buffer do not survive a backend
|
|
restart.
|
|
- There are no plugin credentials to persist or lose — auth is re-derived from the
|
|
spawn relationship on every process launch.
|
|
|
|
## Scenarios
|
|
|
|
- **GIVEN** an operator with a release tarball URL for a Slack notification plugin,
|
|
**WHEN** the operator calls `POST /api/plugins/install` with `{"url": "..."}`,
|
|
**THEN** kandev downloads the tarball, verifies `checksums.txt`, validates the
|
|
manifest, extracts it to `~/.kandev/plugins/kandev-plugin-slack/1.0.0/`, spawns the
|
|
platform-matched binary, completes the go-plugin handshake, and the plugin appears
|
|
in `GET /api/plugins` with status `active`.
|
|
|
|
- **GIVEN** an operator with a plugin tarball on their local machine, **WHEN** the
|
|
operator uploads it via `POST /api/plugins/install` (multipart `package`), **THEN**
|
|
kandev runs the same verify → validate → extract → spawn pipeline and the plugin
|
|
reaches `active` without any URL ever being contacted.
|
|
|
|
- **GIVEN** an operator who extracted a plugin package directly into
|
|
`~/.kandev/plugins/<id>/<version>/` on the host filesystem (no install call), **WHEN**
|
|
the operator clicks **Sync** in Settings > Plugins (`POST /api/plugins/sync`),
|
|
**THEN** kandev finds the unrecorded `manifest.yaml`, validates it, and registers the
|
|
plugin with status **`disabled`** — never spawning it automatically. The plugin then
|
|
appears in the list, and the operator can enable it explicitly like any other plugin.
|
|
|
|
- **GIVEN** an active Slack plugin subscribed to `task.state_changed`, **WHEN** a task
|
|
moves to `done`, **THEN** kandev calls the plugin's `DeliverEvent` gRPC method with
|
|
the event over the go-plugin unix socket. The plugin formats a Slack message and
|
|
calls the Slack API, then returns `EventAck{}`.
|
|
|
|
- **GIVEN** a Jira sync plugin with a registered `jira-webhooks` webhook, **WHEN**
|
|
Jira POSTs a webhook to
|
|
`https://kandev.example.com/api/plugins/kandev-plugin-jira/webhooks/jira-webhooks`,
|
|
**THEN** kandev converts the HTTP request into a `WebhookRequest` and calls the
|
|
plugin's `HandleWebhook` gRPC method. The plugin parses the Jira event, calls
|
|
`Host.SetState` to record the linked task, and returns a `WebhookResponse` that
|
|
kandev relays back as the HTTP response.
|
|
|
|
- **GIVEN** an active plugin subprocess that crashes, **WHEN** kandev detects the
|
|
process exit, **THEN** kandev immediately attempts a restart with backoff, marks the
|
|
plugin `error` while buffering events (up to 100 or 5 minutes), and creates an inbox
|
|
item "Plugin kandev-plugin-slack is unreachable". **WHEN** a subsequent restart
|
|
attempt succeeds and the handshake completes, **THEN** status returns to `active`
|
|
and buffered events are delivered in order.
|
|
|
|
- **GIVEN** a plugin whose manifest declares `secrets: false`, **WHEN** the plugin
|
|
calls `Host.RevealSecret`, **THEN** kandev's server interceptor returns gRPC status
|
|
`PermissionDenied` with message `capability 'secrets' not declared`, before the
|
|
handler runs.
|
|
|
|
- **GIVEN** two plugins (Slack and Jira), **WHEN** the Jira plugin calls
|
|
`Host.EmitEvent` with `event_name: "sync-completed"`, **THEN** it is published as
|
|
`plugin.kandev-plugin-jira.sync-completed` and the Slack plugin (subscribed to
|
|
`plugin.kandev-plugin-jira.*`) receives it via `DeliverEvent` and posts a sync
|
|
summary to Slack.
|
|
|
|
- **GIVEN** a plugin with state, **WHEN** the plugin calls `Host.SetState` with
|
|
`scope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123"`,
|
|
**THEN** the state is persisted in SQLite. A subsequent `Host.GetState` call with the
|
|
same scope/key returns `"PROJ-123"`.
|
|
|
|
- **GIVEN** a plugin whose manifest declares `api_read: ["sessions"]`, **WHEN** the
|
|
plugin calls `ListSessionCodeStats`, **THEN** kandev returns per-session committed
|
|
and peak-pending line counts (plus, via `ListSessions`, each session's
|
|
`acp_session_id`) computed from the service layer, without the plugin opening the
|
|
kandev database file.
|
|
|
|
- **GIVEN** a plugin whose manifest does **not** declare `tasks` in `api_read`,
|
|
**WHEN** the plugin calls `ListTasks`, **THEN** kandev returns gRPC status
|
|
`PermissionDenied` with message `capability 'api_read:tasks' not declared`, before
|
|
the handler runs.
|
|
|
|
- **GIVEN** a plugin with `api_read: ["tasks"]` and more tasks than one page,
|
|
**WHEN** it calls `ListTasks` with `Page{limit: 50}` and then again with the
|
|
returned `PageInfo.next_cursor`, **THEN** the second call returns the next page and
|
|
`has_more` is false once the last page is reached.
|
|
|
|
- **GIVEN** a plugin with `api_read: ["tasks"]`, **WHEN** it calls `ListTasks`
|
|
without `include_ephemeral`, **THEN** quick-chat ephemeral tasks are excluded from
|
|
the results.
|
|
|
|
- **GIVEN** an active plugin registers `app-status-bar-left` or
|
|
`app-status-bar-right`, **WHEN** Kandev switches between desktop/tablet and phone,
|
|
**THEN** the plugin receives the exact slot props for the active bar or Status
|
|
drawer presentation, and only that presentation is mounted.
|
|
|
|
- **GIVEN** a user has moved a registered status contribution, **WHEN** the plugin
|
|
disables and later enables or Kandev restarts, **THEN** its deterministic
|
|
plugin/slot/ordinal identity restores the saved position; the original slot
|
|
remains its default side rather than overriding user order.
|
|
|
|
- **GIVEN** a plugin whose manifest declares `api_write: ["tasks"]`, **WHEN** it
|
|
calls `CreateTask`, **THEN** kandev creates the task through the task service
|
|
(firing `task.created`), stamps `source = "plugin:<id>"`, and returns the task;
|
|
**WHEN** a plugin without `api_write:tasks` calls `CreateTask`, **THEN** kandev
|
|
returns gRPC `PermissionDenied` with `capability 'api_write:tasks' not declared`.
|
|
|
|
- **GIVEN** a plugin whose manifest declares `api_write: ["messages"]`, **WHEN**
|
|
it calls `SendMessage` on a task, **THEN** kandev delivers the prompt to the
|
|
task's session through the orchestrator (queueing if running, resuming/starting
|
|
otherwise), records a user message with `source = "plugin:<id>"`, and returns
|
|
the target session and a `queued`/`sent`/`started` status; a plugin lacking
|
|
`api_write:messages` is denied.
|
|
|
|
- **GIVEN** a `SendMessage` whose dispatch fails after the user message was
|
|
recorded, **THEN** kandev deletes the recorded message and returns an error, so
|
|
a failed delivery leaves no durable user message and a retry can't duplicate
|
|
the prompt.
|
|
|
|
## Out of scope
|
|
|
|
- **Remote / operator-hosted plugin tier.** The earlier `base_url` registration model,
|
|
where an operator ran and managed a plugin process themselves and kandev only knew
|
|
its address, is removed. Every plugin backend in v1 is a binary kandev spawns and
|
|
supervises locally. A remote tier (kandev talking gRPC to a plugin process it does
|
|
not spawn) may return as future work if a real need emerges.
|
|
- **Plugin JS sandboxing.** Native UI plugins (see "Frontend plugin runtime") run
|
|
in the kandev origin with full app-store access. Isolating plugin JS in a worker,
|
|
realm, or comparable sandbox is future work; v1 relies on only loading active,
|
|
operator-installed plugins served same-origin.
|
|
- **In-process backend plugins.** Plugin *backends* remain out-of-process — no Go
|
|
plugin loading via `plugin.Open`, no WASM, no shared-memory communication. (This is
|
|
distinct from the frontend bundle, which does load into the SPA.)
|
|
- **Plugin marketplace or registry.** Out of scope *for this spec*: this spec covers
|
|
install-by-URL/upload as a manual, single-plugin action. The discoverable, curated
|
|
catalog (central registry, one-click install, star ranking, third-party sources) is
|
|
a sibling feature specified in [marketplace.md](marketplace.md) and built on top of
|
|
this spec's install pipeline.
|
|
- **Mandatory package signing.** `checksums.txt.sig` verification is supported when
|
|
present, but signing is optional in v1 — an unsigned package installs with a warning
|
|
rather than being rejected. Requiring signatures is future work.
|
|
- **Agent tools.** Plugins do not contribute tools to agents. An earlier
|
|
`tools[]` manifest section with an `InvokeTool` RPC was built during the
|
|
initial buildout but never wired into agent tool sets, and has been removed —
|
|
it duplicated MCP, kandev's established mechanism for exposing tools to
|
|
agents (`internal/mcp/`). If plugins ever contribute agent tools, they should
|
|
feed through the MCP surface rather than a parallel invocation path.
|
|
- **Hot reload.** Upgrading a plugin requires a new install (new version directory);
|
|
there is no in-place manifest or binary swap on a running process.
|
|
- **Multi-instance plugins.** Each plugin ID maps to exactly one supervised subprocess.
|
|
- **Rate limiting.** No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually.
|
|
- **Plugin database namespaces.** Plugins do not get their own SQLite schemas. KV state is sufficient for v1.
|
|
- **Broader write surface.** The Host data API writes cover task create/update
|
|
(conservative field mask) and sending a message to a task session. Deleting or
|
|
archiving tasks, writing sessions/workspaces/workflows/repositories, a wider
|
|
task-update mask, and delivery-mode/interrupt control on SendMessage are out of
|
|
scope for now. See "Host data API".
|
|
- **Per-session code-stats precomputation.** `SessionCodeStats` is computed on
|
|
demand per request in v1; a materialized or cached aggregation is future work.
|
|
- **Workspace-scoped plugin data access.** v1 reads are global to the instance with
|
|
a reserved scoping hook; per-plugin or per-user workspace restriction is future
|
|
work (see ADR 0043 open decisions).
|