Files
kit/plans/03-event-subscriber-system.md
T
Ed Zynda 626f1105c9 move SDK to pkg/kit, extract shared logic from cmd, relocate main to cmd/kit
Restructure the codebase so the CLI app consumes the SDK rather than
the SDK wrapping CLI internals. This eliminates the circular dependency
(sdk -> cmd -> sdk) and establishes pkg/kit as the canonical API.

Key changes:
- Create pkg/kit/ with InitConfig, SetupAgent, BuildProviderConfig
  extracted from cmd/root.go and cmd/setup.go as parameterized functions
- Move sdk/kit.go -> pkg/kit/kit.go (remove cmd import, use local calls)
- Move sdk/types.go -> pkg/kit/types.go
- Move main.go -> cmd/kit/main.go (standard Go project layout)
- cmd/root.go and cmd/setup.go now delegate to pkg/kit, injecting
  CLI-specific state (quietFlag) via the Quiet field on AgentSetupOptions
- Add setSDKDefaults() for cobra-free SDK usage (viper defaults)
- Fix .gitignore: kit -> /kit (was blocking cmd/kit/ and pkg/kit/)
- Update .goreleaser.yaml, Taskfile.yml, AGENTS.md, contribute/build.sh,
  README.md for new cmd/kit entrypoint and pkg/kit import paths
- Add plans/ with 10 detailed SDK revamp plans and Taskfile.yml
- Delete sdk/ directory entirely
2026-02-27 10:42:27 +03:00

10 KiB

Plan 03: Event/Subscriber System

Priority: P1 Effort: High Goal: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase

Background

Kit currently has three separate event systems that overlap:

  1. SDK callbacks (sdk/kit.go) — 3 function pointers on PromptWithCallbacks
  2. Extension events (internal/extensions/events.go) — 13 typed events dispatched via Runner.Emit()
  3. App/TUI events (internal/app/events.go) — 13 tea.Msg structs for BubbleTea UI updates

Pi uses a single session.subscribe(listener) pattern. This plan creates a unified event system in pkg/kit/ that:

  • Replaces SDK callbacks
  • Becomes the canonical event layer that extensions and the app emit through
  • The TUI adapts SDK events into tea.Msg for rendering (TUI-specific concern stays in internal/ui/)

Prerequisites

  • Plan 00 (Create pkg/kit/)
  • Plan 02 (Richer type exports)

Design Decisions

  1. Single source of truth — events are defined in pkg/kit/, not scattered across packages
  2. Multiple subscribers supported with unsubscribe
  3. Thread-safe emission
  4. App subscribes to SDK events — the TUI layer adapts them to tea.Msg
  5. Extensions emit through SDK — the extension runner emits SDK events, not its own types

Step-by-Step

Step 1: Define public event types

File: pkg/kit/events.go (new)

package kit

import "sync"

// EventType identifies the kind of event.
type EventType string

const (
    EventTurnStart          EventType = "turn_start"
    EventTurnEnd            EventType = "turn_end"
    EventMessageStart       EventType = "message_start"
    EventMessageUpdate      EventType = "message_update"
    EventMessageEnd         EventType = "message_end"
    EventToolCall           EventType = "tool_call"
    EventToolExecutionStart EventType = "tool_execution_start"
    EventToolExecutionEnd   EventType = "tool_execution_end"
    EventToolResult         EventType = "tool_result"
    EventToolCallContent    EventType = "tool_call_content"
    EventResponse           EventType = "response"
    EventSessionStart       EventType = "session_start"
    EventSessionShutdown    EventType = "session_shutdown"
)

// Event is the interface for all event types.
type Event interface {
    EventType() EventType
}

Step 2: Define concrete event structs

These cover the union of all three current event systems:

type TurnStartEvent struct{ Prompt string }
func (e TurnStartEvent) EventType() EventType { return EventTurnStart }

type TurnEndEvent struct{ Response string; Error error }
func (e TurnEndEvent) EventType() EventType { return EventTurnEnd }

type MessageStartEvent struct{}
func (e MessageStartEvent) EventType() EventType { return EventMessageStart }

type MessageUpdateEvent struct{ Chunk string }
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }

type MessageEndEvent struct{ Content string }
func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }

type ToolCallEvent struct{ ToolName string; ToolArgs string }
func (e ToolCallEvent) EventType() EventType { return EventToolCall }

type ToolExecutionStartEvent struct{ ToolName string }
func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart }

type ToolExecutionEndEvent struct{ ToolName string }
func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd }

type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool }
func (e ToolResultEvent) EventType() EventType { return EventToolResult }

type ToolCallContentEvent struct{ Content string }
func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent }

type ResponseEvent struct{ Content string }
func (e ResponseEvent) EventType() EventType { return EventResponse }

Step 3: Implement EventBus

type EventListener func(event Event)

type eventBus struct {
    mu        sync.RWMutex
    listeners map[int]EventListener
    nextID    int
}

func newEventBus() *eventBus {
    return &eventBus{listeners: make(map[int]EventListener)}
}

func (eb *eventBus) subscribe(listener EventListener) func() {
    eb.mu.Lock()
    id := eb.nextID
    eb.nextID++
    eb.listeners[id] = listener
    eb.mu.Unlock()
    return func() {
        eb.mu.Lock()
        delete(eb.listeners, id)
        eb.mu.Unlock()
    }
}

func (eb *eventBus) emit(event Event) {
    eb.mu.RLock()
    snapshot := make([]EventListener, 0, len(eb.listeners))
    for _, l := range eb.listeners {
        snapshot = append(snapshot, l)
    }
    eb.mu.RUnlock()
    for _, l := range snapshot {
        l(event)
    }
}

Step 4: Wire EventBus into Kit struct

File: pkg/kit/kit.go

type Kit struct {
    agent       *agent.Agent
    sessionMgr  *session.Manager
    modelString string
    events      *eventBus
}

func (m *Kit) Subscribe(listener EventListener) func() {
    return m.events.subscribe(listener)
}

Step 5: Wire all agent callbacks to emit events

Update Prompt() and PromptWithCallbacks() to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in internal/app/app.go:364-520):

  1. Before generation: TurnStartEvent, MessageStartEvent
  2. During streaming: MessageUpdateEvent per chunk
  3. On tool call: ToolCallEvent, ToolExecutionStartEvent
  4. On tool result: ToolExecutionEndEvent, ToolResultEvent
  5. On response: ResponseEvent
  6. After generation: MessageEndEvent, TurnEndEvent

Extract shared callback helpers to avoid duplication:

func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
    return func(name, args string) {
        m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
    }
}
// ... similar for all callback types

Step 6: App-as-Consumer — TUI subscribes to SDK events

This is the critical refactor. Currently internal/app/app.go:executeStep() emits TUI events directly via sendFn(StreamChunkEvent{...}). After this change:

  1. The SDK's Prompt() emits SDK events
  2. The app subscribes to SDK events and converts them to tea.Msg

File: internal/app/app.go (migration pattern)

// In App initialization, subscribe to SDK events and bridge to TUI
func (a *App) setupEventBridge() {
    a.kit.Subscribe(func(e kit.Event) {
        switch ev := e.(type) {
        case kit.MessageUpdateEvent:
            a.sendToTUI(StreamChunkEvent{Content: ev.Chunk})
        case kit.ToolCallEvent:
            a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
        case kit.ToolResultEvent:
            a.sendToTUI(ToolResultEvent{
                ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
                Result: ev.Result, IsError: ev.IsError,
            })
        case kit.ResponseEvent:
            a.sendToTUI(ResponseCompleteEvent{Content: ev.Content})
        // ... etc
        }
    })
}

Migration steps:

  1. First: app subscribes to SDK events AND keeps its own emission (dual-emit phase)
  2. Then: remove direct emission from executeStep(), rely solely on SDK events
  3. Finally: remove internal/app/events.go types that are now redundant

Step 7: Extension events bridge to SDK events

The extension Runner should emit through the SDK event bus rather than its own parallel system. This can be bridged:

// In Kit initialization, bridge extension events to SDK events
func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) {
    // When extensions emit events, forward them as SDK events
    // This is done by having the Runner call back into the SDK
    runner.SetEventForwarder(func(event extensions.Event) {
        switch e := event.(type) {
        case extensions.ToolCallEvent:
            m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input})
        // ... etc
        }
    })
}

Note: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively.

Step 8: Typed convenience subscribers

func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
    return m.Subscribe(func(e Event) {
        if tc, ok := e.(ToolCallEvent); ok { handler(tc) }
    })
}

func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
    return m.Subscribe(func(e Event) {
        if tr, ok := e.(ToolResultEvent); ok { handler(tr) }
    })
}

func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
    return m.Subscribe(func(e Event) {
        if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) }
    })
}

Step 9: Write tests and verify

go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...

Files Changed Summary

Action File Change
CREATE pkg/kit/events.go Event types, EventBus, Subscribe()
EDIT pkg/kit/kit.go Add eventBus field, Subscribe(), callback helpers
EDIT internal/app/app.go Subscribe to SDK events (gradual migration)
EDIT internal/extensions/runner.go Optional: event forwarding to SDK bus

Event Flow After This Plan

Agent.GenerateWithLoopAndStreaming()
    ↓ fantasy callbacks
pkg/kit/kit.go  (SDK Prompt method)
    ↓ emits SDK events
EventBus
    ↓ dispatches to all subscribers
    ├── External SDK user's listener
    ├── App TUI bridge → tea.Msg → BubbleTea Update()
    └── Extension bridge → Runner.Emit() → Yaegi handlers

Single source of truth: The SDK EventBus is the only event dispatcher.

Verification Checklist

  • go build -o output/kit ./cmd/kit succeeds
  • go test -race ./... passes
  • Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd
  • Multiple subscribers receive all events
  • Unsubscribe removes listener
  • App TUI still renders correctly via event bridge
  • Thread-safe under concurrent calls