cldpd

package module
v0.0.0-...-1a3b45b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 23, 2026 License: MIT Imports: 17 Imported by: 0

README

cldpd

CI Status codecov Go Report Card CodeQL Go Reference License Go Version Release

Async pod lifecycle library for Claude Code agent teams.

Spawn Docker containers that run Claude Code against your repositories. Each repo carries its own agent workflows, standing orders, and skills. cldpd dispatches work to these self-sufficient teams and returns a session handle for non-blocking lifecycle management.

How It Works

Define a pod — a directory with a Dockerfile and optional configuration. Point it at a GitHub issue. Walk away.

cldpd start myrepo --issue https://github.com/org/repo/issues/42

cldpd builds the Docker image, starts a container running Claude Code headlessly, and streams typed events back to your terminal — lifecycle transitions and output content. The crew inside the container works the issue autonomously. When the task is complete, the container exits and cleans up.

Need to send follow-up guidance while the team is working? Open a second terminal:

cldpd resume myrepo --prompt "Focus on the error handling in api.go"

Install

go install github.com/zoobzio/cldpd/cmd/cldpd@latest

Requires Go 1.24+ and Docker.

Quick Start

1. Create a pod
mkdir -p ~/.cldpd/pods/myrepo
2. Write a Dockerfile
# ~/.cldpd/pods/myrepo/Dockerfile
FROM node:20

# Install Claude Code
RUN npm install -g @anthropic-ai/claude-code

# Clone your repository
RUN git clone https://github.com/org/repo.git /workspace
WORKDIR /workspace
3. Add configuration (optional)

~/.cldpd/pods/myrepo/pod.json:

{
  "env": {
    "ANTHROPIC_API_KEY": "sk-ant-..."
  },
  "workdir": "/workspace"
}
4. Dispatch
cldpd start myrepo --issue https://github.com/org/repo/issues/42

The team leader's output streams to your terminal. The container exits when the task is complete.

Pod Structure

Pods live at ~/.cldpd/pods/<name>/. Each pod directory contains:

File Required Description
Dockerfile Yes Defines the container environment
pod.json No Optional configuration
template.md No Standing orders prepended to the prompt on start

The pod name is the directory name. cldpd does not generate or modify Dockerfiles — what goes inside the container is your concern.

template.md

If template.md is present in the pod directory, its contents are prepended to the prompt when cldpd start is invoked. This is where team-specific standing orders go — git setup, branch workflow, operational strategy. The template is not used during resume (the agent already has its instructions from the initial session).

If the file is absent, the prompt is unchanged. If the file is present but unreadable, pod discovery returns an error.

pod.json

All fields are optional:

{
  "image": "custom-image:v1",
  "env": {"KEY": "value"},
  "buildArgs": {"ARG": "value"},
  "workdir": "/workspace",
  "inheritEnv": ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"],
  "mounts": [
    {"source": "/home/user/.ssh", "target": "/root/.ssh", "readOnly": true}
  ]
}
Field Default Description
image cldpd-<podname> Docker image tag override
env none Environment variables passed to the container
buildArgs none Docker build arguments (--build-arg)
workdir none Working directory inside the container
inheritEnv none Host environment variable names to forward to the container
mounts none Bind mounts (-v source:target[:ro]). Source paths starting with ~ are expanded to the user's home directory.

CLI Reference

start

Build and run a pod, streaming events until the container exits.

cldpd start <pod> --issue <url>
  • Builds the Docker image from the pod's Dockerfile
  • Starts a container named cldpd-<pod>
  • Runs claude -p "<prompt>" inside the container (if template.md exists, its contents are prepended to the prompt)
  • Streams output events to your terminal, errors to stderr
  • Handles Ctrl+C gracefully (SIGTERM with 10-second timeout)
  • Exits with the container's exit code
resume

Send a follow-up prompt to a running pod.

cldpd resume <pod> --prompt <text>
  • Execs into the running container named cldpd-<pod>
  • Runs claude --resume -p "<text>"
  • Streams output events to your terminal
  • Handles Ctrl+C gracefully
  • Fails with a clear error if the container is not running

Library Usage

cldpd is also a Go library. The CLI is a thin wrapper around the Dispatcher:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/zoobzio/cldpd"
)

func main() {
    runner := &cldpd.DockerRunner{}
    d := cldpd.NewDispatcher("/home/user/.cldpd/pods", runner)

    session, err := d.Start(
        context.Background(),
        "myrepo",
        "https://github.com/org/repo/issues/42",
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "start failed: %v\n", err)
        os.Exit(1)
    }

    // Consume events non-blocking
    for event := range session.Events() {
        switch event.Type {
        case cldpd.EventOutput:
            fmt.Println(event.Data)
        case cldpd.EventError:
            fmt.Fprintf(os.Stderr, "error: %s\n", event.Data)
        }
    }

    code, _ := session.Wait()
    os.Exit(code)
}

Start returns a *Session immediately after the image build completes. The session emits typed events over a channel and provides Stop for graceful shutdown and Wait for the exit code. The Runner interface abstracts Docker operations, allowing you to swap implementations or mock for testing.

Design

  • Stdlib only — Zero external dependencies. Docker interaction via os/exec.
  • AsyncStart returns a *Session immediately. The container runs in a background goroutine.
  • Event-driven — Typed events (EventOutput, EventContainerExited, etc.) replace raw io.Writer streaming.
  • Ephemeral — Containers use --rm. No state persists between runs.
  • Composable — The Runner interface decouples Docker operations from orchestration.
  • Stateless dispatcher — The Dispatcher does not track sessions. The caller owns the *Session handle.

Examples

The examples/ directory contains complete pod definitions for two teams:

  • examples/red-team/ — Dockerfile, pod.json, and template.md for a red team deployment
  • examples/blue-team/ — Dockerfile, pod.json, and template.md for a blue team deployment

Each demonstrates SSH key mounting, API key passthrough via inheritEnv, git identity configuration, and team-specific standing orders. Copy an example to ~/.cldpd/pods/ and adjust the paths and identity to match your setup.

Documentation

  • Overview — What cldpd does and why
  • Quickstart — From zero to dispatching
  • Concepts — Core abstractions and mental models
  • Architecture — Component design and data flow
  • Guides — Testing, troubleshooting, and how-to
  • Reference — Complete API documentation
  • Types — Type definitions and configuration schema

Contributing

See CONTRIBUTING.md for guidelines. Run make help for available commands.

License

MIT License — see LICENSE for details.

Documentation

Overview

Package cldpd is an async pod lifecycle library for Claude Code agent teams.

Each zoobzio repository carries its own agent workflows, standing orders, and skills. cldpd spawns pods — Docker containers running Claude Code — pointed at GitHub issues, and returns a Session handle for non-blocking lifecycle management.

Basic usage

d := cldpd.NewDispatcher(podsDir, &cldpd.DockerRunner{})

session, err := d.Start(ctx, "myrepo", "https://github.com/org/repo/issues/42")
if err != nil {
    log.Fatal(err)
}

for event := range session.Events() {
    if event.Type == cldpd.EventOutput {
        fmt.Println(event.Data)
    }
}

code, err := session.Wait()

Session lifecycle

Dispatcher.Start builds the pod's Docker image synchronously, then returns a *Session immediately. The container runs in the background. The Session emits typed events on its Events() channel:

BuildStarted → BuildComplete → ContainerStarted → Output* → ContainerExited

Call session.Stop(ctx) for graceful shutdown (SIGTERM with timeout, then SIGKILL). Call session.Wait() to block until the container exits.

Pod definitions

Pods are directories under ~/.cldpd/pods/<name>/ containing:

  • Dockerfile (required) — the container image definition
  • pod.json (optional) — configuration: env, mounts, image tag, etc.
  • template.md (optional) — standing orders prepended to the prompt on Start

See PodConfig for available configuration fields including credential passthrough (InheritEnv, Mounts). Mount source paths beginning with ~/ are expanded to the user's home directory.

Constraints

cldpd uses the Docker CLI via os/exec and has no external dependencies beyond the Go standard library.

Index

Constants

This section is empty.

Variables

View Source
var ErrBuildFailed = errors.New("image build failed")

ErrBuildFailed is returned when the Docker image build exits with a non-zero status.

View Source
var ErrContainerFailed = errors.New("container exited with error")

ErrContainerFailed is returned when a container exits with a non-zero status.

View Source
var ErrDockerUnavailable = errors.New("docker is not available")

ErrDockerUnavailable is returned when the Docker daemon cannot be reached.

View Source
var ErrInvalidPod = errors.New("invalid pod: Dockerfile not found")

ErrInvalidPod is returned when a pod directory exists but contains no Dockerfile.

View Source
var ErrPodNotFound = errors.New("pod not found")

ErrPodNotFound is returned when a pod directory does not exist.

View Source
var ErrSessionNotFound = errors.New("no running session for pod")

ErrSessionNotFound is returned when no running session exists for the given pod name.

View Source
var ErrStopFailed = errors.New("container stop failed")

ErrStopFailed is returned when docker stop exits with a non-zero status.

Functions

func DefaultPodsDir

func DefaultPodsDir() (string, error)

DefaultPodsDir returns the conventional pods directory: ~/.cldpd/pods/.

Types

type Dispatcher

type Dispatcher struct {
	// contains filtered or unexported fields
}

Dispatcher coordinates pod discovery, image building, and container lifecycle. Use NewDispatcher to create one.

Dispatcher is stateless — it does not track running sessions. Each returned *Session is self-contained. The caller is responsible for calling Stop or Wait.

func NewDispatcher

func NewDispatcher(podsDir string, runner Runner) *Dispatcher

NewDispatcher returns a Dispatcher that discovers pods from podsDir and executes Docker operations via runner.

func (*Dispatcher) Resume

func (d *Dispatcher) Resume(ctx context.Context, podName string, prompt string) (*Session, error)

Resume returns a *Session wrapping a follow-up exec into an already-running container for the named pod. Resume does not build an image.

The Session emits events in the following order:

ContainerStarted → Output* → ContainerExited

Returns ErrSessionNotFound if no container named cldpd-<podName> is running. The caller is responsible for calling session.Stop or session.Wait.

func (*Dispatcher) Start

func (d *Dispatcher) Start(ctx context.Context, podName string, issueURL string) (*Session, error)

Start builds the pod's Docker image synchronously, then returns a *Session representing the running container. The image build completes before Start returns — if the build fails, Start returns an error and no Session is created.

If the pod's template.md is non-empty, its contents are prepended to the prompt passed to Claude Code: template + "\n\n" + "Work on this GitHub issue: " + issueURL. When template.md is absent, the prompt is the issue URL directive alone.

The Session emits events in the following order:

BuildStarted → BuildComplete → ContainerStarted → Output* → ContainerExited

On build failure: BuildStarted → Error (no Session returned). On runtime failure: events up to ContainerStarted, then Output*, then Error.

The caller is responsible for calling session.Stop or session.Wait.

type DockerRunner

type DockerRunner struct{}

DockerRunner implements Runner using the Docker CLI via os/exec.

func (*DockerRunner) Build

func (d *DockerRunner) Build(ctx context.Context, tag string, dir string, buildArgs map[string]string) error

Build builds a Docker image tagged with tag from the Dockerfile in dir.

func (*DockerRunner) Exec

func (d *DockerRunner) Exec(ctx context.Context, container string, cmd []string, stdout io.Writer) (int, error)

Exec runs a command in an already-running container and streams its stdout. Returns ErrSessionNotFound if the container does not exist or is not running. For all other non-zero exits the exit code is returned with a nil error.

func (*DockerRunner) Preflight

func (d *DockerRunner) Preflight(ctx context.Context) error

Preflight checks that the Docker daemon is reachable by running docker info. Returns ErrDockerUnavailable if the daemon cannot be contacted.

func (*DockerRunner) Run

func (d *DockerRunner) Run(ctx context.Context, opts RunOptions, stdout io.Writer) (int, error)

Run starts a container with the given options, streams stdout, and blocks until the container exits. Returns the container's exit code.

func (*DockerRunner) Stop

func (d *DockerRunner) Stop(ctx context.Context, container string, timeout time.Duration) error

Stop sends SIGTERM to the named container via docker stop, waits up to timeout, then SIGKILL if needed. If the container is not found (already removed), returns nil. Returns ErrStopFailed if docker stop exits with a non-zero status for any other reason.

type Event

type Event struct {
	Time time.Time
	Data string
	Type EventType
	Code int
}

Event is a lifecycle or output event emitted by a Session.

Temporal ordering guarantees:

  • Successful start: BuildStarted → BuildComplete → ContainerStarted → Output* → ContainerExited
  • Build failure: BuildStarted → Error
  • Runtime failure: BuildStarted → BuildComplete → ContainerStarted → Output* → Error

After the terminal event (ContainerExited or Error), the channel is closed.

type EventType

type EventType int

EventType identifies the kind of event emitted by a Session.

const (
	// EventBuildStarted is emitted when the Docker image build begins.
	// Data contains the image tag.
	EventBuildStarted EventType = iota

	// EventBuildComplete is emitted when the Docker image build succeeds.
	// Data contains the image tag.
	EventBuildComplete

	// EventContainerStarted is emitted when the container begins running.
	// Data contains the container name.
	EventContainerStarted

	// EventOutput is emitted for each line of container stdout.
	// Data contains the line content.
	EventOutput

	// EventContainerExited is emitted when the container exits normally.
	// Code contains the container's exit code.
	EventContainerExited

	// EventError is emitted when a fatal error terminates the session.
	// Data contains the error message.
	EventError
)

type Mount

type Mount struct {
	Source   string // host path
	Target   string // container path
	ReadOnly bool
}

Mount describes a bind mount to pass to the container.

type Pod

type Pod struct {
	Name       string    // directory name, used as the pod identifier
	Dir        string    // absolute path to the pod directory
	Dockerfile string    // absolute path to the Dockerfile within Dir
	Template   string    // contents of template.md; empty string if absent
	Config     PodConfig // parsed from pod.json; zero-value if pod.json is absent
}

Pod is a discovered pod definition. It holds the pod name, the absolute path to its directory, the parsed configuration, the absolute path to its Dockerfile, and the optional template contents loaded from template.md.

func DiscoverAll

func DiscoverAll(podsDir string) ([]Pod, error)

DiscoverAll loads all valid pods from the given pods directory. Entries that are not directories, or directories without a Dockerfile, are skipped. The returned slice is sorted by pod name.

func DiscoverPod

func DiscoverPod(podsDir, name string) (Pod, error)

DiscoverPod loads a single pod by name from the given pods directory. It returns ErrPodNotFound if the pod directory does not exist, and ErrInvalidPod if the directory exists but contains no Dockerfile. If pod.json is absent the pod is returned with a zero-value PodConfig. If pod.json is present but malformed, an error is returned. Mount source paths beginning with ~ or ~/ are expanded to the user's home directory. ~user expansion is not supported. If template.md is absent, Pod.Template is an empty string. If template.md is present but cannot be read, an error is returned.

type PodConfig

type PodConfig struct {
	Env        map[string]string `json:"env"`        // environment variables passed to the container
	BuildArgs  map[string]string `json:"buildArgs"`  // --build-arg values passed to docker build
	Image      string            `json:"image"`      // Docker image tag; defaults to cldpd-<name> if empty
	Workdir    string            `json:"workdir"`    // working directory inside the container
	InheritEnv []string          `json:"inheritEnv"` // host env var names to forward to the container
	Mounts     []Mount           `json:"mounts"`     // bind mounts to pass to the container
}

PodConfig holds the optional configuration parsed from a pod's pod.json file. All fields are optional; absent values use zero values (empty string, nil map, nil slice).

type RunOptions

type RunOptions struct {
	Env        map[string]string // environment variables (-e K=V)
	Image      string            // Docker image to run
	Name       string            // container name (--name); used for deterministic resume
	Workdir    string            // working directory inside the container (-w)
	Cmd        []string          // command and arguments to run inside the container
	InheritEnv []string          // host env var names to forward as -e NAME=VALUE
	Mounts     []Mount           // bind mounts (-v source:target[:ro])
	Remove     bool              // remove the container after it exits (--rm)
}

RunOptions configures a docker run invocation.

type Runner

type Runner interface {
	// Preflight checks that the Docker daemon is reachable.
	// Returns ErrDockerUnavailable if the daemon cannot be contacted.
	Preflight(ctx context.Context) error

	// Build builds a Docker image tagged with tag from the Dockerfile in dir.
	// buildArgs are passed as --build-arg K=V flags.
	// Returns ErrBuildFailed if the build exits with a non-zero status.
	Build(ctx context.Context, tag string, dir string, buildArgs map[string]string) error

	// Run starts a container with the given options, streams its stdout to the
	// provided writer, blocks until the container exits, and returns the exit code.
	// A non-zero exit code is not itself an error — the caller interprets it.
	Run(ctx context.Context, opts RunOptions, stdout io.Writer) (int, error)

	// Exec runs a command in an already-running container, streams its stdout
	// to the provided writer, blocks until the command exits, and returns the exit code.
	// Returns ErrSessionNotFound if the container is not running.
	Exec(ctx context.Context, container string, cmd []string, stdout io.Writer) (int, error)

	// Stop sends SIGTERM to the named container via docker stop, waits up to timeout,
	// then SIGKILL if needed. Returns ErrStopFailed on non-zero exit from docker stop.
	// If the container is not found (already removed), Stop returns nil.
	Stop(ctx context.Context, container string, timeout time.Duration) error
}

Runner is the interface over Docker CLI operations. All methods block until the operation completes and stream output to the provided io.Writer where applicable.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session represents an active pod lifecycle. It is returned by Dispatcher.Start and Dispatcher.Resume. The caller owns the Session and is responsible for calling Stop or Wait.

Events and Wait are independent consumption paths — neither requires the other. Stop is idempotent.

func (*Session) Events

func (s *Session) Events() <-chan Event

Events returns a receive-only channel of typed events. The channel is closed after the terminal event (ContainerExited or Error). Callers may range over this channel to consume the full event stream.

Consuming Events() is optional. Wait() returns as soon as the container exits, independent of whether Events() is consumed. Under high output volume, output events may be dropped if the buffer fills; the terminal event may also be dropped if the buffer is full when the container exits, but the channel is always closed as the definitive terminal signal.

func (*Session) ID

func (s *Session) ID() string

ID returns the unique session identifier.

func (*Session) Stop

func (s *Session) Stop(ctx context.Context) error

Stop initiates graceful shutdown of the container. It calls runner.Stop with a 10-second SIGTERM timeout, then blocks until the container goroutine exits or ctx expires.

Stop is idempotent: calling it on an already-stopped session returns nil immediately.

func (*Session) Wait

func (s *Session) Wait() (int, error)

Wait blocks until the container exits and returns its exit code and any process-level error. A non-zero exit code does not itself produce an error here — check the returned code.

Wait is independent of Events: it can be called without consuming the event channel.

Directories

Path Synopsis
cmd
cldpd command
Command cldpd dispatches Claude Code agent teams to Docker containers.
Command cldpd dispatches Claude Code agent teams to Docker containers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL