feat(plugins): add voice extraction host APIs (#2548)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -144,6 +144,30 @@ func TestEnabledModePATAuthenticates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabledModePATAuthenticatesPluginWebhook(t *testing.T) {
|
||||
svc := newTestService(t, true)
|
||||
setupAdmin(t, svc)
|
||||
_, pat, err := svc.MintToken(context.Background(), userstore.DefaultUserID, "plugin-ui", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := newTestRouter(svc)
|
||||
router.POST("/api/plugins/:id/webhooks/:key", func(c *gin.Context) {
|
||||
if _, ok := authn.FromGin(c); !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "no identity"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
rec := doRequest(router, http.MethodPost, "/api/plugins/p1/webhooks/transcribe", func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer "+pat)
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated plugin webhook: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnabledModeAllowlistMatrix pins the pass/deny policy for every path
|
||||
// class from the plan's allowlist spec. 404 = middleware passed through
|
||||
// (route unregistered), 401 = middleware denied.
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/auth/authn"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/pkgtar"
|
||||
"github.com/kandev/kandev/internal/plugins/store"
|
||||
"github.com/kandev/kandev/pkg/pluginsdk"
|
||||
@@ -24,7 +26,7 @@ import (
|
||||
// use to exhaust backend memory. 4 MiB comfortably covers realistic webhook
|
||||
// payloads (GitHub/Slack/Jira event bodies are KB-sized) while bounding
|
||||
// worst-case memory use per request.
|
||||
const maxWebhookBodyBytes = 4 << 20 // 4 MiB
|
||||
const maxWebhookBodyBytes = manifest.DefaultWebhookMaxBodyBytes
|
||||
|
||||
// Controller holds the plugin HTTP handlers: operator-facing management
|
||||
// (install/list/get/config/uninstall/enable/disable), the bundle/UI
|
||||
@@ -372,12 +374,19 @@ func (c *Controller) webhook(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
key := ctx.Param("key")
|
||||
if !manifestDeclaresWebhookKey(record, key) {
|
||||
declaration, ok := findWebhookDeclaration(record, key)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("plugin %q has no webhook %q", id, key)})
|
||||
return
|
||||
}
|
||||
if declaration.EffectiveAccess() == manifest.WebhookAccessAuthenticated {
|
||||
if _, authenticated := authn.FromGin(ctx); !authenticated {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := readCappedWebhookBody(ctx)
|
||||
body, err := readCappedWebhookBody(ctx, declaration.EffectiveMaxBodyBytes())
|
||||
if err != nil {
|
||||
return // readCappedWebhookBody already wrote the error response
|
||||
}
|
||||
@@ -386,7 +395,7 @@ func (c *Controller) webhook(ctx *gin.Context) {
|
||||
WebhookKey: key,
|
||||
Method: ctx.Request.Method,
|
||||
Query: ctx.Request.URL.RawQuery,
|
||||
Headers: flattenHeaders(ctx.Request.Header),
|
||||
Headers: flattenWebhookHeaders(ctx.Request.Header),
|
||||
Body: body,
|
||||
}
|
||||
|
||||
@@ -479,13 +488,18 @@ func (c *Controller) applyAuthLogin(ctx *gin.Context, record *store.Record, raw
|
||||
|
||||
// manifestDeclaresWebhookKey reports whether record's manifest declares a
|
||||
// webhooks[] entry with the given key.
|
||||
func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
|
||||
func findWebhookDeclaration(record *store.Record, key string) (manifest.Webhook, bool) {
|
||||
for _, wh := range record.Webhooks {
|
||||
if wh.Key == key {
|
||||
return true
|
||||
return wh, true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return manifest.Webhook{}, false
|
||||
}
|
||||
|
||||
func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
|
||||
_, ok := findWebhookDeclaration(record, key)
|
||||
return ok
|
||||
}
|
||||
|
||||
// readCappedWebhookBody reads ctx.Request.Body bounded at
|
||||
@@ -493,14 +507,14 @@ func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
|
||||
// itself (and returning a non-nil error as a sentinel to the caller) when
|
||||
// the body exceeds the cap, so a single external webhook POST cannot
|
||||
// exhaust backend memory via an unbounded io.ReadAll.
|
||||
func readCappedWebhookBody(ctx *gin.Context) ([]byte, error) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxWebhookBodyBytes)
|
||||
func readCappedWebhookBody(ctx *gin.Context, maxBytes int64) ([]byte, error) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxBytes)
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
ctx.JSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||
"error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxWebhookBodyBytes),
|
||||
"error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxBytes),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
@@ -521,3 +535,10 @@ func flattenHeaders(h http.Header) map[string]string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenWebhookHeaders(h http.Header) map[string]string {
|
||||
safe := h.Clone()
|
||||
safe.Del("Authorization")
|
||||
safe.Del("Cookie")
|
||||
return flattenHeaders(safe)
|
||||
}
|
||||
|
||||
@@ -387,6 +387,33 @@ runtime:
|
||||
return &buf
|
||||
}
|
||||
|
||||
func authenticatedWebhookPackage(t *testing.T, id, key string, maxBodyBytes int64) *bytes.Buffer {
|
||||
t.Helper()
|
||||
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
||||
manifestYAML := fmt.Sprintf(`
|
||||
id: %s
|
||||
api_version: 1
|
||||
version: "1.0.0"
|
||||
display_name: Test Plugin
|
||||
webhooks:
|
||||
- key: %s
|
||||
access: authenticated
|
||||
max_body_bytes: %d
|
||||
runtime:
|
||||
type: binary
|
||||
executables:
|
||||
%s: server/plugin
|
||||
`, id, key, maxBodyBytes, platformKey)
|
||||
var buf bytes.Buffer
|
||||
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
||||
"manifest.yaml": []byte(manifestYAML),
|
||||
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
||||
}); err != nil {
|
||||
t.Fatalf("WritePackage: %v", err)
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestWebhookHandlerUnknownPluginReturns404(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
rec := doRequest(router, http.MethodPost, "/api/plugins/missing/webhooks/key1", "{}", nil)
|
||||
@@ -420,13 +447,56 @@ func TestWebhookHandlerOversizedBodyReturns413(t *testing.T) {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
|
||||
oversized := strings.Repeat("a", maxWebhookBodyBytes+1)
|
||||
oversized := strings.Repeat("a", int(maxWebhookBodyBytes+1))
|
||||
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", oversized, nil)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedWebhookRejectsAnonymousRequest(t *testing.T) {
|
||||
router, svc := newTestRouter(t)
|
||||
if _, err := svc.Install(t.Context(), authenticatedWebhookPackage(t, "kandev-plugin-voice", "transcribe", 16<<20)); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-voice/webhooks/transcribe", "audio", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCappedWebhookBodyUsesDeclaredLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345"))
|
||||
_, err := readCappedWebhookBody(ctx, 4)
|
||||
if err == nil || rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("error = %v, status = %d, want size error and 413", err, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookRelayHeadersStripHostCredentials(t *testing.T) {
|
||||
headers := http.Header{
|
||||
"Authorization": []string{"Bearer kandev-pat"},
|
||||
"Cookie": []string{"kandev_session=secret"},
|
||||
"Content-Type": []string{"audio/webm"},
|
||||
"X-Plugin-Key": []string{"plugin-secret"},
|
||||
}
|
||||
|
||||
got := flattenWebhookHeaders(headers)
|
||||
|
||||
if _, ok := got["Authorization"]; ok {
|
||||
t.Fatal("Authorization header was forwarded to plugin")
|
||||
}
|
||||
if _, ok := got["Cookie"]; ok {
|
||||
t.Fatal("Cookie header was forwarded to plugin")
|
||||
}
|
||||
if got["Content-Type"] != "audio/webm" || got["X-Plugin-Key"] != "plugin-secret" {
|
||||
t.Fatalf("relay-safe headers = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandlerNotRunningReturns503(t *testing.T) {
|
||||
router, svc := newTestRouter(t)
|
||||
if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil {
|
||||
|
||||
@@ -111,9 +111,32 @@ type AuthProvider struct {
|
||||
|
||||
// Webhook is a proxied external webhook endpoint the plugin declares.
|
||||
type Webhook struct {
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Method string `yaml:"method,omitempty" json:"method,omitempty"`
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Method string `yaml:"method,omitempty" json:"method,omitempty"`
|
||||
Access string `yaml:"access,omitempty" json:"access,omitempty"`
|
||||
MaxBodyBytes int64 `yaml:"max_body_bytes,omitempty" json:"max_body_bytes,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
WebhookAccessPublic = "public"
|
||||
WebhookAccessAuthenticated = "authenticated"
|
||||
DefaultWebhookMaxBodyBytes int64 = 4 << 20
|
||||
MaximumWebhookMaxBodyBytes int64 = 16 << 20
|
||||
)
|
||||
|
||||
func (w Webhook) EffectiveAccess() string {
|
||||
if w.Access == "" {
|
||||
return WebhookAccessPublic
|
||||
}
|
||||
return w.Access
|
||||
}
|
||||
|
||||
func (w Webhook) EffectiveMaxBodyBytes() int64 {
|
||||
if w.MaxBodyBytes == 0 {
|
||||
return DefaultWebhookMaxBodyBytes
|
||||
}
|
||||
return w.MaxBodyBytes
|
||||
}
|
||||
|
||||
// UISection declares UI pages the plugin contributes, and/or a native UI
|
||||
|
||||
@@ -455,6 +455,36 @@ func TestParseKeybindingCombo_RejectsNoNonModifierKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAccessAndBodyLimitValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
webhook Webhook
|
||||
wantErr string
|
||||
}{
|
||||
{name: "legacy defaults", webhook: Webhook{Key: "transcribe"}},
|
||||
{name: "authenticated 16 MiB", webhook: Webhook{Key: "transcribe", Access: "authenticated", MaxBodyBytes: 16 << 20}},
|
||||
{name: "public over default ceiling", webhook: Webhook{Key: "transcribe", Access: "public", MaxBodyBytes: (4 << 20) + 1}, wantErr: "authenticated"},
|
||||
{name: "unknown access", webhook: Webhook{Key: "transcribe", Access: "private"}, wantErr: "access"},
|
||||
{name: "over host ceiling", webhook: Webhook{Key: "transcribe", MaxBodyBytes: (16 << 20) + 1}, wantErr: "max_body_bytes"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := validManifest(t)
|
||||
m.Webhooks = []Webhook{tt.webhook}
|
||||
err := m.Validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeybindingCombo_RejectsUnknownModifier(t *testing.T) {
|
||||
err := parseKeybindingCombo("hyper+k")
|
||||
if err == nil {
|
||||
|
||||
@@ -395,6 +395,15 @@ func (m *Manifest) validateWebhooks() []error {
|
||||
seen := make(map[string]bool, len(m.Webhooks))
|
||||
var errs []error
|
||||
for _, wh := range m.Webhooks {
|
||||
if access := wh.EffectiveAccess(); access != WebhookAccessPublic && access != WebhookAccessAuthenticated {
|
||||
errs = append(errs, fmt.Errorf("webhook %q access %q is invalid", wh.Key, wh.Access))
|
||||
}
|
||||
if wh.MaxBodyBytes < 0 || wh.MaxBodyBytes > MaximumWebhookMaxBodyBytes {
|
||||
errs = append(errs, fmt.Errorf("webhook %q max_body_bytes must be between 1 and %d when set", wh.Key, MaximumWebhookMaxBodyBytes))
|
||||
}
|
||||
if wh.EffectiveAccess() == WebhookAccessPublic && wh.MaxBodyBytes > DefaultWebhookMaxBodyBytes {
|
||||
errs = append(errs, fmt.Errorf("webhook %q must use authenticated access when max_body_bytes exceeds %d", wh.Key, DefaultWebhookMaxBodyBytes))
|
||||
}
|
||||
if seen[wh.Key] {
|
||||
errs = append(errs, fmt.Errorf("duplicate webhook key %q", wh.Key))
|
||||
continue
|
||||
|
||||
@@ -247,6 +247,9 @@ func Serve(p Plugin, opts ...Option) {
|
||||
hcplugin.Serve(&hcplugin.ServeConfig{
|
||||
HandshakeConfig: Handshake,
|
||||
Plugins: map[string]hcplugin.Plugin{PluginMapKey: gp},
|
||||
GRPCServer: hcplugin.DefaultGRPCServer,
|
||||
GRPCServer: func(options []grpc.ServerOption) *grpc.Server {
|
||||
// The webhook HTTP body may be 16 MiB; leave room for protobuf metadata.
|
||||
return grpc.NewServer(append(options, grpc.MaxRecvMsgSize(17<<20))...)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeDisabledReason,
|
||||
isNativeSubmitDisabled,
|
||||
resolveDisabledReason,
|
||||
REASON_NO_COMPATIBLE_AGENT,
|
||||
REASON_TITLE,
|
||||
@@ -47,6 +48,20 @@ function makeProps(
|
||||
};
|
||||
}
|
||||
|
||||
describe("isNativeSubmitDisabled", () => {
|
||||
it("matches pending uploads and other externally blocked native submissions", () => {
|
||||
expect(isNativeSubmitDisabled(makeProps({ submitBlockedReason: "upload pending" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches missing-profile rejection for the start-task path", () => {
|
||||
expect(isNativeSubmitDisabled(makeProps({ agentProfileId: "" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a complete native submission", () => {
|
||||
expect(isNativeSubmitDisabled(makeProps())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDisabledReason (start-task)", () => {
|
||||
it("returns null when nothing is missing", () => {
|
||||
expect(computeDisabledReason(makeProps(), KIND_START)).toBeNull();
|
||||
|
||||
@@ -357,6 +357,12 @@ function computeFooterState(props: TaskCreateDialogFooterProps) {
|
||||
return { showStartTask, splitDisabled, altDisabled, defaultDisabled, disabledReason };
|
||||
}
|
||||
|
||||
export function isNativeSubmitDisabled(props: TaskCreateDialogFooterProps): boolean {
|
||||
const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props);
|
||||
if (props.isTaskStarted) return props.isCreatingTask || !props.hasTitle;
|
||||
return showStartTask ? splitDisabled : defaultDisabled;
|
||||
}
|
||||
|
||||
export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
|
||||
props: TaskCreateDialogFooterProps,
|
||||
) {
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("DialogPromptSection (CLI-mode parity)", () => {
|
||||
|
||||
it("forwards onVoiceAutoSend to TaskFormInputs", () => {
|
||||
taskFormInputsCalls.length = 0;
|
||||
const onVoiceAutoSend = () => {};
|
||||
const onVoiceAutoSend = () => true;
|
||||
render(
|
||||
<DialogPromptSection
|
||||
isSessionMode={false}
|
||||
|
||||
@@ -377,7 +377,7 @@ export type DialogPromptSectionProps = {
|
||||
*/
|
||||
autoFocusDescription?: boolean;
|
||||
/** Called after a non-empty voice transcript is inserted and auto-send is on. */
|
||||
onVoiceAutoSend?: () => void;
|
||||
onVoiceAutoSend?: () => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
// importBindings collapses the optional Jira/Linear import callbacks into the
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { TaskFormInputs } from "./task-create-dialog-selectors";
|
||||
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
|
||||
import type { PluginComposerSlotProps } from "@/lib/plugins/types";
|
||||
|
||||
const TOAST_MESSAGE_TEST_ID = "toast-message";
|
||||
|
||||
@@ -32,6 +33,7 @@ type VoiceProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
const voiceCalls: VoiceProps[] = [];
|
||||
const pluginSlotCalls: PluginComposerSlotProps[] = [];
|
||||
vi.mock("@/components/task/chat/voice-input-button", () => ({
|
||||
VoiceInputButton: (props: VoiceProps) => {
|
||||
voiceCalls.push(props);
|
||||
@@ -39,6 +41,13 @@ vi.mock("@/components/task/chat/voice-input-button", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/plugins/plugin-slot", () => ({
|
||||
PluginSlot: ({ slotProps }: { slotProps: PluginComposerSlotProps }) => {
|
||||
pluginSlotCalls.push(slotProps);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
// Inert mention popover — the real hook installs a `keydown` listener that
|
||||
// drains React's event queue across re-renders and adds noise to assertions.
|
||||
vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
||||
@@ -60,10 +69,17 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
voiceCalls.length = 0;
|
||||
pluginSlotCalls.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(processFile).mockReset();
|
||||
});
|
||||
|
||||
function lastPluginSlotProps(): PluginComposerSlotProps {
|
||||
const last = pluginSlotCalls.at(-1);
|
||||
if (!last) throw new Error("PluginSlot was not rendered");
|
||||
return last;
|
||||
}
|
||||
|
||||
function lastVoiceProps(): VoiceProps {
|
||||
const last = voiceCalls.at(-1);
|
||||
if (!last) throw new Error("VoiceInputButton was not rendered");
|
||||
@@ -158,6 +174,44 @@ describe("TaskFormInputs voice-input wiring — rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskFormInputs plugin composer submission", () => {
|
||||
it("reports blocked when the native creation path rejects submission", async () => {
|
||||
const ref = createRef<TaskFormInputsHandle>();
|
||||
render(
|
||||
<TaskFormInputs
|
||||
isSessionMode={false}
|
||||
autoFocus={false}
|
||||
initialDescription="prompt"
|
||||
onDescriptionChange={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
descriptionValueRef={ref}
|
||||
onVoiceAutoSend={() => false}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "blocked" });
|
||||
});
|
||||
|
||||
it("reports submitted only when the native creation path accepts submission", async () => {
|
||||
const ref = createRef<TaskFormInputsHandle>();
|
||||
render(
|
||||
<TaskFormInputs
|
||||
isSessionMode={false}
|
||||
autoFocus={false}
|
||||
initialDescription="prompt"
|
||||
onDescriptionChange={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
descriptionValueRef={ref}
|
||||
onVoiceAutoSend={() => true}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "submitted" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskFormInputs voice-input wiring — at-cursor splice", () => {
|
||||
it("splices the transcript at the caret with a leading space after a word", () => {
|
||||
const { textarea } = renderTaskFormInputs("hello world");
|
||||
|
||||
@@ -42,6 +42,9 @@ import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { createPluginComposerCapability } from "@/lib/plugins/composer-capability";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
const CURSOR_POINTER_CLASS = "cursor-pointer";
|
||||
|
||||
@@ -330,6 +333,7 @@ export const InlineTaskName = memo(function InlineTaskName({
|
||||
// Memoized description input to prevent re-rendering the entire dialog on every keystroke
|
||||
type TaskFormInputsProps = {
|
||||
isSessionMode: boolean;
|
||||
taskId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
autoFocus?: boolean;
|
||||
initialDescription: string;
|
||||
@@ -357,7 +361,7 @@ type TaskFormInputsProps = {
|
||||
* when the user has voice auto-send enabled. The dialog wires this to a
|
||||
* programmatic form submit so dictation can create the task hands-free.
|
||||
*/
|
||||
onVoiceAutoSend?: () => void;
|
||||
onVoiceAutoSend?: () => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
@@ -697,6 +701,7 @@ type FormInputsToolbarProps = {
|
||||
onTranscript: (text: string) => void;
|
||||
onAutoSend?: () => void;
|
||||
};
|
||||
pluginActions?: React.ReactNode;
|
||||
};
|
||||
|
||||
function FormInputsToolbar({
|
||||
@@ -708,6 +713,7 @@ function FormInputsToolbar({
|
||||
jiraImport,
|
||||
linearImport,
|
||||
voice,
|
||||
pluginActions,
|
||||
}: FormInputsToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center px-1 pb-1">
|
||||
@@ -733,6 +739,7 @@ function FormInputsToolbar({
|
||||
onImport={linearImport.onImport}
|
||||
/>
|
||||
)}
|
||||
{pluginActions}
|
||||
{voice && (
|
||||
<div className="ml-auto flex items-center">
|
||||
<VoiceInputButton
|
||||
@@ -766,6 +773,53 @@ function PromptMentionPopover({
|
||||
);
|
||||
}
|
||||
|
||||
function useCreationComposerPluginActions(args: {
|
||||
isSessionMode: boolean;
|
||||
taskId: string | null;
|
||||
disabled: boolean;
|
||||
description: string;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
insertAtCursor: (text: string) => void;
|
||||
submit?: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const handle = useMemo(
|
||||
() =>
|
||||
createPluginComposerCapability({
|
||||
insertText: (text) => {
|
||||
args.insertAtCursor(text);
|
||||
return true;
|
||||
},
|
||||
focus: () => {
|
||||
if (!args.textareaRef.current) return false;
|
||||
args.textareaRef.current.focus();
|
||||
return true;
|
||||
},
|
||||
submit: async () => {
|
||||
if (args.disabled || !args.description.trim() || !args.submit) return false;
|
||||
return await args.submit();
|
||||
},
|
||||
}),
|
||||
[args],
|
||||
);
|
||||
useEffect(() => () => handle.revoke(), [handle]);
|
||||
return (
|
||||
<PluginSlot
|
||||
name={args.isSessionMode ? "new-session-input-actions" : "task-create-input-actions"}
|
||||
slotProps={{
|
||||
surface: args.isSessionMode ? "new-session" : "task-create",
|
||||
presentation: isMobile ? "mobile" : "desktop",
|
||||
taskId: args.taskId,
|
||||
activeSessionId: null,
|
||||
sessionIds: [],
|
||||
disabled: args.disabled,
|
||||
submittable: !args.disabled && args.description.trim().length > 0,
|
||||
composer: handle.api,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useTextareaHandlers(
|
||||
mention: ReturnType<typeof useTaskCreatePromptMention>,
|
||||
onKeyDown: TaskFormInputsProps["onKeyDown"],
|
||||
@@ -830,6 +884,8 @@ function DraggingOverlay({ isDragging }: { isDragging: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The input coordinates existing attachment, mention, voice, and plugin controls in one field.
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
workspaceId,
|
||||
isSessionMode,
|
||||
@@ -847,6 +903,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
jiraImport,
|
||||
linearImport,
|
||||
onVoiceAutoSend,
|
||||
taskId = null,
|
||||
}: TaskFormInputsProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -884,6 +941,15 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
() => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }),
|
||||
[insertAtCursor, onVoiceAutoSend],
|
||||
);
|
||||
const pluginActions = useCreationComposerPluginActions({
|
||||
isSessionMode,
|
||||
taskId,
|
||||
disabled: Boolean(disabled),
|
||||
description,
|
||||
textareaRef,
|
||||
insertAtCursor,
|
||||
submit: onVoiceAutoSend,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -923,6 +989,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
jiraImport={jiraImport}
|
||||
linearImport={linearImport}
|
||||
voice={voiceBinding}
|
||||
pluginActions={pluginActions}
|
||||
/>
|
||||
<HiddenFileInput inputRef={fileInputRef} onChange={handleFileInputChange} />
|
||||
</div>
|
||||
|
||||
@@ -505,5 +505,5 @@ export type DialogFormBodyProps = {
|
||||
* routes this to a programmatic form submit so dictation can create the task
|
||||
* hands-free.
|
||||
*/
|
||||
onVoiceAutoSend?: () => void;
|
||||
onVoiceAutoSend?: () => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ vi.mock("@/hooks/use-keyboard-shortcut", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task-create-dialog-footer", () => ({
|
||||
isNativeSubmitDisabled: () => false,
|
||||
TaskCreateDialogFooter: () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
|
||||
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
|
||||
import {
|
||||
isNativeSubmitDisabled,
|
||||
TaskCreateDialogFooter,
|
||||
} from "@/components/task-create-dialog-footer";
|
||||
import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog";
|
||||
import { DialogHeaderContent } from "@/components/task-create-dialog-header";
|
||||
import {
|
||||
@@ -286,14 +289,19 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
}
|
||||
resetQueuedLastUsedOnClose();
|
||||
}, [props.open, resetQueuedLastUsedOnClose]);
|
||||
// Voice auto-send invokes the same submit handler as the in-form Submit
|
||||
// button. Every existing validation gate (missing title/repo/branch/agent,
|
||||
// `submitBlockedReason`, in-flight create) still applies because they live
|
||||
// inside `handleSubmit` itself, so a dictation with incomplete fields
|
||||
// silently no-ops rather than creating a malformed task.
|
||||
// Programmatic submissions use the native control's preflight before
|
||||
// entering the same guarded submit handler as the form button.
|
||||
const pendingAttachmentReason = setup.fs.hasPendingAttachmentUploads
|
||||
? t("chat:attachmentUploadPendingSubmit")
|
||||
: null;
|
||||
const nativeSubmitDisabled = isNativeSubmitDisabled(
|
||||
buildDialogFooterProps(setup, props, pendingAttachmentReason),
|
||||
);
|
||||
const handleVoiceAutoSend = useCallback(() => {
|
||||
if (nativeSubmitDisabled) return false;
|
||||
guardedHandleSubmit(VOICE_SUBMIT_EVENT);
|
||||
}, [guardedHandleSubmit]);
|
||||
return true;
|
||||
}, [guardedHandleSubmit, nativeSubmitDisabled]);
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -323,13 +331,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
/>
|
||||
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
|
||||
<TaskCreateDialogFooter
|
||||
{...buildDialogFooterProps(
|
||||
setup,
|
||||
props,
|
||||
setup.fs.hasPendingAttachmentUploads
|
||||
? t("chat:attachmentUploadPendingSubmit")
|
||||
: null,
|
||||
)}
|
||||
{...buildDialogFooterProps(setup, props, pendingAttachmentReason)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,6 +13,8 @@ import type { ContextItem } from "@/lib/types/context";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
import { createPluginComposerCapability } from "@/lib/plugins/composer-capability";
|
||||
import type { PluginComposerCapability } from "@/lib/plugins/types";
|
||||
|
||||
export type ChatInputEditorAreaProps = {
|
||||
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
|
||||
@@ -129,6 +131,41 @@ function FileInput({
|
||||
);
|
||||
}
|
||||
|
||||
function useChatPluginComposer(p: {
|
||||
inputRef: ChatInputEditorAreaProps["inputRef"];
|
||||
submitDisabled: boolean;
|
||||
isEnhancingPrompt: boolean;
|
||||
onSubmit: () => void;
|
||||
}): PluginComposerCapability {
|
||||
const handle = useMemo(
|
||||
() =>
|
||||
createPluginComposerCapability({
|
||||
insertText: (text) => {
|
||||
const editor = p.inputRef.current;
|
||||
if (!editor) return false;
|
||||
const from = editor.getSelectionStart();
|
||||
const prefix = from > 0 && !/\s/.test(editor.getValue().charAt(from - 1)) ? " " : "";
|
||||
editor.insertText(prefix + text.trim(), from, editor.getSelectionEnd());
|
||||
editor.focus();
|
||||
return true;
|
||||
},
|
||||
focus: () => {
|
||||
if (!p.inputRef.current) return false;
|
||||
p.inputRef.current.focus();
|
||||
return true;
|
||||
},
|
||||
submit: async () => {
|
||||
if (p.isEnhancingPrompt || p.submitDisabled) return false;
|
||||
p.onSubmit();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
[p.inputRef, p.isEnhancingPrompt, p.onSubmit, p.submitDisabled],
|
||||
);
|
||||
useEffect(() => () => handle.revoke(), [handle]);
|
||||
return handle.api;
|
||||
}
|
||||
|
||||
export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p;
|
||||
@@ -148,6 +185,13 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
|
||||
const hasContent = value.trim().length > 0 || userContextCount > 0;
|
||||
// Block submit while enhancing prompt, but keep editor editable for programmatic updates
|
||||
const wrappedSubmit = isEnhancingPrompt || p.submitDisabled ? () => {} : handleSubmitWithReset;
|
||||
|
||||
const composerCapability = useChatPluginComposer({
|
||||
inputRef,
|
||||
submitDisabled: p.submitDisabled,
|
||||
isEnhancingPrompt: Boolean(isEnhancingPrompt),
|
||||
onSubmit: handleSubmitWithReset,
|
||||
});
|
||||
const submitDisabledReason = p.hasPendingAttachmentUploads
|
||||
? t("chat:attachmentUploadPendingSubmit")
|
||||
: p.submitDisabledReason;
|
||||
@@ -200,6 +244,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
|
||||
isSending={isSending}
|
||||
onCancel={onCancel}
|
||||
onSubmit={wrappedSubmit}
|
||||
composerCapability={composerCapability}
|
||||
composerSurface={taskId ? "task-chat" : "quick-chat"}
|
||||
submitKey={submitKey}
|
||||
contextCount={contextCount}
|
||||
contextPopoverOpen={contextPopoverOpen}
|
||||
|
||||
@@ -5,6 +5,17 @@ import { useOptionalAppStore } from "@/components/state-provider";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import type {
|
||||
PluginComposerCapability,
|
||||
PluginComposerSurface,
|
||||
PluginPresentation,
|
||||
} from "@/lib/plugins/types";
|
||||
|
||||
const UNAVAILABLE_COMPOSER: PluginComposerCapability = {
|
||||
insertText: () => ({ status: "unavailable" }),
|
||||
focus: () => ({ status: "unavailable" }),
|
||||
submit: async () => ({ status: "unavailable" }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Props forwarded to every plugin component registered for the
|
||||
@@ -27,6 +38,12 @@ export type ChatInputActionsSlotProps = {
|
||||
activeSessionId: string | null;
|
||||
/** Every kandev session id on the task (includes `activeSessionId`). */
|
||||
sessionIds: string[];
|
||||
surface: PluginComposerSurface;
|
||||
presentation: PluginPresentation;
|
||||
disabled: boolean;
|
||||
submittable: boolean;
|
||||
disabledReason?: string;
|
||||
composer: PluginComposerCapability;
|
||||
};
|
||||
|
||||
const EMPTY_SESSIONS: TaskSession[] = [];
|
||||
@@ -42,6 +59,12 @@ export function ChatInputPluginActions(props: {
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
surface?: "task-chat" | "quick-chat";
|
||||
presentation?: PluginPresentation;
|
||||
disabled?: boolean;
|
||||
submittable?: boolean;
|
||||
disabledReason?: string;
|
||||
composer?: PluginComposerCapability;
|
||||
}) {
|
||||
const { sessionId, taskId, taskTitle } = props;
|
||||
// itemsByTaskId holds a stable per-task array reference (updated only when
|
||||
@@ -60,8 +83,19 @@ export function ChatInputPluginActions(props: {
|
||||
// The active session may not yet be in the store list (freshly prepared);
|
||||
// make sure the plugin always receives it.
|
||||
if (sessionId && !sessionIds.includes(sessionId)) sessionIds.unshift(sessionId);
|
||||
return { taskId, taskTitle, activeSessionId: sessionId, sessionIds };
|
||||
}, [taskSessions, sessionId, taskId, taskTitle]);
|
||||
return {
|
||||
taskId,
|
||||
taskTitle,
|
||||
activeSessionId: sessionId,
|
||||
sessionIds,
|
||||
surface: props.surface ?? (taskId ? "task-chat" : "quick-chat"),
|
||||
presentation: props.presentation ?? "desktop",
|
||||
disabled: props.disabled ?? false,
|
||||
submittable: props.submittable ?? false,
|
||||
disabledReason: props.disabledReason,
|
||||
composer: props.composer ?? UNAVAILABLE_COMPOSER,
|
||||
};
|
||||
}, [taskSessions, sessionId, taskId, taskTitle, props]);
|
||||
|
||||
return <PluginSlot name="chat-input-actions" slotProps={slotProps} />;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ function DesktopRightSection(props: {
|
||||
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
|
||||
onVoiceTranscript?: (text: string) => void;
|
||||
onVoiceAutoSend?: () => void;
|
||||
composerCapability?: ChatInputToolbarProps["composerCapability"];
|
||||
composerSurface?: ChatInputToolbarProps["composerSurface"];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
@@ -176,6 +178,12 @@ function DesktopRightSection(props: {
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
|
||||
presentation="desktop"
|
||||
disabled={props.isDisabled}
|
||||
submittable={!props.isDisabled && props.hasContent}
|
||||
disabledReason={props.submitDisabledReason}
|
||||
composer={props.composerCapability}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
import type { PluginComposerCapability } from "@/lib/plugins/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
type MobileToolbarProps = {
|
||||
@@ -57,6 +58,8 @@ type MobileToolbarProps = {
|
||||
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
|
||||
onVoiceTranscript?: (text: string) => void;
|
||||
onVoiceAutoSend?: () => void;
|
||||
composerCapability?: PluginComposerCapability;
|
||||
composerSurface?: "task-chat" | "quick-chat";
|
||||
};
|
||||
|
||||
function mobileContextButton(contextCount: number) {
|
||||
@@ -177,6 +180,12 @@ export function MobileChatInputToolbar(props: MobileToolbarProps) {
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
|
||||
presentation="mobile"
|
||||
disabled={props.isDisabled}
|
||||
submittable={!props.isDisabled && props.hasContent}
|
||||
disabledReason={props.submitDisabledReason}
|
||||
composer={props.composerCapability}
|
||||
/>
|
||||
)}
|
||||
{props.onVoiceTranscript && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
import type { PluginComposerCapability } from "@/lib/plugins/types";
|
||||
|
||||
export type ChatInputToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
@@ -61,6 +62,8 @@ export type ChatInputToolbarProps = {
|
||||
hideAgentControls?: boolean;
|
||||
/** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */
|
||||
hidePlanMode?: boolean;
|
||||
composerCapability?: PluginComposerCapability;
|
||||
composerSurface?: "task-chat" | "quick-chat";
|
||||
};
|
||||
|
||||
function MinimalToolbar({
|
||||
|
||||
@@ -40,6 +40,7 @@ export type TipTapInputHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getSelectionStart: () => number;
|
||||
getSelectionEnd: () => number;
|
||||
getValue: () => string;
|
||||
setValue: (value: string) => void;
|
||||
clear: () => void;
|
||||
@@ -497,6 +498,7 @@ function useEditorImperativeHandle(
|
||||
focus: () => editor?.commands.focus(),
|
||||
blur: () => editor?.commands.blur(),
|
||||
getSelectionStart: () => editor?.state.selection.from ?? 0,
|
||||
getSelectionEnd: () => editor?.state.selection.to ?? 0,
|
||||
getValue: () => (editor ? getMarkdownText(editor) : ""),
|
||||
setValue: (v: string) => {
|
||||
if (!editor) return;
|
||||
|
||||
@@ -444,6 +444,7 @@ function NewSessionForm({
|
||||
/>
|
||||
<TaskFormInputs
|
||||
isSessionMode
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
autoFocus
|
||||
initialDescription=""
|
||||
@@ -468,8 +469,11 @@ function NewSessionForm({
|
||||
isEnhancingPrompt={isEnhancingPrompt}
|
||||
isUtilityConfigured={isUtilityConfigured}
|
||||
onVoiceAutoSend={() => {
|
||||
if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) return;
|
||||
if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) {
|
||||
return false;
|
||||
}
|
||||
void handleSubmit(VOICE_SUBMIT_EVENT);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<PromptResultRecovery
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPluginComposerCapability } from "./composer-capability";
|
||||
|
||||
describe("createPluginComposerCapability", () => {
|
||||
it("fails closed after revocation", async () => {
|
||||
const insertText = vi.fn(() => true);
|
||||
const focus = vi.fn(() => true);
|
||||
const submit = vi.fn(async () => true);
|
||||
const capability = createPluginComposerCapability({ insertText, focus, submit });
|
||||
|
||||
capability.revoke();
|
||||
|
||||
expect(capability.api.insertText("hello")).toEqual({ status: "unavailable" });
|
||||
expect(capability.api.focus()).toEqual({ status: "unavailable" });
|
||||
await expect(capability.api.submit()).resolves.toEqual({ status: "unavailable" });
|
||||
expect(insertText).not.toHaveBeenCalled();
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps native operations to public outcomes", async () => {
|
||||
const capability = createPluginComposerCapability({
|
||||
insertText: () => true,
|
||||
focus: () => true,
|
||||
submit: async () => false,
|
||||
});
|
||||
|
||||
expect(capability.api.insertText("hello")).toEqual({ status: "inserted" });
|
||||
expect(capability.api.focus()).toEqual({ status: "focused" });
|
||||
await expect(capability.api.submit()).resolves.toEqual({ status: "blocked" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginComposerCapability } from "./types";
|
||||
|
||||
type NativeComposerOperations = {
|
||||
insertText(text: string): boolean;
|
||||
focus(): boolean;
|
||||
submit(): Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createPluginComposerCapability(operations: NativeComposerOperations): {
|
||||
api: PluginComposerCapability;
|
||||
revoke(): void;
|
||||
} {
|
||||
let active = true;
|
||||
|
||||
return {
|
||||
api: {
|
||||
insertText(text) {
|
||||
if (!active) return { status: "unavailable" };
|
||||
if (!text.trim()) return { status: "ignored" };
|
||||
return { status: operations.insertText(text) ? "inserted" : "unavailable" };
|
||||
},
|
||||
focus() {
|
||||
if (!active) return { status: "unavailable" };
|
||||
return { status: operations.focus() ? "focused" : "unavailable" };
|
||||
},
|
||||
async submit() {
|
||||
if (!active) return { status: "unavailable" };
|
||||
return (await operations.submit()) ? { status: "submitted" } : { status: "blocked" };
|
||||
},
|
||||
},
|
||||
revoke() {
|
||||
active = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -128,6 +128,32 @@ export type WsHandler = (payload: unknown) => void;
|
||||
/** Presentation context a task panel or kanban menu action renders under. */
|
||||
export type PluginPresentation = "desktop" | "mobile";
|
||||
|
||||
export type PluginComposerSurface = "task-chat" | "quick-chat" | "task-create" | "new-session";
|
||||
|
||||
export type PluginComposerSubmitResult =
|
||||
| { status: "submitted" }
|
||||
| { status: "blocked"; reason?: string }
|
||||
| { status: "unavailable" };
|
||||
|
||||
export interface PluginComposerCapability {
|
||||
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
|
||||
focus(): { status: "focused" | "unavailable" };
|
||||
submit(): Promise<PluginComposerSubmitResult>;
|
||||
}
|
||||
|
||||
export interface PluginComposerSlotProps {
|
||||
surface: PluginComposerSurface;
|
||||
presentation: PluginPresentation;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
activeSessionId: string | null;
|
||||
sessionIds: string[];
|
||||
disabled: boolean;
|
||||
submittable: boolean;
|
||||
disabledReason?: string;
|
||||
composer: PluginComposerCapability;
|
||||
}
|
||||
|
||||
/** Props passed to a `TaskPanelRegistration.Component`. */
|
||||
export interface PluginTaskPanelProps {
|
||||
/** This registration's panel id, so one Component can back multiple panels. */
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-2026-08-11-composer-access-authenticated-webhooks: Composer Access and Authenticated Webhooks
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-11
|
||||
**Area:** frontend, backend, protocol, security
|
||||
|
||||
## Context
|
||||
|
||||
Voice Mode currently reaches private editor handles and native submit callbacks in four composer
|
||||
families, while plugin slots receive identifiers only. Plugin UI already calls its backend through
|
||||
webhooks, but declarations cannot distinguish public integration ingress from authenticated Kandev UI
|
||||
calls and all requests share a 4 MiB cap. Voice transcription needs authenticated UI access and at
|
||||
least 10 MiB while keeping its OpenAI key in plugin-owned secret settings.
|
||||
|
||||
## Decision
|
||||
|
||||
Kandev exposes a typed, mounted-composer capability only through composer slot props. The capability
|
||||
adapts insertion, focus, state, and submission to the owning surface's existing editor/form code; it
|
||||
does not expose draft contents or let plugins synthesize outbound messages. Task chat, Quick Chat,
|
||||
task creation, and new-session creation each supply an adapter to the same public shape.
|
||||
|
||||
Kandev extends each webhook declaration with `access: public|authenticated` and
|
||||
`max_body_bytes`. Existing declarations default to `public` and 4 MiB. Authenticated webhooks require
|
||||
the normal Kandev identity and same-origin browser checks; their configured limit may be raised to a
|
||||
16 MiB host ceiling. Both modes continue through the existing `HandleWebhook` RPC, with request
|
||||
cancellation propagated through its context.
|
||||
|
||||
Plugin localization is deferred to a separate prerequisite. It is required before core Voice Mode
|
||||
can be removed, but it is independent of composer access and authenticated backend invocation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Plugins can augment native composers without duplicating message construction or form behavior. A
|
||||
plugin UI can use the existing webhook API for a larger authenticated upload without adding a route,
|
||||
frontend API, protobuf method, or token lifecycle. Plugin authors must deliberately classify each
|
||||
webhook; older manifests retain their current behavior.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Expose drafts and editor handles through `host.store`.** Rejected because drafts are local React
|
||||
state, editor implementations differ, and a global mutable handle would outlive or target the wrong
|
||||
composer during navigation.
|
||||
- **Let plugins send messages through `api_write:messages`.** Rejected because it bypasses the native
|
||||
submit and form behavior this extraction must preserve.
|
||||
- **Add a separate browser-action route and RPC.** Rejected because access mode and body size are
|
||||
properties of the existing UI-to-plugin webhook relay; a second transport duplicates it.
|
||||
- **Raise every webhook to 16 MiB.** Rejected because existing public webhook callers do not need the
|
||||
larger exposure. Limits remain declaration-specific and bounded by the host ceiling.
|
||||
- **Add host localization in this package.** Rejected because locale/catalog ownership is independent
|
||||
of the two host blockers addressed here and can be designed as a separate prerequisite.
|
||||
@@ -107,6 +107,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
|
||||
| 2026-08-01-gitlab-mr-lifecycle-notifications | [GitLab MR Lifecycle Notifications](2026-08-01-gitlab-mr-lifecycle-notifications.md) | accepted | backend, frontend | 2026-08-01 |
|
||||
| 2026-08-01-per-user-plugin-storage | [Host-Provided Per-User Plugin Storage](2026-08-01-per-user-plugin-storage.md) | accepted | backend, frontend, security | 2026-08-01 |
|
||||
| 2026-08-11-composer-access-authenticated-webhooks | [Composer Access and Authenticated Webhooks](2026-08-11-composer-access-authenticated-webhooks.md) | accepted | frontend, backend, protocol, security | 2026-08-11 |
|
||||
| 2026-08-01-plugin-task-panel-contributions | [Plugin Task Panel, Kanban Menu Action, and Card Indicator Contributions](2026-08-01-plugin-task-panel-contributions.md) | accepted | frontend | 2026-08-01 |
|
||||
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
|
||||
| 2026-08-02-explicit-user-cancel-completion | [Explicit User Cancellation May Complete a Workflow Step](2026-08-02-explicit-user-cancel-completion.md) | accepted | workflow | 2026-08-02 |
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
spec: docs/specs/plugins/voice-extraction-host.md
|
||||
created: 2026-08-11
|
||||
status: building
|
||||
---
|
||||
|
||||
# Implementation Plan: Voice Plugin Host Prerequisites
|
||||
|
||||
## Overview
|
||||
|
||||
Add host-owned composer adapters to every Voice Mode surface, then extend existing webhooks with
|
||||
per-declaration access and request-size policies. Publish the contract through frontend types,
|
||||
manifest validation, the frozen plugin API, and public authoring documentation.
|
||||
Keep core Voice Mode installed throughout; a separate plugin repository will consume these APIs and
|
||||
only a later parity task may remove core code.
|
||||
|
||||
## Contract Decisions
|
||||
|
||||
- Composer state stays local to each mounted surface. Slot props expose current disabled/submittable
|
||||
state plus insertion, focus, and native submit without exposing draft contents or private refs.
|
||||
- `chat-input-actions` grows compatible props and covers task chat plus Quick Chat. Creation forms get
|
||||
explicit `task-create-input-actions` and `new-session-input-actions` slots because their identifiers,
|
||||
validation, and submit lifecycles differ.
|
||||
- Plugin UI continues to use `host.api.fetch("webhooks/<key>")` and the existing `HandleWebhook` RPC.
|
||||
`webhooks[].access` selects public or authenticated invocation and `max_body_bytes` sets a bounded
|
||||
per-key limit up to 16 MiB; old manifests retain public/4 MiB defaults.
|
||||
- Plugin localization is a separate prerequisite. It must be resolved before core Voice Mode is
|
||||
removed, but this package adds no host i18n API.
|
||||
|
||||
## Frontend Composer Architecture
|
||||
|
||||
- Add public types in `apps/web/lib/plugins/types.ts` and mirror them in
|
||||
`docs/plans/plugins/PLUGIN-API.md`. Implement adapters guarded by the mounted composer generation;
|
||||
normal React props update disabled/submittable presentation without draft state entering Zustand.
|
||||
- Extend `TipTapInputHandle` only where required for the host adapter. Reuse its selection-aware
|
||||
`insertText`, but centralize the Voice spacing rule so textarea and TipTap adapters behave identically.
|
||||
- Adapt `useChatInputState`/`ChatInputArea` to expose native `handleSubmit` and the same disabled gate
|
||||
to `ChatInputPluginActions`. Do not call `useMessageHandler` or construct `ChatSubmitPayload` from
|
||||
plugin code. Quick Chat inherits the adapter through its existing `ChatInputArea` composition.
|
||||
- Extend `TaskFormInputsHandle` with guarded focus/insertion operations or provide a sibling adapter
|
||||
around `useDescriptionInput`. Task creation invokes its existing form submit handler; new-session
|
||||
creation invokes its existing `handleSubmit`, including profile, upload, and busy-state gates.
|
||||
- Keep slot controls in the existing toolbars on desktop. On native mobile, use the existing composer
|
||||
toolbar/overflow composition and 44px touch targets; do not introduce a separate drawer solely for
|
||||
plugin actions.
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
- **Outcome and entry points:** Voice plugin actions remain beside native composer controls in task
|
||||
chat and inside the prompt toolbar for task/new-session creation; Quick Chat uses its existing input
|
||||
toolbar. All actions are reachable by touch.
|
||||
- **Exemplars:** task chat reuses `chat-input-toolbar-mobile.tsx` and
|
||||
`mobile-voice-mode.spec.ts`; new session reuses the full-height dialog in
|
||||
`mobile-new-session-dialog.spec.ts`; task creation reuses the existing responsive Task Create dialog.
|
||||
- **Hierarchy and action:** the plugin action is secondary to the native submit action and never hides
|
||||
submit, model/profile selection, or cancellation. Recording/transcription state belongs
|
||||
to the plugin control; the native composer remains the single draft and submit owner.
|
||||
- **Surface rationale:** frequent, one-tap input augmentation belongs inline in the existing toolbar,
|
||||
not behind navigation or a new drawer. Existing dialog and composer scroll owners remain unchanged;
|
||||
overlays and toolbars keep safe-area clearance and no document horizontal overflow.
|
||||
- **Shared behavior:** capability state and native adapters are shared across presentations. Only the
|
||||
existing toolbar placement differs. Pixel 5 Playwright proves insertion plus native submission in
|
||||
task chat, task creation, and new-session creation; Quick Chat mobile coverage is included if its
|
||||
shipped mobile entry exposes the full toolbar.
|
||||
|
||||
## Backend Authenticated Webhook Architecture
|
||||
|
||||
- Extend manifest `Webhook` with `Access` and `MaxBodyBytes`. Validate `access` as
|
||||
`public|authenticated`, default it to `public`, default the limit to 4 MiB, reject non-positive or
|
||||
>16 MiB values, and preserve those values through install/registry serialization.
|
||||
- Keep the existing webhook URL and `HandleWebhook` RPC. In the handler, resolve the declared key
|
||||
before reading the body, apply its effective limit, and require normal Kandev identity plus accepted
|
||||
Origin for authenticated cookie requests. Authentication-disabled mode uses the synthetic user.
|
||||
- Propagate `Request.Context()` cancellation through `Service.InvokeWebhook`; bound and sanitize the
|
||||
response as today. Logs may add access mode and byte count but never request content or secrets.
|
||||
- Keep existing public webhooks byte-for-byte compatible: no authentication requirement and a 4 MiB
|
||||
default. The Voice plugin stores its OpenAI key through existing secret config fields; no host key
|
||||
storage or new secret API is introduced.
|
||||
|
||||
## Localization And Compatibility
|
||||
|
||||
- Add optional manifest fields without changing plugin `api_version: 1`. Document that consumers must
|
||||
declare the first supporting `min_kandev_version`; older hosts fail installation instead of falling
|
||||
back to webhooks.
|
||||
- Update `docs/specs/plugins/spec.md`, `docs/plans/plugins/PLUGIN-API.md`,
|
||||
`docs/public/plugins-authoring.md`, and `docs/public/plugins-manifest.md` in the same contract task.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Frontend unit/component tests prove selection replacement, spacing, focus, live state props,
|
||||
stale-adapter failure, native submit delegation, blocked submission, cleanup, Quick Chat props, and
|
||||
desktop/mobile slot placement across all composer families.
|
||||
- Backend manifest tests prove defaults, validation, and compatibility. Handler/service tests prove
|
||||
authenticated/public access, Origin enforcement, 10 MiB success, >16 MiB rejection, cancellation,
|
||||
disabled/runtime failure, and unchanged legacy webhook behavior.
|
||||
- Desktop and mobile Playwright install or inject a fixture plugin action, insert a distinctive string
|
||||
at a non-terminal selection, invoke native submit, and assert the resulting task/message/session.
|
||||
A backend-integrated E2E action uploads >10 MiB only where the container/runtime fixture is available;
|
||||
focused Go integration tests remain the deterministic size/security proof.
|
||||
|
||||
## Implementation Waves And Tasks
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Publish composer contract](task-01-publish-composer-contract.md)
|
||||
- [x] [Task 02: Extend webhook declarations](task-02-authenticated-webhook-manifest.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 03: Adapt chat and Quick Chat composers](task-03-chat-composer-adapters.md) (depends on Task 01)
|
||||
- [x] [Task 04: Adapt creation composers](task-04-creation-composer-adapters.md) (depends on Task 01)
|
||||
- [x] [Task 05: Enforce authenticated webhooks](task-05-authenticated-webhook-handler.md) (depends on Task 02)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [ ] [Task 06: Document and verify extraction prerequisites](task-06-docs-e2e-verification.md) (depends on Tasks 03-05)
|
||||
|
||||
Tasks in the same wave are parallel-safe only where their owned files remain disjoint. Execution stays
|
||||
sequential unless the user explicitly authorizes subagents.
|
||||
|
||||
## Risks
|
||||
|
||||
- A capability that snapshots React state can auto-submit after a gate changes; `submit()` must
|
||||
revalidate the native gate at call time.
|
||||
- React Strict Mode or a task/session switch can leave stale plugin closures alive; generation guards
|
||||
must make them fail closed rather than target the newly mounted editor.
|
||||
- Conditional authentication inside a route currently treated as public must fail closed per declared
|
||||
key; direct handler and middleware-boundary tests are required.
|
||||
- Raising gRPC message limits globally would unnecessarily affect other plugin traffic; configure only
|
||||
the existing webhook call path sufficiently for the 16 MiB host ceiling.
|
||||
- Core Voice removal in this package would make rollback and parity validation unsafe; it is explicitly
|
||||
deferred to the plugin implementation/extraction task.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
make -C apps/backend test
|
||||
make -C apps/backend lint
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/chat components/task-create-dialog-selectors.test.tsx components/task/new-session-dialog.test.tsx
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
make build-web && make build-backend
|
||||
cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts
|
||||
cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: "01-publish-composer-contract"
|
||||
title: "Publish composer contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 01: Publish Composer Contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public plugin types define composer surface/state/capability/results exactly as the
|
||||
spec, with backward-compatible additions to existing slot props.
|
||||
- Internal capability lifecycle utilities make stale/unmounted capabilities
|
||||
fail closed without exposing draft values or editor handles.
|
||||
- Host API tests cover teardown, prop updates, and stale capability behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/plugins/host-api.test.ts lib/plugins/host-lifecycle.test.ts lib/plugins/host.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
Follow RED-GREEN-REFACTOR: add contract/lifecycle failures first, then the smallest public types and
|
||||
host wiring. Do not add composer surface adapters in this task.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/lib/plugins/types.ts`
|
||||
- `apps/web/lib/plugins/host-api.ts`
|
||||
- `apps/web/lib/plugins/host.ts`
|
||||
- focused tests beside those files
|
||||
- a new internal composer-capability utility and test
|
||||
- `docs/plans/plugins/PLUGIN-API.md`
|
||||
|
||||
## Inputs And Risks
|
||||
|
||||
- Spec API Surface and ADR mounted-capability decision.
|
||||
- Avoid a Zustand draft slice, public editor handle, or snapshot-only state. Strict Mode teardown must
|
||||
not notify or mutate after revocation.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: "02-authenticated-webhook-manifest"
|
||||
title: "Extend webhook declarations"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 02: Extend Webhook Declarations
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Manifest webhooks support optional `access: public|authenticated` and `max_body_bytes`, with legacy
|
||||
declarations resolving to public access and a 4 MiB limit.
|
||||
- Validation rejects unknown access modes, public limits above 4 MiB, and authenticated limits above
|
||||
16 MiB, while preserving existing key uniqueness and method behavior.
|
||||
- Install, registry, and JSON/YAML round-trip tests preserve the new fields. Protobuf and the Go plugin
|
||||
SDK remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/plugins/manifest ./internal/plugins
|
||||
```
|
||||
|
||||
Follow RED-GREEN-REFACTOR, beginning with legacy-default and invalid-limit cases.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/plugins/manifest/*`
|
||||
- `apps/backend/internal/plugins/types.go`
|
||||
- plugin install/registry serialization tests
|
||||
|
||||
## Inputs And Risks
|
||||
|
||||
- Spec authenticated-webhook API and compatibility section.
|
||||
- Do not reinterpret omitted fields during YAML/JSON round trips in a way that breaks old packages.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: "03-chat-composer-adapters"
|
||||
title: "Adapt chat and Quick Chat composers"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-publish-composer-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 03: Adapt Chat And Quick Chat Composers
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop/mobile task chat and Quick Chat pass a live composer capability through
|
||||
`chat-input-actions`, with correct surface, presentation, task, and session metadata.
|
||||
- Insertion replaces the active TipTap selection with native spacing/focus semantics; submit delegates
|
||||
to the existing draft state machine and preserves native submission behavior.
|
||||
- Unit tests cover blocked, stale, task/session-switch, and task-less Quick Chat cases.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/task/chat lib/plugins components/quick-chat
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
Follow RED-GREEN-REFACTOR. Keep `useChatInputState` and `useSubmitHandler` as the only submit owners.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/components/task/chat/chat-input-plugin-actions.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-desktop.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-area.tsx`
|
||||
- `apps/web/components/task/chat/use-chat-input-state.ts`
|
||||
- `apps/web/components/task/chat/use-tiptap-editor.ts`
|
||||
- focused chat and Quick Chat tests
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
Reuse the native mobile toolbar and its scroll/overflow behavior. The plugin action remains secondary
|
||||
to send/cancel, has a >=44px touch target, and receives the same capability semantics as desktop.
|
||||
|
||||
## Risks
|
||||
|
||||
- Fast insertion then submit must read the updated value ref before React effects.
|
||||
- An old session capability must return `unavailable`, never insert into the newly active session.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: "04-creation-composer-adapters"
|
||||
title: "Adapt creation composers"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-publish-composer-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 04: Adapt Creation Composers
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Task creation and new-session creation render their declared plugin action slots on desktop and
|
||||
native mobile with live prompt state and relevant identifiers.
|
||||
- Selection insertion reuses one spacing algorithm and restores focus/caret; plugin submit invokes the
|
||||
exact native form handler with existing busy and validation gates.
|
||||
- Tests cover selected-range replacement, blocked submit, stale dialog handles, successful native
|
||||
task/session creation delegation, and cleanup on close.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-selectors.test.tsx components/task-create-dialog.test.tsx components/task/new-session-dialog.test.tsx
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
Follow RED-GREEN-REFACTOR. Do not make Task Create or New Session send a chat message directly.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/components/task-create-dialog-types.ts`
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx`
|
||||
- `apps/web/components/task-create-dialog-form-body.tsx`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task/new-session-dialog.tsx`
|
||||
- new shared creation-composer plugin action component/helper
|
||||
- focused tests beside these files
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
Keep actions inside the existing prompt toolbar and full-height responsive dialogs. Preserve the
|
||||
dialog's scroll owner, safe-area behavior, primary submit hierarchy, and >=44px touch targets.
|
||||
|
||||
## Risks
|
||||
|
||||
- Programmatic form submission must not bypass native validation or submit while the form is blocked.
|
||||
- Task Create and New Session share input UI but have different submit ownership; avoid a false shared
|
||||
callback that drops either form's gates.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: "05-authenticated-webhook-handler"
|
||||
title: "Enforce authenticated webhooks"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["02-authenticated-webhook-manifest"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 05: Enforce Authenticated Webhooks
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The existing webhook handler enforces each declared key's access mode and effective body limit before
|
||||
invoking the existing `HandleWebhook` RPC.
|
||||
- Authenticated webhooks require Kandev identity and accepted Origin for cookie-authenticated browser
|
||||
calls; public webhooks retain their current unauthenticated behavior.
|
||||
- Integration tests cover legacy 4 MiB behavior, authenticated denial/success, Origin rejection,
|
||||
10 MiB multipart success, >16 MiB rejection, cancellation, disabled plugin, and runtime failure.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/plugins ./internal/auth/httpmw ./internal/backendapp
|
||||
make -C apps/backend lint
|
||||
```
|
||||
|
||||
Follow RED-GREEN-REFACTOR, beginning with direct handler and middleware-boundary security tests.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/plugins/handlers.go`
|
||||
- `apps/backend/internal/plugins/invoke.go` and focused tests if cancellation/size wiring needs changes
|
||||
- `apps/backend/internal/auth/httpmw/*`
|
||||
|
||||
## Inputs And Risks
|
||||
|
||||
- ADR conditional webhook access decision.
|
||||
- Because the webhook path is currently public at middleware level, handler authentication must use
|
||||
the canonical auth service and fail closed before reading or relaying the request body.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: "06-docs-e2e-verification"
|
||||
title: "Document and verify extraction prerequisites"
|
||||
status: pending
|
||||
wave: 3
|
||||
depends_on: ["03-chat-composer-adapters", "04-creation-composer-adapters", "05-authenticated-webhook-handler"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/voice-extraction-host.md"
|
||||
---
|
||||
|
||||
# Task 06: Document And Verify Extraction Prerequisites
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public authoring/manifest docs and the plugin spec describe composer slots, webhook access/size
|
||||
fields, min-version rules, cancellation, plugin-owned secret settings, and the separate localization
|
||||
prerequisite.
|
||||
- Desktop and Pixel 5 Playwright prove selection insertion plus native submit for task chat, task
|
||||
creation, and new session; Quick Chat is covered wherever its toolbar is exposed.
|
||||
- Core Voice Mode remains present and its focused unit/E2E coverage still passes, establishing a safe
|
||||
prerequisite release for the later external plugin task.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
make build-web && make build-backend
|
||||
cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts
|
||||
cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/chat/mobile-voice-mode.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `docs/specs/plugins/spec.md`
|
||||
- `docs/plans/plugins/PLUGIN-API.md`
|
||||
- `docs/public/plugins-authoring.md`
|
||||
- `docs/public/plugins-manifest.md`
|
||||
- `apps/web/e2e/tests/plugins/composer-actions.spec.ts`
|
||||
- `apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts`
|
||||
- fixture/plugin E2E helpers and existing Quick Chat/New Session specs where needed
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
Pixel 5 tests use the shipped task toolbar, Task Create dialog, and New Session full-height dialog.
|
||||
They assert the action is touch-reachable, native submission completes, the intended surface owns
|
||||
scrolling, and the document has no horizontal overflow.
|
||||
|
||||
## Risks
|
||||
|
||||
- A mock that calls the submit callback directly does not prove native behavior; E2E must interact
|
||||
through a registered fixture plugin slot and observe the created message/task/session.
|
||||
- Do not remove or weaken current Voice Mode tests. Extraction and core removal belong to the later
|
||||
dedicated plugin task.
|
||||
@@ -40,9 +40,10 @@ they must use `host.React` (NOT bundle their own React) to share the host instan
|
||||
```ts
|
||||
interface PluginHostApi {
|
||||
pluginId: string;
|
||||
React: typeof import("react"); // host React instance (shared)
|
||||
jsx: typeof React.createElement; // convenience alias (h)
|
||||
store: { // kandev app store (zustand StoreApi)
|
||||
React: typeof import("react"); // host React instance (shared)
|
||||
jsx: typeof React.createElement; // convenience alias (h)
|
||||
store: {
|
||||
// kandev app store (zustand StoreApi)
|
||||
getState(): AppState;
|
||||
setState(partial): void;
|
||||
subscribe(listener): () => void;
|
||||
@@ -56,7 +57,7 @@ interface PluginHostApi {
|
||||
// dev/desktop base URL from window internals.
|
||||
baseUrl: string;
|
||||
};
|
||||
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
|
||||
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
|
||||
// The resolved light/dark theme, read live on every access. `host` is built
|
||||
// once per plugin load, so copying this into a variable that outlives a
|
||||
// render freezes it; read it during render, and pair it with onThemeChange
|
||||
@@ -89,7 +90,11 @@ interface PluginHostApi {
|
||||
storage: PluginStorageApi;
|
||||
}
|
||||
|
||||
interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; }
|
||||
interface PluginStorageEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PluginStorageSetOptions {
|
||||
// Optimistic-concurrency guard: the updatedAt the caller last read. The
|
||||
@@ -115,20 +120,37 @@ interface PluginStorageSetOptions {
|
||||
}
|
||||
|
||||
// Mirrors the backend's user-state route scopes.
|
||||
type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";
|
||||
type PluginStorageScope =
|
||||
| "instance"
|
||||
| "workspace"
|
||||
| "task"
|
||||
| "session"
|
||||
| "repository";
|
||||
|
||||
interface PluginStorageApi {
|
||||
get(scope: PluginStorageScope, scopeId: string, key: string): Promise<PluginStorageEntry | undefined>;
|
||||
get(
|
||||
scope: PluginStorageScope,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
): Promise<PluginStorageEntry | undefined>;
|
||||
set(
|
||||
scope: PluginStorageScope, scopeId: string, key: string, value: unknown,
|
||||
scope: PluginStorageScope,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
options?: PluginStorageSetOptions,
|
||||
): Promise<{ updatedAt: string }>;
|
||||
delete(
|
||||
scope: PluginStorageScope, scopeId: string, key: string,
|
||||
scope: PluginStorageScope,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
options?: Pick<PluginStorageSetOptions, "writerId">,
|
||||
): Promise<void>;
|
||||
// Every entry under (scope, scopeId), ordered by key. Not paginated.
|
||||
list(scope: PluginStorageScope, scopeId: string): Promise<PluginStorageEntry[]>;
|
||||
list(
|
||||
scope: PluginStorageScope,
|
||||
scopeId: string,
|
||||
): Promise<PluginStorageEntry[]>;
|
||||
// Subscribes to live updates for this plugin's own storage made from
|
||||
// another tab, device, or surface — e.g. the kanban Edit modal and the
|
||||
// task panel both editing the same document. filter.scope/scopeId/key
|
||||
@@ -145,7 +167,12 @@ interface PluginStorageApi {
|
||||
// it would incorrectly suppress each other's legitimate writes as if they
|
||||
// were one surface's own echo.
|
||||
subscribe(
|
||||
filter: { scope?: PluginStorageScope; scopeId?: string; key?: string; writerId?: string },
|
||||
filter: {
|
||||
scope?: PluginStorageScope;
|
||||
scopeId?: string;
|
||||
key?: string;
|
||||
writerId?: string;
|
||||
},
|
||||
handler: (change: PluginUserStateChange) => void,
|
||||
): () => void;
|
||||
}
|
||||
@@ -159,10 +186,10 @@ interface PluginUserStateChange {
|
||||
}
|
||||
|
||||
interface PluginModalOptions {
|
||||
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
|
||||
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
|
||||
content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract
|
||||
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
|
||||
dismissible?: boolean; // overlay click / Escape close the modal; default true
|
||||
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
|
||||
dismissible?: boolean; // overlay click / Escape close the modal; default true
|
||||
}
|
||||
|
||||
interface PluginModalHandle {
|
||||
@@ -261,7 +288,11 @@ interface RichTextEditorProps {
|
||||
testId?: string;
|
||||
}
|
||||
// RichTextReadOnly: renders markdown read-only, no taskId dependency.
|
||||
interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; }
|
||||
interface RichTextReadOnlyProps {
|
||||
value: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Deliberately narrow — not the plan editor's `comments`, `onSelectionChange`,
|
||||
@@ -324,16 +355,22 @@ own write).
|
||||
// "integrations" renders inside the sidebar's Integrations section alongside
|
||||
// the first-party integration links (GitHub, Jira, ...). Hosts predating a
|
||||
// section value simply don't render items targeting it (additive change).
|
||||
interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; }
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
section?: "main" | "settings" | "integrations";
|
||||
}
|
||||
|
||||
// Configuration for the kandev-style title bar the host renders above a plugin
|
||||
// route. All fields optional; defaults are derived (see registerRoute below).
|
||||
interface PluginPageChrome {
|
||||
title?: string; // default: nav-item label for the same path, else plugin name
|
||||
subtitle?: string; // muted text next to the title
|
||||
icon?: string; // curated icon name; default: matching nav item's icon
|
||||
backHref?: string; // back-link target (host default "/")
|
||||
backLabel?: string; // back-link label (host default "Kandev")
|
||||
title?: string; // default: nav-item label for the same path, else plugin name
|
||||
subtitle?: string; // muted text next to the title
|
||||
icon?: string; // curated icon name; default: matching nav item's icon
|
||||
backHref?: string; // back-link target (host default "/")
|
||||
backLabel?: string; // back-link label (host default "Kandev")
|
||||
actions?: React.ComponentType; // rendered on the right side of the topbar
|
||||
}
|
||||
|
||||
@@ -349,7 +386,11 @@ interface PluginRegistry {
|
||||
// required for v1 — exact + startsWith("/plugins/{id}") allowed). The host wraps the
|
||||
// page in kandev chrome (PageTopbar + scrollable content area) by default —
|
||||
// configure or opt out via options.topbar.
|
||||
registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;
|
||||
registerRoute(
|
||||
path: string,
|
||||
Component: React.ComponentType,
|
||||
options?: PluginRouteOptions,
|
||||
): void;
|
||||
|
||||
// Sidebar/main nav entry. Rendered by <PluginNavItems/> in the app sidebar,
|
||||
// and by <MobilePluginNavSection/> in the phone menu sheet (the sidebar is
|
||||
@@ -398,7 +439,10 @@ interface PluginRegistry {
|
||||
// registered by the plugin currently being viewed, so your card appears on
|
||||
// your own settings page and never on another plugin's — no per-id gating
|
||||
// needed in your component.
|
||||
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
|
||||
registerComponent(
|
||||
slot: string,
|
||||
Component: React.ComponentType<{ slotProps?: unknown }>,
|
||||
): void;
|
||||
|
||||
// WS action handler. Bridged into the existing lib/ws dispatch; called with the
|
||||
// decoded message payload for that action string.
|
||||
@@ -442,6 +486,32 @@ interface PluginRegistry {
|
||||
|
||||
type PluginPresentation = "desktop" | "mobile";
|
||||
|
||||
type PluginComposerSurface =
|
||||
| "task-chat"
|
||||
| "quick-chat"
|
||||
| "task-create"
|
||||
| "new-session";
|
||||
interface PluginComposerCapability {
|
||||
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
|
||||
focus(): { status: "focused" | "unavailable" };
|
||||
submit(): Promise<{
|
||||
status: "submitted" | "blocked" | "unavailable";
|
||||
reason?: string;
|
||||
}>;
|
||||
}
|
||||
interface PluginComposerSlotProps {
|
||||
surface: PluginComposerSurface;
|
||||
presentation: PluginPresentation;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
activeSessionId: string | null;
|
||||
sessionIds: string[];
|
||||
disabled: boolean;
|
||||
submittable: boolean;
|
||||
disabledReason?: string;
|
||||
composer: PluginComposerCapability;
|
||||
}
|
||||
|
||||
interface PluginTaskPanelProps {
|
||||
panelId: string; // this registration's panel id, so one Component can back multiple panels
|
||||
taskId: string;
|
||||
@@ -450,9 +520,9 @@ interface PluginTaskPanelProps {
|
||||
}
|
||||
|
||||
interface TaskPanelRegistration {
|
||||
id: string; // plugin-local panel id (unique within the plugin, not globally)
|
||||
title: string; // add-panel-menu row label and dockview tab title
|
||||
icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts)
|
||||
id: string; // plugin-local panel id (unique within the plugin, not globally)
|
||||
title: string; // add-panel-menu row label and dockview tab title
|
||||
icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts)
|
||||
Component: React.ComponentType<PluginTaskPanelProps>; // wrapped in a PluginErrorBoundary
|
||||
mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false.
|
||||
}
|
||||
@@ -488,7 +558,7 @@ interface PluginTaskFilterOption {
|
||||
}
|
||||
|
||||
interface TaskFilterRegistration {
|
||||
id: string; // plugin-local filter id (unique within the plugin, not globally)
|
||||
id: string; // plugin-local filter id (unique within the plugin, not globally)
|
||||
label: string; // filter section label shown in the dropdown
|
||||
getOptions(): PluginTaskFilterOption[];
|
||||
// Called only when `selected` is non-empty — an empty selection is
|
||||
@@ -652,8 +722,10 @@ defaults, or the bare component when the route opted out (`topbar: false`).
|
||||
The chat composer toolbar
|
||||
(`components/task/chat/chat-input-toolbar-desktop.tsx` and
|
||||
`-mobile.tsx`, via `chat-input-plugin-actions.tsx`) hosts the
|
||||
`chat-input-actions` slot, passing
|
||||
`{ taskId, taskTitle, activeSessionId, sessionIds }`. `kanban-card-plugin-slots.tsx`
|
||||
`chat-input-actions` slot for task and Quick Chat, passing the typed
|
||||
`PluginComposerSlotProps`. Task creation and new-session creation mount
|
||||
`task-create-input-actions` and `new-session-input-actions` with the same
|
||||
capability contract. `kanban-card-plugin-slots.tsx`
|
||||
hosts `task-card-indicators` beside `PRTaskIcon` and `task-card-tags` as its
|
||||
own row, both mounted from `kanban-card-content.tsx`'s `KanbanCardBody`.
|
||||
- `components/task/dockview-shared.tsx` / `dockview-panel-content.tsx` /
|
||||
|
||||
+149
-113
@@ -89,11 +89,11 @@ directory.
|
||||
|
||||
## Choose a plugin shape
|
||||
|
||||
| Shape | Package contents | Typical contract | Minimal capability set |
|
||||
| --- | --- | --- | --- |
|
||||
| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend |
|
||||
| Shape | Package contents | Typical contract | Minimal capability set |
|
||||
| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend |
|
||||
| UI-focused plugin | no-op managed binary, ui/bundle.js, optional styles/assets | routes, nav, named slots, WebSocket handlers, keybindings, shared store | ui.bundle; add ui.keybindings when declaring shortcuts |
|
||||
| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege |
|
||||
| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege |
|
||||
|
||||
There is no separate HTTP server to launch. pluginsdk.Serve owns the
|
||||
go-plugin/gRPC handshake and Host injection. The backend implements
|
||||
@@ -116,22 +116,23 @@ curated React, UI, and app-store surface.
|
||||
capabilities.events controls event delivery.
|
||||
- GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own
|
||||
config, including cleartext secret fields, so do not log or commit it.
|
||||
- Declared webhook keys are routable, but Kandev does not authenticate callers
|
||||
or enforce webhooks[].method. Validate the method, provider signature, and
|
||||
replay/timestamp rules inside the plugin before side effects.
|
||||
- Declared webhook keys default to public. Set `webhooks[].access: authenticated`
|
||||
for browser UI and billable operations. Kandev does not enforce
|
||||
`webhooks[].method`; public integrations must still validate the provider
|
||||
signature and replay/timestamp rules before side effects.
|
||||
- capabilities.auth is the highest-risk capability. A webhook response may
|
||||
assert a verified external identity with X-Kandev-Auth-Login; only assert an
|
||||
email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md).
|
||||
|
||||
## Storage decision table
|
||||
|
||||
| Need | Use | Scope/lifecycle | Capability or rule |
|
||||
| --- | --- | --- | --- |
|
||||
| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars |
|
||||
| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId |
|
||||
| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess |
|
||||
| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values |
|
||||
| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations |
|
||||
| Need | Use | Scope/lifecycle | Capability or rule |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars |
|
||||
| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId |
|
||||
| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess |
|
||||
| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values |
|
||||
| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations |
|
||||
|
||||
Host state is not host.storage: Host state is plugin-scoped backend state with
|
||||
no transaction primitive, so design updates to be idempotent; host.storage
|
||||
@@ -178,43 +179,45 @@ bookkeeping is host-internal; plugins do not call lifecycle methods.
|
||||
|
||||
### Frontend hook/API matrix
|
||||
|
||||
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
|
||||
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
|
||||
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
|
||||
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
|
||||
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
|
||||
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
|
||||
| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) |
|
||||
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) |
|
||||
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
|
||||
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
|
||||
| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) |
|
||||
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Abort/ignore requests after destroy; do not assume a webhook authenticates callers | host.api.fetch("webhooks/inbound", { method: "POST" }) |
|
||||
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) |
|
||||
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
|
||||
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
|
||||
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
|
||||
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
|
||||
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
|
||||
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
|
||||
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
|
||||
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
|
||||
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
|
||||
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
|
||||
| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) |
|
||||
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) |
|
||||
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
|
||||
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
|
||||
| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) |
|
||||
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; abort requests on destroy | host.api.fetch("webhooks/inbound", { method: "POST" }) |
|
||||
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) |
|
||||
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
|
||||
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
|
||||
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
|
||||
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
|
||||
|
||||
### Supported named slots
|
||||
|
||||
registerComponent currently has these mounted slots. The source type is open
|
||||
to strings, but an unmounted name renders nowhere.
|
||||
|
||||
| Slot | Mounted location | slotProps |
|
||||
| --- | --- | --- |
|
||||
| task-sidebar | Bottom of task-detail sidebar | none |
|
||||
| settings-nav | Settings navigation tree | none |
|
||||
| chat-input-actions | Chat composer toolbar | { taskId, taskTitle?, activeSessionId, sessionIds } |
|
||||
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
|
||||
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
|
||||
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
|
||||
| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } |
|
||||
| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } |
|
||||
| Slot | Mounted location | slotProps |
|
||||
| ------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| task-sidebar | Bottom of task-detail sidebar | none |
|
||||
| settings-nav | Settings navigation tree | none |
|
||||
| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps |
|
||||
| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps |
|
||||
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
|
||||
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
|
||||
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
|
||||
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
|
||||
| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } |
|
||||
| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } |
|
||||
|
||||
AppStatusBarSlotProps is { placement, presentation, density, pathname,
|
||||
activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a
|
||||
@@ -222,15 +225,21 @@ compact 24px bar; mobile presentation is an in-flow drawer, so render a
|
||||
touch-usable row. Status items can be reordered by the host; plugins do not get
|
||||
an ordering API.
|
||||
|
||||
`PluginComposerSlotProps` is `{ surface, presentation, taskId, taskTitle?,
|
||||
activeSessionId, sessionIds, disabled, submittable, disabledReason?, composer }`.
|
||||
The `composer` capability provides `insertText`, `focus`, and asynchronous
|
||||
`submit`; submit returns `submitted` only when the native composer accepts the
|
||||
operation, otherwise `blocked` or `unavailable`.
|
||||
|
||||
## Backend contract
|
||||
|
||||
### Plugin lifecycle surface
|
||||
|
||||
| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Plugin.OnEvent | OnEvent(ctx, *pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } |
|
||||
| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } |
|
||||
| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.<id>.<name> | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) |
|
||||
| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Plugin.OnEvent | OnEvent(ctx, \*pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } |
|
||||
| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } |
|
||||
| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.<id>.<name> | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) |
|
||||
|
||||
Event delivery is best-effort: Kandev makes the initial attempt plus retries
|
||||
with 5s, 15s, and 45s delays, and a bounded in-memory queue/error buffer can
|
||||
@@ -241,21 +250,21 @@ subscription vocabulary and wildcard rules are in the
|
||||
|
||||
### Host API matrix
|
||||
|
||||
| Host surface | Methods | Required manifest capability | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions |
|
||||
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
|
||||
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
|
||||
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
|
||||
| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire |
|
||||
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
|
||||
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
|
||||
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
|
||||
| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API |
|
||||
| Repositories | Repositories().List | api_read: repositories | List by workspace id |
|
||||
| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped |
|
||||
| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin:<id> author |
|
||||
| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition |
|
||||
| Host surface | Methods | Required manifest capability | Notes |
|
||||
| -------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions |
|
||||
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
|
||||
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
|
||||
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
|
||||
| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire |
|
||||
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
|
||||
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
|
||||
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
|
||||
| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API |
|
||||
| Repositories | Repositories().List | api_read: repositories | List by workspace id |
|
||||
| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped |
|
||||
| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin:<id> author |
|
||||
| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition |
|
||||
|
||||
The Go signatures, filters, DTOs, and pagination types live in
|
||||
apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message
|
||||
@@ -318,7 +327,12 @@ window.registerKandevPlugin("acme-dashboard", {
|
||||
return host.jsx("main", null, "Acme dashboard");
|
||||
}
|
||||
registry.registerRoute("/acme-dashboard", Page);
|
||||
registry.registerNavItem({ id: "home", label: "Acme", path: "/acme-dashboard", icon: "chart" });
|
||||
registry.registerNavItem({
|
||||
id: "home",
|
||||
label: "Acme",
|
||||
path: "/acme-dashboard",
|
||||
icon: "chart",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -388,34 +402,38 @@ function useNoteValue(taskId, panelId) {
|
||||
// duplicating the read-and-commit logic without its guards.
|
||||
const guardRef = host.React.useRef({ cancelled: false, generation: 0 });
|
||||
|
||||
const refresh = host.React.useCallback((options = {}) => {
|
||||
const preserveValue = options.preserveValue ?? dirtyRef.current;
|
||||
const guard = guardRef.current;
|
||||
const generation = ++guard.generation;
|
||||
return host.storage.get("task", taskId, "note").then(
|
||||
(entry) => {
|
||||
// Ignore a response that resolves after taskId changed, the panel
|
||||
// unmounted, or a newer refresh started — otherwise a stale read
|
||||
// (the previous task's note, or one of two overlapping reads that
|
||||
// resolved out of order) can land in the current field.
|
||||
if (guard.cancelled || generation !== guard.generation) return;
|
||||
if (!preserveValue && !dirtyRef.current) setValue(entry ? entry.value : "");
|
||||
updatedAtRef.current = entry ? entry.updatedAt : undefined;
|
||||
setLoadedTaskId(taskId);
|
||||
setReadError(false);
|
||||
return true;
|
||||
},
|
||||
() => {
|
||||
// Do not mark the task loaded after a rejected read. Rendering an
|
||||
// empty editor here would allow its first save to omit
|
||||
// ifUnmodifiedSince and overwrite an existing note. Keep the panel
|
||||
// in a retry state until an authoritative read succeeds.
|
||||
if (guard.cancelled || generation !== guard.generation) return false;
|
||||
setReadError(true);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}, [taskId]);
|
||||
const refresh = host.React.useCallback(
|
||||
(options = {}) => {
|
||||
const preserveValue = options.preserveValue ?? dirtyRef.current;
|
||||
const guard = guardRef.current;
|
||||
const generation = ++guard.generation;
|
||||
return host.storage.get("task", taskId, "note").then(
|
||||
(entry) => {
|
||||
// Ignore a response that resolves after taskId changed, the panel
|
||||
// unmounted, or a newer refresh started — otherwise a stale read
|
||||
// (the previous task's note, or one of two overlapping reads that
|
||||
// resolved out of order) can land in the current field.
|
||||
if (guard.cancelled || generation !== guard.generation) return;
|
||||
if (!preserveValue && !dirtyRef.current)
|
||||
setValue(entry ? entry.value : "");
|
||||
updatedAtRef.current = entry ? entry.updatedAt : undefined;
|
||||
setLoadedTaskId(taskId);
|
||||
setReadError(false);
|
||||
return true;
|
||||
},
|
||||
() => {
|
||||
// Do not mark the task loaded after a rejected read. Rendering an
|
||||
// empty editor here would allow its first save to omit
|
||||
// ifUnmodifiedSince and overwrite an existing note. Keep the panel
|
||||
// in a retry state until an authoritative read succeeds.
|
||||
if (guard.cancelled || generation !== guard.generation) return false;
|
||||
setReadError(true);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
},
|
||||
[taskId],
|
||||
);
|
||||
|
||||
host.React.useEffect(() => {
|
||||
const guard = guardRef.current;
|
||||
@@ -454,10 +472,15 @@ function useNoteValue(taskId, panelId) {
|
||||
}
|
||||
|
||||
function NotesPanel({ taskId, panelId }) {
|
||||
const { value, setValue, updatedAtRef, dirtyRef, loaded, readError, refresh } = useNoteValue(
|
||||
taskId,
|
||||
panelId,
|
||||
);
|
||||
const {
|
||||
value,
|
||||
setValue,
|
||||
updatedAtRef,
|
||||
dirtyRef,
|
||||
loaded,
|
||||
readError,
|
||||
refresh,
|
||||
} = useNoteValue(taskId, panelId);
|
||||
const [conflict, setConflict] = host.React.useState(false);
|
||||
const [writeError, setWriteError] = host.React.useState(false);
|
||||
const pendingValueRef = host.React.useRef(undefined);
|
||||
@@ -542,11 +565,13 @@ function NotesPanel({ taskId, panelId }) {
|
||||
setConflict(false);
|
||||
setWriteError(false);
|
||||
return () => {
|
||||
if (writeGenerationRef.current === generation) writeGenerationRef.current += 1;
|
||||
if (writeGenerationRef.current === generation)
|
||||
writeGenerationRef.current += 1;
|
||||
pendingValueRef.current = undefined;
|
||||
writeInFlightRef.current = false;
|
||||
writeBlockedRef.current = false;
|
||||
if (writeTimerRef.current !== undefined) clearTimeout(writeTimerRef.current);
|
||||
if (writeTimerRef.current !== undefined)
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = undefined;
|
||||
};
|
||||
}, [taskId, panelId]);
|
||||
@@ -574,7 +599,11 @@ function NotesPanel({ taskId, panelId }) {
|
||||
"div",
|
||||
null,
|
||||
"This note changed elsewhere. Your edit is preserved.",
|
||||
host.jsx("button", { type: "button", onClick: retryWrite }, "Retry my edit"),
|
||||
host.jsx(
|
||||
"button",
|
||||
{ type: "button", onClick: retryWrite },
|
||||
"Retry my edit",
|
||||
),
|
||||
)
|
||||
: writeError
|
||||
? host.jsx(
|
||||
@@ -587,7 +616,13 @@ function NotesPanel({ taskId, panelId }) {
|
||||
return host.jsx("div", null, editor, status);
|
||||
}
|
||||
|
||||
registry.registerTaskPanel({ id: "notes", title: "Notes", icon: "book", Component: NotesPanel, mobileEnabled: true });
|
||||
registry.registerTaskPanel({
|
||||
id: "notes",
|
||||
title: "Notes",
|
||||
icon: "book",
|
||||
Component: NotesPanel,
|
||||
mobileEnabled: true,
|
||||
});
|
||||
```
|
||||
|
||||
On a phone, all `mobileEnabled` panels appear under one **Panels** bottom-nav
|
||||
@@ -612,8 +647,10 @@ task, not for an unbounded per-item collection.
|
||||
### 4. Webhook receiver
|
||||
|
||||
Declare the route, validate the method and provider signature inside the
|
||||
backend, and return a bounded status/body. Kandev caps incoming bodies at 4 MiB
|
||||
but does not authenticate the caller.
|
||||
backend, and return a bounded status/body. Public webhooks are capped at 4 MiB.
|
||||
For plugin-UI operations, declare `access: authenticated`; those routes may
|
||||
raise `max_body_bytes` to 16 MiB. Kandev verifies the current user but strips
|
||||
its session cookie and PAT before relaying the remaining headers.
|
||||
|
||||
```yaml
|
||||
webhooks:
|
||||
@@ -717,7 +754,6 @@ An empty selection is implicit "All" for that section — `matches` is only
|
||||
called once at least one option is selected, and a `matches` that throws is
|
||||
caught, logged, and treated as non-matching for that task.
|
||||
|
||||
|
||||
## Build, package, install, and test
|
||||
|
||||
### Plugin repository
|
||||
@@ -788,12 +824,12 @@ instance; it removes the plugin package and KANDEV_PLUGIN_DATA_DIR.
|
||||
Reinstalling the same id/version is rejected. Bump version or uninstall before
|
||||
repackaging.
|
||||
|
||||
| Layer | Command/source | Checks | Does not check |
|
||||
| --- | --- | --- | --- |
|
||||
| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime |
|
||||
| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls |
|
||||
| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering |
|
||||
| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance |
|
||||
| Layer | Command/source | Checks | Does not check |
|
||||
| ------- | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime |
|
||||
| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls |
|
||||
| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering |
|
||||
| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance |
|
||||
|
||||
## Common mistakes
|
||||
|
||||
@@ -818,8 +854,8 @@ repackaging.
|
||||
uninstall.
|
||||
- **Importing direct DB/internal packages:** use apps/backend/pkg/pluginsdk and
|
||||
typed Host methods. Direct database access is outside the plugin contract.
|
||||
- **Trusting webhook metadata:** webhooks[].method is informational and the route
|
||||
is not authenticated by Kandev. Validate method, signature, timestamp, replay
|
||||
- **Trusting webhook metadata:** `webhooks[].method` is informational. Public
|
||||
routes are not authenticated by Kandev, so validate method, signature, timestamp, replay
|
||||
protection, and body before side effects.
|
||||
- **Bundling React:** use host.React, host.jsx, and host.ui; a second React or
|
||||
Radix copy breaks shared contexts and portals.
|
||||
|
||||
+122
-104
@@ -22,62 +22,78 @@ workflow and [Plugins](plugins.md) for install/operate.
|
||||
## Annotated example
|
||||
|
||||
```yaml
|
||||
id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$
|
||||
api_version: 1 # must be 1 (the only supported value)
|
||||
id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$
|
||||
api_version: 1 # must be 1 (the only supported value)
|
||||
version: "1.0.0"
|
||||
display_name: "Slack Notifications"
|
||||
description: "Post to Slack on task events, relay messages to agents"
|
||||
author: "kandev"
|
||||
categories: ["connector"] # connector | automation | tools | analytics
|
||||
repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins
|
||||
categories: ["connector"] # connector | automation | tools | analytics
|
||||
repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins
|
||||
|
||||
runtime:
|
||||
type: binary # only supported value today
|
||||
type: binary # only supported value today
|
||||
executables:
|
||||
linux-amd64: server/plugin-linux-amd64
|
||||
linux-arm64: server/plugin-linux-arm64
|
||||
darwin-amd64: server/plugin-darwin-amd64
|
||||
darwin-arm64: server/plugin-darwin-arm64
|
||||
windows-amd64: server/plugin-windows-amd64.exe
|
||||
# any subset; the host's <goos>-<goarch> key
|
||||
# is required at install time
|
||||
min_kandev_version: "0.78.0" # optional
|
||||
windows-amd64:
|
||||
server/plugin-windows-amd64.exe
|
||||
# any subset; the host's <goos>-<goarch> key
|
||||
# is required at install time
|
||||
min_kandev_version: "0.78.0" # optional
|
||||
|
||||
capabilities:
|
||||
events: ["task.created", "task.state_changed", "agent.completed"]
|
||||
api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs
|
||||
api_write: ["tasks"] # gates Tasks().Create/Update
|
||||
api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs
|
||||
api_write: ["tasks"] # gates Tasks().Create/Update
|
||||
state: true
|
||||
secrets: true
|
||||
agent_invoke: true # gates Host.InvokeUtilityAgent
|
||||
auth: true # gates external (OIDC/SAML) login — see ADR 0050
|
||||
user_state: true # gates host.storage (per-user browser storage)
|
||||
agent_invoke: true # gates Host.InvokeUtilityAgent
|
||||
auth: true # gates external (OIDC/SAML) login — see ADR 0050
|
||||
user_state: true # gates host.storage (per-user browser storage)
|
||||
|
||||
webhooks:
|
||||
- key: "slack-events"
|
||||
description: "Slack Events API webhook"
|
||||
method: "POST" # informational only, not enforced
|
||||
method: "POST" # informational only, not enforced
|
||||
access: public # public (default) or authenticated
|
||||
max_body_bytes: 4194304 # public maximum 4 MiB; authenticated maximum 16 MiB
|
||||
|
||||
auth_providers: # login buttons (needs capabilities.auth)
|
||||
auth_providers: # login buttons (needs capabilities.auth)
|
||||
- id: "google"
|
||||
display_name: "Google"
|
||||
initiate: "login-start" # names a webhook key above; the button navigates there
|
||||
initiate: "login-start" # names a webhook key above; the button navigates there
|
||||
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
bot_token: { type: string, secret: true, title: "Bot Token", description: "Slack bot OAuth token" }
|
||||
default_channel: { type: string, description: "Default channel for notifications" }
|
||||
bot_token:
|
||||
{
|
||||
type: string,
|
||||
secret: true,
|
||||
title: "Bot Token",
|
||||
description: "Slack bot OAuth token",
|
||||
}
|
||||
default_channel:
|
||||
{ type: string, description: "Default channel for notifications" }
|
||||
notify_on_task_created: { type: boolean, default: true }
|
||||
utility_agent: { type: string, format: utility-agent, title: "Utility Agent", description: "Agent used for plugin LLM calls" }
|
||||
utility_agent:
|
||||
{
|
||||
type: string,
|
||||
format: utility-agent,
|
||||
title: "Utility Agent",
|
||||
description: "Agent used for plugin LLM calls",
|
||||
}
|
||||
required: ["bot_token", "default_channel", "utility_agent"]
|
||||
|
||||
ui: # optional native frontend plugin
|
||||
bundle: "/ui/bundle.js" # root-relative
|
||||
styles: ["/ui/plugin.css"] # optional, root-relative
|
||||
keybindings: # optional, requires ui.bundle
|
||||
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
|
||||
default: "mod+shift+j" # combo grammar, see field reference
|
||||
ui: # optional native frontend plugin
|
||||
bundle: "/ui/bundle.js" # root-relative
|
||||
styles: ["/ui/plugin.css"] # optional, root-relative
|
||||
keybindings: # optional, requires ui.bundle
|
||||
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
|
||||
default: "mod+shift+j" # combo grammar, see field reference
|
||||
description: "Open the Acme panel"
|
||||
```
|
||||
|
||||
@@ -88,44 +104,46 @@ ui: # optional native frontend plugin
|
||||
<details>
|
||||
<summary>Complete field reference and validation rules</summary>
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. |
|
||||
| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. |
|
||||
| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins/<id>/<version>/`). |
|
||||
| `display_name` | no | string | Shown in Settings > Plugins. |
|
||||
| `description` | no | string | Shown in Settings > Plugins. |
|
||||
| `author` | no | string | Free-form. |
|
||||
| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. |
|
||||
| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. |
|
||||
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
|
||||
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
|
||||
| `runtime.executables` | required when `runtime.type: binary` | map\<string,string\> | Key is `<goos>-<goarch>` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
|
||||
| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= <version>, running <version>` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. |
|
||||
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
|
||||
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
|
||||
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |
|
||||
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
|
||||
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
|
||||
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. |
|
||||
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
|
||||
| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. |
|
||||
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
|
||||
| `webhooks[].description` | no | string | Free-form. |
|
||||
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
|
||||
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
|
||||
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
|
||||
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
|
||||
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
|
||||
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
|
||||
| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). |
|
||||
| `ui.pages[].title` | yes* | string | Display title. |
|
||||
| `ui.pages[].path` | yes* | string | Route path for the page. |
|
||||
| `ui.pages[].surface` | yes* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. |
|
||||
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
|
||||
| `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. |
|
||||
| `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
|
||||
| `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
|
||||
| Field | Required | Type | Notes |
|
||||
| ------------------------------ | ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. |
|
||||
| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. |
|
||||
| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins/<id>/<version>/`). |
|
||||
| `display_name` | no | string | Shown in Settings > Plugins. |
|
||||
| `description` | no | string | Shown in Settings > Plugins. |
|
||||
| `author` | no | string | Free-form. |
|
||||
| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. |
|
||||
| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. |
|
||||
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
|
||||
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
|
||||
| `runtime.executables` | required when `runtime.type: binary` | map\<string,string\> | Key is `<goos>-<goarch>` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
|
||||
| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= <version>, running <version>` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. |
|
||||
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
|
||||
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
|
||||
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |
|
||||
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
|
||||
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
|
||||
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. |
|
||||
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
|
||||
| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. |
|
||||
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
|
||||
| `webhooks[].description` | no | string | Free-form. |
|
||||
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
|
||||
| `webhooks[].access` | no | string | `public` (default) allows anonymous callers; `authenticated` requires a Kandev identity. Cookie-authenticated browser calls remain subject to Kandev's same-origin policy. |
|
||||
| `webhooks[].max_body_bytes` | no | int | Request-body limit for this key. Defaults to 4 MiB. Public webhooks cannot exceed 4 MiB; authenticated webhooks can request up to 16 MiB. Plugins using this field should declare the first supporting `min_kandev_version`. |
|
||||
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
|
||||
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
|
||||
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
|
||||
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
|
||||
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
|
||||
| `ui.pages[].key` | yes\* | string | Stable identifier for the page (\*required when a page entry is present). |
|
||||
| `ui.pages[].title` | yes\* | string | Display title. |
|
||||
| `ui.pages[].path` | yes\* | string | Route path for the page. |
|
||||
| `ui.pages[].surface` | yes\* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. |
|
||||
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
|
||||
| `ui.keybindings[].id` | yes\* | string | Stable, plugin-local slug (_required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]_$`and be unique within this plugin's own`ui.keybindings`list — not globally; the effective shortcut is namespaced`plugin:{pluginId}:{id}`. |
|
||||
| `ui.keybindings[].default` | yes\* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
|
||||
| `ui.keybindings[].description` | yes\* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
|
||||
|
||||
`ui.pages` is declarative manifest metadata only and is not currently rendered
|
||||
by the frontend. A native bundle's runtime nav items, icons, routes, named
|
||||
@@ -142,7 +160,7 @@ must **not** set `base_url` or an `endpoints` block (`health`/`events`/
|
||||
remote/operator-hosted tier, and validation rejects a managed manifest that
|
||||
sets them.
|
||||
|
||||
A manifest with an empty `runtime.type` still *parses* as a legacy remote
|
||||
A manifest with an empty `runtime.type` still _parses_ as a legacy remote
|
||||
manifest (with `base_url`/`endpoints` instead) and passes
|
||||
`manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar
|
||||
.Install` requires `manifest.IsManaged()` to be true (`runtime.type:
|
||||
@@ -228,39 +246,39 @@ matched — so a plugin subscribed to `shell.output.*` reads the session id off
|
||||
`event_type` as its last segment. For an unsuffixed subject that string is
|
||||
just the subject itself (`task.created`).
|
||||
|
||||
| Domain | Events |
|
||||
|---|---|
|
||||
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` |
|
||||
| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` |
|
||||
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
|
||||
| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` |
|
||||
| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` |
|
||||
| Task sessions | `task_session.state_changed` |
|
||||
| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` |
|
||||
| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` |
|
||||
| Session turns | `turn.started`, `turn.completed` |
|
||||
| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` |
|
||||
| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` |
|
||||
| Users | `user.settings.updated` |
|
||||
| System | `system.job.update` |
|
||||
| Environments | `environment.created`, `environment.updated`, `environment.deleted` |
|
||||
| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` |
|
||||
| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` |
|
||||
| Agent stream | `agent.stream` (per-session: `agent.stream.<sessionId>`), `agent.turn.message_saved` |
|
||||
| Agent prompts | `permission_request.received` (per-session: `permission_request.received.<sessionId>`) |
|
||||
| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` |
|
||||
| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) |
|
||||
| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) |
|
||||
| Dev server I/O | `process.output` (per-session), `process.status` (per-session) |
|
||||
| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) |
|
||||
| Automations | `automation.triggered`, `automation.run.created` |
|
||||
| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` |
|
||||
| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` |
|
||||
| Jira | `jira.new_issue` |
|
||||
| Linear | `linear.new_issue` |
|
||||
| Sentry | `sentry.new_issue` |
|
||||
| Domain | Events |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` |
|
||||
| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` |
|
||||
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
|
||||
| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` |
|
||||
| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` |
|
||||
| Task sessions | `task_session.state_changed` |
|
||||
| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` |
|
||||
| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` |
|
||||
| Session turns | `turn.started`, `turn.completed` |
|
||||
| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` |
|
||||
| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` |
|
||||
| Users | `user.settings.updated` |
|
||||
| System | `system.job.update` |
|
||||
| Environments | `environment.created`, `environment.updated`, `environment.deleted` |
|
||||
| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` |
|
||||
| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` |
|
||||
| Agent stream | `agent.stream` (per-session: `agent.stream.<sessionId>`), `agent.turn.message_saved` |
|
||||
| Agent prompts | `permission_request.received` (per-session: `permission_request.received.<sessionId>`) |
|
||||
| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` |
|
||||
| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) |
|
||||
| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) |
|
||||
| Dev server I/O | `process.output` (per-session), `process.status` (per-session) |
|
||||
| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) |
|
||||
| Automations | `automation.triggered`, `automation.run.created` |
|
||||
| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` |
|
||||
| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` |
|
||||
| Jira | `jira.new_issue` |
|
||||
| Linear | `linear.new_issue` |
|
||||
| Sentry | `sentry.new_issue` |
|
||||
| Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` |
|
||||
| Cross-plugin | `plugin.<plugin_id>.<name>` — published by `Host.EmitEvent`; subscribe with `plugin.<other-plugin-id>.*` to react to another plugin's events. |
|
||||
| Cross-plugin | `plugin.<plugin_id>.<name>` — published by `Host.EmitEvent`; subscribe with `plugin.<other-plugin-id>.*` to react to another plugin's events. |
|
||||
|
||||
> Event list current as of 2026-07-16; regenerate from
|
||||
> `apps/backend/internal/events/types.go` if this page drifts from the code.
|
||||
@@ -272,13 +290,13 @@ alongside the parsed manifest. These are **kandev-owned runtime state, not
|
||||
author-supplied manifest fields** — do not include them in `manifest.yaml`;
|
||||
they have no effect there and are overwritten on install:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. |
|
||||
| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins/<id>/<version>/`). |
|
||||
| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). |
|
||||
| `installed_at` | Install timestamp. |
|
||||
| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. |
|
||||
| Field | Meaning |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. |
|
||||
| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins/<id>/<version>/`). |
|
||||
| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). |
|
||||
| `installed_at` | Install timestamp. |
|
||||
| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. |
|
||||
|
||||
Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md).
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ Product-wide capabilities that are not tied to a single feature area.
|
||||
| [plugins](plugins/spec.md) | draft |
|
||||
| [plugins — authoring experience](plugins/authoring-experience.md) | draft |
|
||||
| [plugins — marketplace](plugins/marketplace.md) | building |
|
||||
| [plugins — Voice extraction host prerequisites](plugins/voice-extraction-host.md) | draft |
|
||||
| [semantic-notifications](platform/notifications.md) | shipped |
|
||||
| [workspace-git-status](platform/workspace-git-status.md) | shipped |
|
||||
| [git-subprocess-admission](platform/git-subprocess-admission.md) | building |
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-08-11
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Voice Plugin Host Prerequisites
|
||||
|
||||
## Why
|
||||
|
||||
Voice Mode can ship independently only if a plugin can participate in every native prompt composer
|
||||
without bypassing its draft and submission behavior, and can invoke its own billable backend without
|
||||
exposing that operation as a public webhook. The host must provide those narrow capabilities before
|
||||
the existing core implementation can move to a dedicated plugin repository.
|
||||
|
||||
Decision: [ADR-2026-08-11-composer-access-authenticated-webhooks](../../decisions/2026-08-11-composer-access-authenticated-webhooks.md).
|
||||
|
||||
## What
|
||||
|
||||
- A plugin action rendered in a supported composer receives typed composer props owned by that
|
||||
mounted composer. They report whether editing and submission are currently allowed, insert
|
||||
text at the current selection, focuses the editor, and submits through the same handler as the
|
||||
native submit control.
|
||||
- `composer.insertText(text)` trims an empty transcript to a no-op, replaces the selected range (or
|
||||
inserts at the caret), applies the native Voice Mode word-boundary spacing rule, places the caret
|
||||
after the insertion, updates the authoritative local draft synchronously, and focuses the input.
|
||||
- `composer.submit()` returns a typed outcome and never constructs or sends a message itself. The
|
||||
native path remains responsible for all existing submission behavior and errors.
|
||||
- Slot props expose live `disabled`, `submittable`, and optional `disabledReason` state. An
|
||||
operation against an unmounted or superseded composer fails closed with `unavailable`; submit
|
||||
against a currently blocked form returns `blocked` without discarding the draft.
|
||||
- Composer action slots exist in desktop and native-mobile task chat, Quick Chat, task creation, and
|
||||
new-session creation. Each slot reports its surface and presentation plus only the identifiers
|
||||
meaningful on that surface.
|
||||
- The existing `chat-input-actions` registration remains source-compatible. Its slot props gain the
|
||||
composer capability and surface metadata; new `task-create-input-actions` and
|
||||
`new-session-input-actions` slots cover creation forms. Quick Chat uses `chat-input-actions` with
|
||||
`surface: "quick-chat"` and `taskId: null` when it is genuinely task-less.
|
||||
- A webhook declaration chooses `access: public` or `access: authenticated`. Existing declarations
|
||||
default to `public`; authenticated webhooks require the current Kandev user and same-origin browser
|
||||
request checks before the existing plugin webhook RPC runs.
|
||||
- A webhook declaration may lower its request cap through `max_body_bytes`. Only authenticated
|
||||
webhooks may raise it above the existing 4 MiB public ceiling, up to a host ceiling of 16 MiB.
|
||||
Voice transcription declares an
|
||||
authenticated webhook with a limit of at least 10 MiB.
|
||||
- The Voice plugin's OpenAI key belongs to its existing plugin settings and secret storage. Kandev
|
||||
relays the request but does not own, expose, or select that key.
|
||||
- Plugin localization is a separately scoped prerequisite. This package adds no host i18n API. The
|
||||
later Voice plugin task must decide how plugin-owned catalogs follow Kandev's locale before core
|
||||
Voice Mode is removed.
|
||||
- Disabling, uninstalling, or reloading a plugin aborts its in-flight webhook requests, unregisters
|
||||
its composer actions, and leaves native drafts intact.
|
||||
- The current core Voice Mode stays present until a separately delivered Voice plugin proves parity
|
||||
on all listed surfaces. Removing core Voice Mode is not part of these host prerequisites.
|
||||
|
||||
## API Surface
|
||||
|
||||
### Composer slots
|
||||
|
||||
```ts
|
||||
type PluginComposerSurface =
|
||||
| "task-chat"
|
||||
| "quick-chat"
|
||||
| "task-create"
|
||||
| "new-session";
|
||||
type PluginPresentation = "desktop" | "mobile";
|
||||
|
||||
type PluginComposerSubmitResult =
|
||||
| { status: "submitted" }
|
||||
| { status: "blocked"; reason?: string }
|
||||
| { status: "unavailable" };
|
||||
|
||||
interface PluginComposerCapability {
|
||||
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
|
||||
focus(): { status: "focused" | "unavailable" };
|
||||
submit(): Promise<PluginComposerSubmitResult>;
|
||||
}
|
||||
|
||||
interface PluginComposerSlotProps {
|
||||
surface: PluginComposerSurface;
|
||||
presentation: PluginPresentation;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
activeSessionId: string | null;
|
||||
sessionIds: string[];
|
||||
disabled: boolean;
|
||||
submittable: boolean;
|
||||
disabledReason?: string;
|
||||
composer: PluginComposerCapability;
|
||||
}
|
||||
```
|
||||
|
||||
The host re-renders slot props when native state changes. `submit()` always revalidates native state
|
||||
at call time. The host may replace the capability object when a surface is remounted; methods on the
|
||||
old object return `unavailable`.
|
||||
|
||||
### Authenticated webhooks
|
||||
|
||||
Manifest:
|
||||
|
||||
```yaml
|
||||
webhooks:
|
||||
- key: transcribe
|
||||
access: authenticated
|
||||
max_body_bytes: 16777216
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```ts
|
||||
host.api.fetch("webhooks/transcribe", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
```
|
||||
|
||||
HTTP and runtime wire contract:
|
||||
|
||||
```text
|
||||
POST /api/plugins/{pluginId}/webhooks/{key}
|
||||
Origin: <the configured Kandev web origin>
|
||||
|
||||
rpc HandleWebhook(WebhookRequest) returns (WebhookResponse)
|
||||
```
|
||||
|
||||
`access` is enforced by the host before `HandleWebhook` runs. For `authenticated`, the normal Kandev
|
||||
identity must be present and browser cookie authentication additionally requires an accepted Origin;
|
||||
authentication-disabled installations use the synthetic default user. Public webhooks retain their
|
||||
current external-call behavior. Request cancellation propagates through the existing RPC context.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Composer capabilities exist only as props on a currently rendered slot; they are not placed in
|
||||
`host.store`, exported globally, or addressable by task/session ID.
|
||||
- Webhook access changes only who may invoke that declared key. It grants no Host data, state, secret,
|
||||
or event capability.
|
||||
- Plugin backends receive the existing webhook request shape. They never receive the Kandev session
|
||||
cookie or PAT. The Voice plugin reads its OpenAI secret through existing plugin configuration.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
- Empty or whitespace-only insertion is ignored. Unsupported or stale composers return
|
||||
`unavailable`; they do not mutate another surface's draft.
|
||||
- A blocked native submit returns `blocked`, preserves the native draft, and leaves native error
|
||||
presentation authoritative. A native submission failure resolves `blocked` with its safe reason
|
||||
where available and preserves the draft exactly as a native failure does.
|
||||
- An unavailable plugin runtime returns `503`; cancellation stops the runtime request and returns no
|
||||
synthetic success; an oversized body returns `413`; malformed multipart or metadata returns `400`;
|
||||
missing authentication returns `401`; origin or declaration failure returns
|
||||
`403` or `404` according to the existing hide-existence policy.
|
||||
- Host logs may contain plugin ID, action, request ID, byte count, duration, status, and cancellation,
|
||||
but never audio bytes, authorization material, cookies, or plugin secrets.
|
||||
|
||||
## Compatibility And Versioning
|
||||
|
||||
- These additions are backward-compatible within plugin `api_version: 1`; all new manifest and host
|
||||
fields are optional, and existing slot components that ignore added props continue to work.
|
||||
- A plugin using `webhooks[].access` or `max_body_bytes` must set `min_kandev_version` to the first
|
||||
release containing this contract. Older hosts reject installation instead of silently treating an
|
||||
authenticated webhook as public.
|
||||
- Manifest types, validation, authoring docs, and handler behavior change together. The protobuf and
|
||||
Go plugin SDK webhook contract remain unchanged.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a task-chat selection, **WHEN** a plugin inserts a transcript and invokes submit, **THEN**
|
||||
the selection is replaced and the existing native submit handler runs.
|
||||
- **GIVEN** a running agent configured for steering or queueing, **WHEN** a plugin auto-submits,
|
||||
**THEN** the same steering or queueing decision and error handling as the native submit button runs.
|
||||
- **GIVEN** task creation or new-session creation on desktop or Pixel 5, **WHEN** a plugin inserts at
|
||||
the textarea selection and submits, **THEN** the native form validation and creation handler run.
|
||||
- **GIVEN** a plugin action is recording on one session, **WHEN** the task, session, or composer
|
||||
changes, **THEN** the plugin cancels its request and the stale capability cannot modify the new
|
||||
composer.
|
||||
- **GIVEN** Quick Chat, **WHEN** a composer plugin action is rendered, **THEN** it receives the shared
|
||||
capability with `surface: "quick-chat"` and completes the same insertion and submit behavior.
|
||||
- **GIVEN** a plugin declares an authenticated `transcribe` webhook with a 16 MiB cap, **WHEN** its UI
|
||||
sends 10 MiB of multipart audio through `host.api.fetch`, **THEN** the existing webhook RPC reaches
|
||||
that plugin backend and returns its response.
|
||||
- **GIVEN** an unauthenticated caller, **WHEN** it calls the authenticated transcription webhook,
|
||||
**THEN** the request fails before plugin execution and cannot consume the plugin's configured key.
|
||||
- **GIVEN** an in-flight transcription, **WHEN** the caller aborts or the plugin is disabled, **THEN**
|
||||
the HTTP and runtime requests are cancelled and no transcript is inserted.
|
||||
- **GIVEN** a plugin built for this contract, **WHEN** it is installed on an older Kandev release,
|
||||
**THEN** `min_kandev_version` rejects installation with no partial registration.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Implementing, packaging, publishing, or removing the dedicated Voice plugin.
|
||||
- Removing core Voice Mode before plugin parity and migration are separately validated.
|
||||
- Exposing draft text, selection offsets, or arbitrary native composer mutation through
|
||||
`host.store` or a global host API.
|
||||
- General authenticated REST routing, response streaming, bidirectional browser sockets, or changing
|
||||
the default limit for existing public webhooks.
|
||||
- Sandboxing mutually malicious native UI plugin bundles.
|
||||
- Plugin localization; it is a separate prerequisite for the later Voice plugin extraction.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
See [the implementation plan](../../plans/plugin-voice-extraction-host/plan.md).
|
||||
Reference in New Issue
Block a user