18 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | plugins |
|
2026-04-26 |
|
Plugin System System Design Part 4
Purpose and boundaries
This design preserves the technical source detail for REQ-PLUGINS-PLUGINS-001 during migration.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-PLUGINS-PLUGINS-001 |
Migrated source detail |
Migrated source detail
Scenarios
-
GIVEN an operator with a release tarball URL for a Slack notification plugin, WHEN the operator calls
POST /api/plugins/installwith{"url": "..."}, THEN kandev downloads the tarball, verifieschecksums.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 inGET /api/pluginswith statusactive. -
GIVEN an operator with a plugin tarball on their local machine, WHEN the operator uploads it via
POST /api/plugins/install(multipartpackage), THEN kandev runs the same verify → validate → extract → spawn pipeline and the plugin reachesactivewithout any URL ever being contacted. -
GIVEN the operator has entered a valid URL or selected a package in the Settings > Plugins install dialog, WHEN the operator submits the install, THEN the primary Install action is disabled, marked busy, and shows an animated loading indicator with the installing label until the install and post-install loading pipeline settles. WHEN the pipeline succeeds, THEN the dialog closes as usual. WHEN it fails, THEN the indicator stops, the action becomes available for retry, and the existing inline error remains visible.
-
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 unrecordedmanifest.yaml, validates it, and registers the plugin with statusdisabled— 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 todone, THEN kandev calls the plugin'sDeliverEventgRPC method with the event over the go-plugin unix socket. The plugin formats a Slack message and calls the Slack API, then returnsEventAck{}. -
GIVEN a Jira sync plugin with a registered
jira-webhookswebhook, WHEN Jira POSTs a webhook tohttps://kandev.example.com/api/plugins/kandev-plugin-jira/webhooks/jira-webhooks, THEN kandev converts the HTTP request into aWebhookRequestand calls the plugin'sHandleWebhookgRPC method. The plugin parses the Jira event, callsHost.SetStateto record the linked task, and returns aWebhookResponsethat 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
errorwhile 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 toactiveand buffered events are delivered in order, with the persisted failure diagnostic cleared. -
GIVEN a plugin in
errorwith a persistedlast_error, WHEN the operator opens Settings > Plugins, THEN the row shows the diagnostic and an Enable action. WHEN the operator clicks Enable and the plugin starts successfully, THEN status returns toactive, the diagnostic fields are cleared, and normal delivery resumes. WHEN the retry fails, THEN status remainserror, the client refetches the authoritative plugin record, and the new diagnostic replaces the previous one in the row and detail views. -
GIVEN restart exhaustion is entering its final unhealthy callback, WHEN an operator concurrently clicks Enable, THEN the callback and Enable both complete and the replacement process is either started or reports its own result; neither operation waits forever on the other.
-
GIVEN a persisted diagnostic containing an unbroken long token, WHEN the operator opens the plugin row or detail view on a phone, THEN the diagnostic wraps inside its container and the page has no horizontal overflow. Recovery actions have a minimum 44 CSS-pixel phone target.
-
GIVEN a plugin whose event ring buffer is full, WHEN additional events are dropped, THEN kandev logs one warning for the first drop and suppresses further warnings for that plugin for one minute while counting them. WHEN the next warning is emitted, THEN it includes the aggregate number of drops since the previous warning.
-
GIVEN a plugin whose manifest declares
secrets: false, WHEN the plugin callsHost.RevealSecret, THEN kandev's server interceptor returns gRPC statusPermissionDeniedwith messagecapability 'secrets' not declared, before the handler runs. -
GIVEN two plugins (Slack and Jira), WHEN the Jira plugin calls
Host.EmitEventwithevent_name: "sync-completed", THEN it is published asplugin.kandev-plugin-jira.sync-completedand the Slack plugin (subscribed toplugin.kandev-plugin-jira.*) receives it viaDeliverEventand posts a sync summary to Slack. -
GIVEN a plugin with state, WHEN the plugin calls
Host.SetStatewithscope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123", THEN the state is persisted in SQLite. A subsequentHost.GetStatecall with the same scope/key returns"PROJ-123". -
GIVEN a plugin whose manifest declares
api_read: ["sessions"], WHEN the plugin callsListSessionCodeStats, THEN kandev returns per-session committed and peak-pending line counts (plus, viaListSessions, each session'sacp_session_id) computed from the service layer, without the plugin opening the kandev database file. -
GIVEN a plugin whose manifest does not declare
tasksinapi_read, WHEN the plugin callsListTasks, THEN kandev returns gRPC statusPermissionDeniedwith messagecapability 'api_read:tasks' not declared, before the handler runs. -
GIVEN a plugin with
api_read: ["tasks"]and more tasks than one page, WHEN it callsListTaskswithPage{limit: 50}and then again with the returnedPageInfo.next_cursor, THEN the second call returns the next page andhas_moreis false once the last page is reached. -
GIVEN a plugin with
api_read: ["tasks"], WHEN it callsListTaskswithoutinclude_ephemeral, THEN quick-chat ephemeral tasks are excluded from the results. -
GIVEN an active plugin registers
app-status-bar-leftorapp-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 callsCreateTask, THEN kandev creates the task through the task service (firingtask.created), stampssource = "plugin:<id>", and returns the task; WHEN a plugin withoutapi_write:taskscallsCreateTask, THEN kandev returns gRPCPermissionDeniedwithcapability 'api_write:tasks' not declared. -
GIVEN a plugin whose manifest declares
api_write: ["messages"], WHEN it callsSendMessageon 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 withsource = "plugin:<id>", and returns the target session and aqueued/sent/startedstatus; a plugin lackingapi_write:messagesis denied. -
GIVEN a
SendMessagewhose 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. -
GIVEN an active plugin declares
connection.savewith workspace scope, WHEN a signed-in user invokeshost.api.invokeAction("connection.save", ...), THEN Kandev authenticates the caller, authorizes the workspace, bounds the payload, and passes verified context separately toPlugin.HandleAction; the public webhook endpoint is not invoked. -
GIVEN a provider operation owns an
AbortSignal, WHEN its registration is replaced, unloaded, or its request is superseded, THENinvokeActionpropagates cancellation to the authenticated HTTP request and backend action context. -
GIVEN a manifest-owned repository and review provider, WHEN it registers native task-link actions and a review panel, THEN Kandev renders those contributions in applicable desktop and mobile task/review surfaces and removes them, including in-flight work, when the plugin disables.
-
GIVEN a registered review provider publishes normalized task status, WHEN a linked task renders on desktop or mobile, THEN Kandev automatically mounts the same topbar control, composer CI chip, hover popover/mobile drawer, review summary, comment summary, and checks anatomy used by GitHub, refreshes through the provider lifecycle, and requires no provider-owned visual slot or second poller.
-
GIVEN a compatible code-host review provider, WHEN its panel renders normalized detail through
host.ui.ChangeRequestDetail, THEN GitHub and the provider share the same header, collapsible sections, add-to-context controls, scroll ownership, loading/error treatment, and desktop/mobile geometry; only data, capabilities, and callbacks remain provider-owned. -
GIVEN a plugin-owned
#reference source returns a pull-request candidate, WHEN the user submits the selected reference, THEN Kandev authorizes it again against the live plugin and rejects a disabled, stale, tampered, or cross-workspace candidate before queueing message metadata. -
GIVEN a saved or open plugin task panel and a plugin reload whose import or
initialize()takes longer than 500 milliseconds, WHEN registration eventually succeeds with the same panel identity, THEN the panel remains in the layout and renders the reloaded component without being closed or deleted. -
GIVEN an open plugin task panel and a successfully initialized replacement version that no longer registers that panel identity, WHEN initialization completes, THEN the obsolete panel closes exactly once.
-
GIVEN a plugin task panel focused on a phone, WHEN the plugin is disabled or uninstalled, THEN its picker row disappears, its component unmounts, and the session focuses Chat instead of leaving an unavailable panel selected.
-
GIVEN several plugins register mobile-enabled task panels, WHEN a user opens the task workspace on a phone, THEN the fixed bottom navigation exposes one touch-sized Panels entry whose picker lists every panel without shrinking the other navigation targets or causing document-level horizontal overflow.
-
GIVEN a plugin task-menu action is invoked from the phone kanban, WHEN its
visible(context)orrun(context)callback executes, THENcontext.presentationis"mobile"; the same action invoked from desktop receives"desktop". -
GIVEN a plugin registers a
"primary"task-menu action, WHEN a user opens a desktop sidebar or phone task-sheet menu, THEN the same action is present with the correctpresentation. GIVEN the plugin unregisters while the menu is open, THEN its action disappears without a reload. -
GIVEN any plugin registers
"task-row-metadata", WHEN sidebar or/tasksrows render, THEN it receives{ taskId, workspaceId, workflowStepId, surface }. GIVEN the slot is empty, THEN the host renders no metadata wrapper or extra spacing. -
GIVEN a plugin has per-user state and deleting those rows returns an error, WHEN an operator uninstalls it, THEN the request fails, the package and plugin record remain installed for retry, and no successful-uninstall response is emitted. WHEN cleanup later succeeds and uninstall is retried, THEN all users' rows, the package, and the record are removed.
-
GIVEN a plugin registers a component for
"task-card-tags", WHEN any kanban card renders, THEN that component mounts in its own row (distinct from the title row hosting"task-card-indicators"), receiving exactly{ taskId, workspaceId, workflowStepId }for that card. GIVEN no plugin is registered for the slot, WHEN a card renders, THEN no extra DOM node or empty-row spacing appears. GIVEN two plugins register for"task-card-tags", WHEN a card renders, THEN both render, in registration order. GIVEN a"task-card-tags"component throws during render, WHEN that card renders, THEN the card's title and its other slot components (e.g."task-card-indicators") still render, isolated by the existing per-registration error boundary. -
GIVEN a plugin registers a component for
"sidebar-workspace-actions", WHEN the sidebar's New Task row renders with the rail expanded and a workspace active, THEN that component mounts in the row's action cluster after the built-in Quick Terminal and Quick Chat icons, receiving{ workspaceId, workspaceLabel, presentation: "desktop" }for the active workspace, and the flex row keeps the label and every registered action reachable without overlap. GIVEN no plugin is registered for the slot, WHEN the row renders, THEN no plugin action markup or extra row spacing appears. GIVEN the desktop rail is collapsed or no workspace is active, WHEN the desktop row renders, THEN no"sidebar-workspace-actions"markup renders, matching the built-in icons' own visibility. GIVEN a"sidebar-workspace-actions"component throws during render, WHEN the row renders, THEN Quick Terminal and Quick Chat still render and function, isolated by the existing per-registration error boundary. GIVEN the same plugin is active on a phone, WHEN the shared navigation sheet opens, THEN the component mounts withpresentation: "mobile"and its control remains reachable with a 44px touch target.
Out of scope
- Remote / operator-hosted plugin tier. The earlier
base_urlregistration 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 and built on top of this spec's install pipeline.
- Mandatory package signing.
checksums.txt.sigverification 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 in the base v1 runtime. The original parallel
tools[]plusInvokeToolpath remains removed. Plugin-contributed agent tools are a separate extension specified in Plugin-Contributed Agent Tools and feed through Kandev's existing MCP surface rather than a second discovery or invocation protocol. - 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.
- Event admission rate limiting. No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually. Diagnostic log aggregation is still required for bounded event-buffer overflow warnings.
- 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.
SessionCodeStatsis computed on demand per request in v1; a materialized or cached aggregation is future work. - New plugin contribution hooks or storage contracts. This hardening does not add
registration methods, change the
plugin_user_stateschema, alter user-state HTTP routes or WS payloads, or broaden the rich-text wrappers. - A general mobile navigation redesign. The bounded Panels picker applies only to plugin-contributed task panels and reuses the existing task-mobile picker pattern.
- 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).