Files
kandev/docs/plans/plugins/GRPC-CONTRACT.md
T
2026-09-15 09:40:39 +01:00

617 lines
33 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);
// Browser calls pass through host-authenticated declared actions only.
rpc HandleAction(PluginActionRequest) returns (PluginActionResponse);
// Manifest-owned dynamic composer reference source operations.
rpc SearchEntityReferences(SearchEntityReferencesRequest) returns (SearchEntityReferencesResponse);
rpc AuthorizeEntityReference(AuthorizeEntityReferenceRequest) returns (AuthorizeEntityReferenceResponse);
// Optional provider-neutral credential resolver for declared repository providers.
rpc ResolveGitCredential(ResolveGitCredentialRequest) returns (ResolveGitCredentialResponse);
rpc GetGitCredentialBinding(GitCredentialBindingRequest) returns (GitCredentialBindingResponse);
}
// 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);
// Empty profile_id delegates to the platform default. A non-empty value
// selects that exact eligible profile. This separate method prevents an
// older host from silently ignoring an explicit selection.
rpc InvokeUtilityAgentWithOptions(InvokeUtilityAgentWithOptionsRequest) returns (InvokeUtilityAgentResponse);
// 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 ListExecutorProfiles(ListExecutorProfilesRequest) returns (ListExecutorProfilesResponse);
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);
rpc PreviewPluginOwnedTaskTree(PreviewPluginOwnedTaskTreeRequest) returns (PreviewPluginOwnedTaskTreeResponse);
rpc DeletePluginOwnedTaskTree(DeletePluginOwnedTaskTreeRequest) returns (DeletePluginOwnedTaskTreeResponse);
}
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; }
// The host derives resources and actor after normal HTTP auth/authorization. Body is
// untrusted JSON bounded by the manifest declaration; plugins must not infer authority
// from it. Response headers are filtered by the host allowlist.
message PluginActionRequest {
string action_key = 1;
VerifiedActionContext context = 2;
bytes body = 3;
}
message VerifiedActionContext {
string actor_id = 1;
string workspace_id = 2;
string task_id = 3;
string repository_id = 4;
string session_id = 5;
string head_branch = 6;
}
// status=0 preserves legacy 200. Otherwise the host accepts 200..599 and
// projects the status after filtering headers and enforcing the body limit.
message PluginActionResponse { bytes body = 1; map<string, string> headers = 2; int32 status = 3; }
message SearchEntityReferencesRequest { string source = 1; string workspace_id = 2; string query = 3; int32 limit = 4; }
message SearchEntityReferencesResponse { repeated EntityReferenceCandidate candidates = 1; }
message EntityReferenceCandidate { string provider_local_id = 1; string title = 2; string url = 3; google.protobuf.Struct attributes = 4; }
message AuthorizeEntityReferenceRequest { string source = 1; string workspace_id = 2; string purpose = 3; google.protobuf.Struct reference = 4; }
message AuthorizeEntityReferenceResponse { bool allowed = 1; string reason = 2; }
// Scope is host-verified. The credential value is transient; it must never be written
// into a host URL, task state, command argument, log, or executor environment.
message ResolveGitCredentialRequest { string provider_id = 1; string workspace_id = 2; string task_id = 3; string session_id = 4; string repository_id = 5; string host = 6; string path = 7; }
message ResolveGitCredentialResponse { string username = 1; string secret = 2; string expires_at = 3; }
message GitCredentialBindingRequest { string provider_id = 1; string workspace_id = 2; string task_id = 3; string session_id = 4; string repository_id = 5; string host = 6; string path = 7; }
message GitCredentialBindingResponse { string binding = 1; }
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 {}
message InvokeUtilityAgentRequest { string prompt = 1; }
message InvokeUtilityAgentWithOptionsRequest {
string prompt = 1;
string profile_id = 2;
}
message InvokeUtilityAgentResponse { string text = 1; }
```
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.
`InvokeUtilityAgent` remains the prompt-only compatibility method. On a revised
host it uses the platform default profile from Settings > Utility Agents.
`InvokeUtilityAgentWithOptions` accepts the same prompt plus an optional
`profile_id`; a non-empty ID selects that exact eligible profile and an invalid
explicit ID returns `FailedPrecondition` without fallback. The revised SDK
uses the options RPC for every call, including calls without an override. An
older host returns `Unimplemented` for that method, and the SDK does not retry
through the prompt-only RPC. Plugins own saved preferences and pass them in
the request. The host does not read plugin configuration, utility-agent
records, or transition metadata for invocation selection.
`DeletePluginOwnedTaskTree` is partial-progress aware. A successful response
carries every deleted task ID. If deletion stops after removing descendants,
the non-OK status includes a `DeletePluginOwnedTaskTreeProgress` detail with
those IDs. The Go SDK preserves them in the returned `([]string, error)` so a
plugin can reconcile completed deletions before retrying idempotent cleanup;
callers must not discard progress merely because the error is non-nil. An absent root
is a successful no-op, so a retry after a completed or externally removed tree remains
safe.
### 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.
API v1 DTO fields are additive-only. `Task.labels` field 23, shipped in v0.93.0,
remains generated and readable as a deprecated compatibility field. New plugins
store provider-specific annotations in plugin-owned task state and render them
through plugin UI slots; CreateTask and UpdateTask do not expose label writes.
**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 |
| `ListExecutorProfiles` | `api_read:executor_profiles` | executor_profiles |
| `ListRepositories` | `api_read:repositories` | repositories |
| `ListSessions` | `api_read:sessions` | sessions |
| `ListSessionCodeStats` | `api_read:sessions` | sessions |
| `ListMessages` | `api_read:messages` | messages |
| `ListPendingInteractions` / `GetInteraction` | `api_read:interactions` | interactions |
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`, and `priority` (each
optional/leave-unset). `workflow_step_id` remains present for
wire compatibility but is rejected; plugins use `MoveTask` for transitions.
`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.
**Pending interactions** (`api_read:interactions` / `api_write:interactions`,
ADR 0052 — `docs/decisions/0052-plugin-host-interaction-api.md`) are the durable
record of every agent request still owed a human answer: a tool permission
request, or a whole clarification bundle collapsed into one `Interaction` with
its `questions`. Session state is deliberately NOT that record —
`WAITING_FOR_INPUT` also describes an ordinarily completed turn — so a plugin
that branches on state alone reports attention nobody owes.
`ListPendingInteractions` applies the same turn/session authority Kandev's own
list surfaces use: only the session's current durable turn counts, terminal
sessions quarantine pending history, and only the newest permission row of that
turn is answerable. `GetInteraction` resolves ANY interaction by pending id,
terminal ones included, so an event-driven cache that started late, restarted,
or dropped an event converges on the current result instead of `NotFound`.
The three writes route through the first-party services the native UI drives:
`RespondToPermission` through the orchestrator, `AnswerClarification` and
`CancelClarification` through the clarification handler (including its durable
exclusive claim and its detached-resume fallback). A permission response must
name one of the interaction's declared options — Kandev derives the
approve/deny outcome from that option's recorded ACP kind, so a plugin cannot
report an outcome the agent never offered — and the target session comes from
the durable record, never from the request.
Writes are terminal-once: the first response wins, an already-resolved
interaction answers `FailedPrecondition`, and an unknown id answers `NotFound`.
Those two codes are the distinction a reconciling cache needs between "someone
else answered first" and "my id is stale". `CancelClarification` is delivered as
a decline rather than an in-memory cancellation, so it also settles a bundle
whose original waiter went away in a restart.
**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
}
// Optional Host extension, discovered without breaking existing Host implementations.
type ExecutorProfileHost interface {
ExecutorProfiles() ExecutorProfileReader
}
func ExecutorProfiles(host Host) (ExecutorProfileReader, bool)
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.
```
### Provider extensions (additive)
`Plugin` remains source-compatible. `Serve` detects optional handler interfaces and
returns `Unimplemented` only when a plugin has not opted into the corresponding
manifest declaration:
```go
type ActionHandler interface {
HandleAction(context.Context, *PluginActionRequest) (*PluginActionResponse, error)
}
type EntityReferenceHandler interface {
SearchEntityReferences(context.Context, *SearchEntityReferencesRequest) (*SearchEntityReferencesResponse, error)
AuthorizeEntityReference(context.Context, *AuthorizeEntityReferenceRequest) (*AuthorizeEntityReferenceResponse, error)
}
type GitCredentialResolver interface {
ResolveGitCredential(context.Context, *ResolveGitCredentialRequest) (*ResolveGitCredentialResponse, error)
}
type GitCredentialBinder interface {
GetGitCredentialBinding(context.Context, *GitCredentialBindingRequest) (*GitCredentialBindingResponse, error)
}
```
`HandleAction` receives host-verified actor/resource context and bounded untrusted body
separately. For task actions, an optional session selector is verified against the task;
when paired with a verified repository selector, `head_branch` is resolved from that
session's exact repository worktree. Browser body JSON cannot select or override it.
`SearchEntityReferences` candidates are untrusted: the host injects
descriptor identity and constructs canonical reference fields. `AuthorizeEntityReference`
runs for search and submission. `ResolveGitCredential` receives an exact host-verified
scope for both initial host materialization and helper-lease redemption, and returns
only a transient credential consumed by the host Git process. Initial materialization
and strict pre-worktree refresh carry the same task/session/repository scope; after a
successful refresh the worktree layer uses local refs and performs no second network operation.
Credential requests must include workspace, task, active session, repository, exact host, and exact path;
an incomplete plugin-provider scope fails closed.
`GetGitCredentialBinding` receives the same scope and returns an opaque, non-secret
generation checked before and after redemption; missing or changed bindings fail closed.
Disabling, failing, or uninstalling a plugin immediately revokes leases for all
manifest-declared provider IDs. Repository host and path matching are exact and
case-sensitive. The broker does not add, remove, or equate a trailing `.git`.
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 ExecutorProfileReader interface {
List(ctx context.Context, page Page) ([]ExecutorProfile, *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.
`Repository` additionally carries credential-free provider origin identity:
`source_type`, `provider_id`, `provider_repository_id`, `provider_host`, `provider_scope`,
`owner_or_project`, `provider_name`, and `remote_url`. The Host never exposes a
local checkout path, scripts, or credentials through this DTO.
`provider_scope` is opaque and credential-free. For provider-backed repositories,
the strong identity is workspace + provider + scope + immutable provider repository
ID. Host/name/owner fields remain routing and display metadata; scoped descriptors do
not adopt legacy unscoped rows.
**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,repositoryProviderIds?}]`.
`repositoryProviderIds` is JSON camelCase copied from manifest
`repository_providers`; frontend loader records it before bundle initialization so
provider/review registration can enforce declared ownership. Omission remains
compatible with older payloads. Failed or timed-out initialization rolls back partial
registrations and fences late callbacks from the expired activation attempt.
```
```