Files
kandev/docs/plans/plugins/GRPC-CONTRACT.md
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* 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>
2026-07-27 10:54:12 +01:00

432 lines
21 KiB
Markdown

# kandev.plugin.v1 — gRPC plugin contract (FROZEN)
Supersedes the HTTP+HMAC transport. Every task builds against this file; do not
diverge without updating it. The frontend contract (PLUGIN-API.md) is unchanged
except where noted in §7.
## 1. Architecture
- Plugin **backends are Go binaries** distributed in a release tarball and
**spawned by kandev as subprocesses** via `hashicorp/go-plugin`.
- Transport: gRPC over a unix domain socket (macOS/Linux) or loopback TCP +
AutoMTLS (Windows) — negotiated by go-plugin, invisible to authors.
- Auth: the spawn relationship + go-plugin handshake + AutoMTLS. **No api_key,
no webhook_secret, no HMAC** — all credential machinery is removed for managed
plugins. The remote/self-hosted tier (`base_url` registration) is REMOVED
(future work if ever needed).
- The `LISTENING <addr>` stdout handshake is replaced by go-plugin's handshake.
## 2. go-plugin handshake
```go
var Handshake = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "KANDEV_PLUGIN",
MagicCookieValue: "kandev-plugin-v1",
}
// plugin map key: "plugin"; AutoMTLS: enabled on the client (kandev) side.
```
Env kandev injects into the subprocess:
- `KANDEV_PLUGIN_DATA_DIR` — per-plugin writable dir (`~/.kandev/plugins/<id>/data`).
## 3. Proto (`apps/backend/proto/kandev/plugin/v1/plugin.proto`)
```proto
syntax = "proto3";
package kandev.plugin.v1;
import "google/protobuf/struct.proto";
// Implemented by the PLUGIN. kandev is the client.
service Plugin {
rpc DeliverEvent(Event) returns (EventAck);
rpc HandleWebhook(WebhookRequest) returns (WebhookResponse);
}
// Implemented by KANDEV (served back over the go-plugin broker).
// Every RPC is capability-gated server-side (§5).
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);
// The plugin's own operator-editable config (Settings > Plugins > <plugin>,
// driven by the manifest's config_schema). Ungated; secret values arrive
// in cleartext — this RPC is how an operator-configured credential (e.g. a
// PAT) reaches the plugin. At rest, secret config fields live in kandev's
// encrypted vault (the config file holds only a vault reference); the Host
// resolves them before responding. kandev restarts a running plugin on
// config change, so reading config at startup is sufficient.
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
// Plugin-scoped secret primitives — capability `secrets`. Keys are
// namespaced server-side to the calling plugin (vault id
// "plugin:<id>:secret:<key>", key must match
// [a-zA-Z0-9][a-zA-Z0-9._-]{0,127}), so a plugin can only ever touch its
// OWN entries; RevealSecret remains the way to resolve an
// operator-provided reference to a shared/global secret. Values live in
// kandev's encrypted vault (AES-256-GCM at rest) and the whole
// "plugin:<id>:" namespace is deleted on uninstall.
rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
rpc SetSecret(SetSecretRequest) returns (SetSecretResponse);
rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse);
// Host data API (ADR 0043, §3a below) — reads, capability api_read:<resource>.
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc GetTask(GetTaskRequest) returns (Task);
rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse);
rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse);
rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse);
rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);
rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse);
// Host data API — writes, capability api_write:<resource>. Route through the
// first-party service layer so events fire (§3a "Writes"). api_write:tasks
// gates CreateTask/UpdateTask; api_write:messages gates SendMessage (delivers
// a prompt to a task session through the orchestrator). Undeclared → gRPC
// PermissionDenied.
rpc CreateTask(CreateTaskRequest) returns (Task);
rpc UpdateTask(UpdateTaskRequest) returns (Task);
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
}
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
}
message EventAck {}
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; }
message GetStateRequest { string scope = 1; string scope_id = 2; string key = 3; }
message GetStateResponse { bool found = 1; google.protobuf.Struct value = 2; }
message SetStateRequest { string scope = 1; string scope_id = 2; string key = 3; google.protobuf.Struct value = 4; }
message SetStateResponse {}
message DeleteStateRequest { string scope = 1; string scope_id = 2; string key = 3; }
message DeleteStateResponse {}
message ListStateRequest { string scope = 1; string scope_id = 2; }
message ListStateResponse { repeated StateEntry entries = 1; }
message StateEntry { string key = 1; google.protobuf.Struct value = 2; string updated_at = 3; }
message GetConfigRequest {}
message GetConfigResponse { google.protobuf.Struct config = 1; }
message GetSecretRequest { string key = 1; }
message GetSecretResponse { bool found = 1; string value = 2; }
message SetSecretRequest { string key = 1; string value = 2; }
message SetSecretResponse {}
message DeleteSecretRequest { string key = 1; }
message DeleteSecretResponse {}
message RevealSecretRequest { string ref = 1; }
message RevealSecretResponse { string value = 1; }
message EmitEventRequest { string event_name = 1; google.protobuf.Struct payload = 2; }
message EmitEventResponse {}
```
Notes: scope ∈ instance|workspace|task|agent (empty scope_id for instance —
matches the state store). The plugin never passes its own id; the Host service
instance is bound to the plugin's record at spawn time.
### 3a. Host data API (ADR 0043)
Read/write RPCs let plugins read and write kandev's own domain data —
tasks, sessions, workspaces, workflows, agent profiles, repositories, messages —
over the same Host gRPC channel used for state/secrets, instead of opening the
kandev database file directly. Full message definitions (`Page`, `PageInfo`,
`Task`, `TaskFilter`, `Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`,
`Repository`, `Session`, `SessionFilter`, `SessionCodeStats`, and the write
`CreateTaskRequest`/`UpdateTaskRequest`/`SendMessageRequest`/`SendMessageResponse`) live in
the real proto — `apps/backend/proto/kandev/plugin/v1/plugin.proto` — and are not
duplicated here; this section covers the RPC list (added to `service Host` above),
capability gating, and cross-cutting conventions. See ADR 0043
(`docs/decisions/0043-plugin-host-data-api.md`) for the design rationale.
**Readable resources.** Each read RPC requires `api_read:<resource>` in the
plugin's manifest:
| RPC | Capability | Resource |
|---|---|---|
| `ListTasks` / `GetTask` | `api_read:tasks` | tasks |
| `ListWorkspaces` | `api_read:workspaces` | workspaces |
| `ListWorkflows` | `api_read:workflows` | workflows |
| `ListWorkflowSteps` | `api_read:workflows` | workflows |
| `ListAgentProfiles` | `api_read:agent_profiles` | agent_profiles |
| `ListRepositories` | `api_read:repositories` | repositories |
| `ListSessions` | `api_read:sessions` | sessions |
| `ListSessionCodeStats` | `api_read:sessions` | sessions |
An undeclared capability returns gRPC `PermissionDenied` with message
`capability 'api_read:tasks' not declared` (substituting the actual resource) —
identical in shape to the existing state/secrets gating. Declaring the resource
grants every RPC listed against it; there is no finer-grained gate within a
resource (e.g. `api_read:workflows` covers both `ListWorkflows` and
`ListWorkflowSteps`).
**Writes.** `CreateTask`, `UpdateTask`, and `SendMessage` are implemented.
`CreateTask`/`UpdateTask` are gated by `api_write:tasks` and route through
`internal/task/service` (never a repository), so `task.*` events fire and
WS-driven UI stays in sync. The server stamps `source = "plugin:<id>"` on the
created task's metadata — a plugin cannot set it itself. `CreateTask` resolves
sane placement defaults when the plugin omits them: an empty `workspace_id`
resolves to the single workspace (ambiguous otherwise → `InvalidArgument`), an
empty `workflow_id` to that workspace's first workflow. `UpdateTask` accepts a
conservative field mask — `title`, `description`, `state`, `workflow_step_id`
(each optional/leave-unset). `start_agent` best-effort auto-launches an agent
through the orchestrator; a launch failure does not fail the create.
Write validation/error contract (so plugin authors can predict outcomes):
`CreateTask` requires a non-empty `title` (`InvalidArgument` otherwise);
`UpdateTask` requires `id` (`InvalidArgument`) and returns `NotFound` for a
task that doesn't exist; an `UpdateTask` `state` outside the known task-state
enum (or the orchestrator-owned `SCHEDULING`) is rejected with `InvalidArgument`
before it reaches the service.
`SendMessage` is gated by `api_write:messages` and 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 — not
an office comment. It resolves the target session (explicit `session_id`,
verified to belong to the task, or the task's primary session), records a user
message stamped `source = "plugin:<id>"`, and dispatches by session state: a
running session queues the prompt (`status: "queued"`); an idle/completed one is
prompted, resuming the agent if its process is gone (`"sent"`); a never-started
one is launched with the prompt as its first turn (`"started"`). A failed
dispatch deletes the recorded message so no orphan prompt is left.
**Reads and writes go through the service layer, never a repository.** Each read
handler calls the relevant internal service (task service, workflow service, the
analytics service for `ListSessionCodeStats`); each write handler calls the
event-publishing service method — so derived fields, events, and future access
rules stay centralized in one place.
**DTOs are a hand-mapped, versioned contract — never internal structs.** The
backend maps internal models to the proto messages above with explicit
conversion code; it never marshals domain structs through
`google.protobuf.Struct` and never generates the messages from models. Fields
are additive-only after merge — removing or renaming one is a breaking change
requiring a new `api_version`. `SessionCodeStats` in particular is a
deliberately **computed** shape (`lines_added_committed` /
`lines_deleted_committed` from commit sums, `lines_added_peak_pending` /
`lines_deleted_peak_pending` from the peak uncommitted diff across snapshots),
computed on demand per request — plugins never see the raw
`task_session_commits` / `task_session_git_snapshots` rows those numbers are
derived from.
**Conventions.**
- **Pagination:** opaque-cursor. A request carries `Page{limit, cursor}` (0 limit
→ server default, currently 50, capped at 200); a list response carries
`PageInfo{next_cursor, has_more}`. An empty cursor is the first page; echo
`next_cursor` to continue. Plugins MUST NOT interpret cursor contents — the
current server encodes it as a decimal offset, but that is an implementation
detail, not part of the contract.
- **Timestamps:** RFC3339 strings (`created_at`, `updated_at`, `started_at`,
`occurred_at`, ...), matching the `Event` envelope and the JSON API — one time
representation across the whole plugin contract, never protobuf `Timestamp`.
- **Nullables:** optional string fields use proto3 `optional` (e.g.
`Task.started_at`, `Task.completed_at`, `Task.parent_id`,
`Session.ended_at`), so an absent (NULL) value is distinguishable from an
empty string.
- **Scoping (v1):** reads are global to the kandev instance — plugins are
installed instance-wide, not per-workspace. Filters (`workspace_ids`,
`task_ids`, `states` on `TaskFilter`/`SessionFilter`) narrow results but do
not themselves confer or restrict visibility; a server-side scoping hook is
reserved for a future per-plugin/per-user restriction without a contract
change (ADR 0043, open decision (a)).
- **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the
request sets `TaskFilter.include_ephemeral`.
## 4. SDK (`apps/backend/pkg/pluginsdk`)
Public Go module surface (authors import only this):
```go
type Plugin interface {
OnEvent(ctx context.Context, e *Event) error // return err → kandev retries
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
}
type Host interface { // injected before Serve returns
GetState/SetState/DeleteState/ListState(...)
GetConfig(ctx) (map[string]any, error) // own operator config, cleartext
RevealSecret(ctx, ref string) (string, error) // operator-provided shared-secret ref
GetSecret(ctx, key) (value string, found bool, err error) // plugin-owned, vault-backed
SetSecret(ctx, key, value string) error
DeleteSecret(ctx, key string) error
EmitEvent(ctx, name string, payload map[string]any) error
// Host data API (ADR 0043, §3a) — each accessor is capability-gated by
// the corresponding api_read:<resource>; see "Host data API accessors"
// below for the reader interfaces and Go-native DTOs.
Tasks() TaskReader
Sessions() SessionReader
Workspaces() WorkspaceReader
Workflows() WorkflowReader
AgentProfiles() AgentProfileReader
Repositories() RepositoryReader
}
func Serve(p Plugin, opts ...Option) // blocks; wires go-plugin server + broker
// Optional embeddable no-op base: sdk.UnimplementedPlugin
// Optional embeddable no-op base for Host data accessors (every method
// PermissionDenied/Unimplemented): sdk.UnimplementedHostData — used on the
// kandev side, not by plugin authors.
```
SDK types mirror proto but use `map[string]any` for Struct fields. The SDK owns
all go-plugin/grpc plumbing (handshake, broker for Host, conversions).
### Host data API accessors (`apps/backend/pkg/pluginsdk/host.go`, `data_types.go`)
Each `Host.<Resource>()` call above returns a small reader interface. All
methods take `context.Context`; list methods take a Go-native `Page{Limit
int32; Cursor string}` and return `(items []T, *PageInfo, error)` where
`PageInfo{NextCursor string; HasMore bool}` — the Go-native mirror of the wire
`Page`/`PageInfo` messages (§3a). A resource whose capability isn't declared
still returns a non-nil reader; every method on it returns a gRPC
`PermissionDenied` error instead of a zero value.
```go
type TaskReader interface {
List(ctx context.Context, filter TaskFilter, page Page) ([]Task, *PageInfo, error)
Get(ctx context.Context, id string) (*Task, error)
}
type SessionReader interface {
List(ctx context.Context, filter SessionFilter, page Page) ([]Session, *PageInfo, error)
CodeStats(ctx context.Context, filter SessionFilter, page Page) ([]SessionCodeStats, *PageInfo, error)
}
type WorkspaceReader interface {
List(ctx context.Context, page Page) ([]Workspace, *PageInfo, error)
}
type WorkflowReader interface {
List(ctx context.Context, workspaceID string, page Page) ([]Workflow, *PageInfo, error)
ListSteps(ctx context.Context, workflowID string) ([]WorkflowStep, error)
}
type AgentProfileReader interface {
List(ctx context.Context, page Page) ([]AgentProfile, *PageInfo, error)
}
type RepositoryReader interface {
List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error)
}
```
`Task`, `Session`, `SessionCodeStats`, `Workspace`, `Workflow`, `WorkflowStep`,
`AgentProfile`, `Repository`, `TaskFilter`, `SessionFilter` are Go-native
structs in `pluginsdk` (field-for-field mirrors of the proto messages, PascalCase
Go names for the proto's snake_case fields, `*string` for `optional string`) —
authors never see the generated `pluginv1.*` types.
**Authoring example** — a plugin declaring `api_read: ["sessions"]` and reading
computed per-session code stats instead of opening the kandev database:
```yaml
# manifest.yaml
capabilities:
api_read: ["sessions"]
```
```go
func (p *statsPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
stats, pageInfo, err := p.host.Sessions().CodeStats(ctx, pluginsdk.SessionFilter{
WorkspaceIDs: []string{e.WorkspaceID},
}, pluginsdk.Page{Limit: 100})
if err != nil {
return err // e.g. gRPC PermissionDenied if api_read:sessions isn't declared
}
for _, s := range stats {
log.Printf("session %s: +%d/-%d committed, +%d/-%d peak pending",
s.SessionID, s.LinesAddedCommitted, s.LinesDeletedCommitted,
s.LinesAddedPeakPending, s.LinesDeletedPeakPending)
}
_ = pageInfo.HasMore // paginate via pageInfo.NextCursor when true
return nil
}
```
`kandev-plugin-agent-stats` is the plugin ADR 0043 was written for: it
originally opened `~/.kandev/data/kandev.db` read-only and hand-aggregated
`task_session_commits`/`task_session_git_snapshots` to get exactly the numbers
`Sessions().CodeStats(...)` now returns as a stable, computed DTO — read via
the API, never the DB.
## 5. Delivery / webhooks semantics (unchanged from HTTP era)
- **DeliverEvent**: unary. Per-plugin sequential queue, 10s timeout, 3 retries
(5s/15s/45s, injectable), ring buffer 100/5min while plugin unhealthy, flush
in order on recovery. Non-nil error or timeout counts as failure.
- **HandleWebhook**: kandev's HTTP endpoint `POST /api/plugins/{id}/webhooks/{key}`
converts the HTTP request to WebhookRequest and relays the WebhookResponse.
- **Health**: go-plugin client `Ping()` every 30s (injectable), 3 consecutive
failures → status `error` (+ restart attempt with backoff), recovery → `active`
+ delivery flush. Crash (process exit) → immediate restart with backoff
(max 5 attempts, then `error`).
- **Capability gating**: each Host RPC checks the plugin's manifest capabilities
before doing any work — `state` for `GetState`/`SetState`/`DeleteState`/
`ListState`, `secrets` for `RevealSecret`, `api_read:<resource>` for each Host
data API read RPC (§3a), `api_write:tasks` for `CreateTask`/`UpdateTask` and
`api_write:messages` for `SendMessage` — and returns PermissionDenied with
`capability '<name>' not declared` on a miss. `EmitEvent` is ungated. Reads
and writes gate independently on the same resource, so a plugin can declare
`api_read:tasks` without `api_write:tasks` (or vice versa), and likewise for
`messages`.
## 6. Package format (`<id>-<version>.tar.gz`)
```
manifest.yaml # authoritative; read BEFORE any code runs
server/plugin-<goos>-<goarch>[.exe] # any subset; host platform 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)
```
Manifest additions (replaces base_url; endpoints block is REMOVED):
```yaml
runtime:
type: binary
executables:
linux-amd64: server/plugin-linux-amd64
darwin-arm64: server/plugin-darwin-arm64
# ... any subset
min_kandev_version: "0.78.0" # optional
```
Install pipeline: `POST /api/plugins/install` with JSON `{"url": "..."}` OR
multipart field `package` → verify checksums.txt covers all files & hashes match
→ parse+validate manifest (host platform key present; id pattern; capabilities)
→ extract to `~/.kandev/plugins/<id>/<version>/` → write record → status
`registered` → spawn → handshake OK → `active`. Record keeps `version` and
`install_path`. Uninstall stops the process and removes record + versions + data
(24h grace not required for v1). `POST /api/plugins/register` is REMOVED.
## 7. Frontend deltas (PLUGIN-API.md otherwise unchanged)
- `GET /api/plugins/{id}/bundle` and `/api/plugins/{id}/ui/*` are served by
kandev **from the extracted package dir** (no reverse proxy, no upstream).
- Management page: "Register plugin" (manifest paste) is replaced by "Install
plugin" (URL input + file upload). No credentials are ever displayed.
- Boot payload `plugins: [{id,name,bundleUrl,styleUrls}]` unchanged.
```