cli/docs/plans/2026-01-30-core-ipc-design.md
Snider eecf267935 docs(plans): add Core IPC design for CLI commands
Documents the four dispatch patterns (ACTION, QUERY, QUERYALL, PERFORM),
worker bundle architecture, and permissions-through-presence model.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:05:35 +00:00

11 KiB

Core Framework IPC Design

Design document for refactoring CLI commands to use the Core framework's IPC system.

Overview

The Core framework provides a dependency injection and inter-process communication (IPC) system originally designed for orchestrating services. This design extends the framework with request/response patterns and applies it to CLI commands.

Commands build "worker bundles" - sandboxed Core instances with specific services. The bundle configuration acts as a permissions layer: if a service isn't registered, that capability isn't available.

Dispatch Patterns

Four patterns for service communication:

Method Behaviour Returns Use Case
ACTION Broadcast to all handlers error Events, notifications
QUERY First responder wins (any, bool, error) Get data
QUERYALL Broadcast, collect all ([]any, error) Aggregate from multiple services
PERFORM First responder executes (any, bool, error) Execute a task with side effects

ACTION (existing)

Fire-and-forget broadcast. All registered handlers receive the message. Errors are aggregated.

c.ACTION(ActionServiceStartup{})

QUERY (new)

Request data from services. Stops at first handler that returns handled=true.

result, handled, err := c.QUERY(git.QueryStatus{Paths: paths})
if !handled {
    // No service registered to handle this query
}
statuses := result.([]git.RepoStatus)

QUERYALL (new)

Broadcast query to all handlers, collect all responses. Useful for aggregating results from multiple services (e.g., multiple QA/lint tools).

results, err := c.QUERYALL(qa.QueryLint{Paths: paths})
for _, r := range results {
    lint := r.(qa.LintResult)
    fmt.Printf("%s found %d issues\n", lint.Tool, len(lint.Issues))
}

PERFORM (new)

Execute a task with side effects. Stops at first handler that returns handled=true.

result, handled, err := c.PERFORM(agentic.TaskCommit{
    Path: repo.Path,
    Name: repo.Name,
})
if !handled {
    // Agentic service not in bundle - commits not available
}

Architecture

┌─────────────────────────────────────────────────────────────┐
│ cmd/dev/dev_work.go                                         │
│   - Builds worker bundle                                    │
│   - Triggers PERFORM(TaskWork{})                            │
└─────────────────────┬───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│ cmd/dev/bundles.go                                          │
│   - NewWorkBundle() - git + agentic + dev                   │
│   - NewStatusBundle() - git + dev only                      │
│   - Bundle config = permissions                             │
└─────────────────────┬───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│ pkg/dev/service.go                                          │
│   - Orchestrates workflow                                   │
│   - QUERY(git.QueryStatus{})                                │
│   - PERFORM(agentic.TaskCommit{})                           │
│   - PERFORM(git.TaskPush{})                                 │
└─────────────────────┬───────────────────────────────────────┘
                      │
        ┌─────────────┴─────────────┐
        ▼                           ▼
┌───────────────────┐     ┌───────────────────┐
│ pkg/git/service   │     │ pkg/agentic/svc   │
│                   │     │                   │
│ Queries:          │     │ Tasks:            │
│ - QueryStatus     │     │ - TaskCommit      │
│ - QueryDirtyRepos │     │ - TaskPrompt      │
│ - QueryAheadRepos │     │                   │
│                   │     │                   │
│ Tasks:            │     │                   │
│ - TaskPush        │     │                   │
│ - TaskPull        │     │                   │
└───────────────────┘     └───────────────────┘

Permissions Model

Permissions are implicit through bundle configuration:

// Full capabilities - can commit and push
func NewWorkBundle(opts WorkBundleOptions) (*framework.Runtime, error) {
    return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
        "dev":     func() (any, error) { return dev.NewService(opts.Dev)(nil) },
        "git":     func() (any, error) { return git.NewService(opts.Git)(nil) },
        "agentic": func() (any, error) { return agentic.NewService(opts.Agentic)(nil) },
    })
}

// Read-only - status queries only, no commits
func NewStatusBundle(opts StatusBundleOptions) (*framework.Runtime, error) {
    return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
        "dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) },
        "git": func() (any, error) { return git.NewService(opts.Git)(nil) },
        // No agentic service - TaskCommit will be unhandled
    })
}

Service options provide fine-grained control:

agentic.NewService(agentic.ServiceOptions{
    AllowEdit: false,  // Claude can only use read-only tools
})

agentic.NewService(agentic.ServiceOptions{
    AllowEdit: true,   // Claude can use Write/Edit tools
})

Key principle: Code never checks permissions explicitly. It dispatches actions and either they're handled or they're not. The bundle configuration is the single source of truth for what's allowed.

Framework Changes

New Types (interfaces.go)

type Query interface{}
type Task interface{}

type QueryHandler func(*Core, Query) (any, bool, error)
type TaskHandler func(*Core, Task) (any, bool, error)

Core Struct Additions (interfaces.go)

type Core struct {
    // ... existing fields

    queryMu       sync.RWMutex
    queryHandlers []QueryHandler

    taskMu        sync.RWMutex
    taskHandlers  []TaskHandler
}

New Methods (core.go)

// QUERY - first responder wins
func (c *Core) QUERY(q Query) (any, bool, error)

// QUERYALL - broadcast, collect all responses
func (c *Core) QUERYALL(q Query) ([]any, error)

// PERFORM - first responder executes
func (c *Core) PERFORM(t Task) (any, bool, error)

// Registration
func (c *Core) RegisterQuery(h QueryHandler)
func (c *Core) RegisterTask(h TaskHandler)

Re-exports (framework.go)

type Query = core.Query
type Task = core.Task
type QueryHandler = core.QueryHandler
type TaskHandler = core.TaskHandler

Service Implementation Pattern

Services register handlers during startup:

func (s *Service) OnStartup(ctx context.Context) error {
    s.Core().RegisterAction(s.handleAction)
    s.Core().RegisterQuery(s.handleQuery)
    s.Core().RegisterTask(s.handleTask)
    return nil
}

func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
    switch m := q.(type) {
    case QueryStatus:
        result := s.getStatus(m.Paths, m.Names)
        return result, true, nil
    case QueryDirtyRepos:
        return s.DirtyRepos(), true, nil
    }
    return nil, false, nil  // Not handled
}

func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
    switch m := t.(type) {
    case TaskPush:
        err := s.push(m.Path)
        return nil, true, err
    case TaskPull:
        err := s.pull(m.Path)
        return nil, true, err
    }
    return nil, false, nil  // Not handled
}

Git Service Queries & Tasks

// pkg/git/queries.go
type QueryStatus struct {
    Paths []string
    Names map[string]string
}

type QueryDirtyRepos struct{}
type QueryAheadRepos struct{}

// pkg/git/tasks.go
type TaskPush struct {
    Path string
    Name string
}

type TaskPull struct {
    Path string
    Name string
}

type TaskPushMultiple struct {
    Paths []string
    Names map[string]string
}

Agentic Service Tasks

// pkg/agentic/tasks.go
type TaskCommit struct {
    Path    string
    Name    string
    CanEdit bool
}

type TaskPrompt struct {
    Prompt       string
    WorkDir      string
    AllowedTools []string
}

Dev Workflow Service

// pkg/dev/tasks.go
type TaskWork struct {
    RegistryPath string
    StatusOnly   bool
    AutoCommit   bool
}

type TaskCommitAll struct {
    RegistryPath string
}

type TaskPushAll struct {
    RegistryPath string
    Force        bool
}

Command Simplification

Before (dev_work.go - 327 lines of orchestration):

func runWork(registryPath string, statusOnly, autoCommit bool) error {
    // Load registry
    // Get git status
    // Display table
    // Loop dirty repos, shell out to claude
    // Re-check status
    // Confirm push
    // Push repos
    // Handle diverged branches
    // ...
}

After (dev_work.go - minimal):

func runWork(registryPath string, statusOnly, autoCommit bool) error {
    bundle, err := NewWorkBundle(WorkBundleOptions{
        RegistryPath: registryPath,
    })
    if err != nil {
        return err
    }

    ctx := context.Background()
    bundle.Core.ServiceStartup(ctx, nil)
    defer bundle.Core.ServiceShutdown(ctx)

    _, _, err = bundle.Core.PERFORM(dev.TaskWork{
        StatusOnly: statusOnly,
        AutoCommit: autoCommit,
    })
    return err
}

All orchestration logic moves to pkg/dev/service.go where it can be tested independently and reused.

Implementation Tasks

  1. Framework Core - Add Query, Task types and QUERY/QUERYALL/PERFORM methods
  2. Framework Re-exports - Update framework.go with new types
  3. Git Service - Add query and task handlers
  4. Agentic Service - Add task handlers
  5. Dev Service - Create workflow orchestration service
  6. Bundles - Create bundle factories in cmd/dev/
  7. Commands - Simplify cmd/dev/*.go to use bundles

Future: CLI-Wide Runtime

Phase 2 will add a CLI-wide Core instance that:

  • Handles signals (SIGINT, SIGTERM)
  • Manages UI state
  • Spawns worker bundles as "interactable elements"
  • Provides cross-bundle communication

Worker bundles become sandboxed children of the CLI runtime, with the runtime controlling what capabilities each bundle receives.

Testing

Each layer is independently testable:

  • Framework: Unit tests for QUERY/QUERYALL/PERFORM dispatch
  • Services: Unit tests with mock Core instances
  • Bundles: Integration tests with real services
  • Commands: E2E tests via CLI invocation

The permission model is testable by creating bundles with/without specific services and verifying behaviour.