Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ba3577c
feat(stream): add internal/stream package with shared message types
thruflo Jan 20, 2026
ae79767
feat(stream): add FileStore for persistent event storage
thruflo Jan 20, 2026
3776548
feat(stream): add StreamClient for HTTP-based stream consumption
thruflo Jan 20, 2026
512366d
feat(spriteloop): add core loop logic for Sprite VM execution
thruflo Jan 20, 2026
69e26ef
feat(spriteloop): add Claude stream-json output parsing
thruflo Jan 20, 2026
7620f6f
feat(spriteloop): add CommandProcessor for stream command handling
thruflo Jan 20, 2026
036ed13
feat(spriteloop): add HTTP server for stream and commands
thruflo Jan 20, 2026
6fd2d4f
feat(wisp-sprite): add binary entry point for Sprite VM execution
thruflo Jan 20, 2026
f932b22
feat(makefile): add build-sprite target for cross-compiling wisp-sprite
thruflo Jan 20, 2026
680539e
feat(cli/start): add functions to upload and start wisp-sprite binary
thruflo Jan 20, 2026
5de6ff9
feat(cli/resume): add functions to reconnect to running wisp-sprite
thruflo Jan 20, 2026
8737558
refactor(tui): add stream client integration for remote Sprite commun…
thruflo Jan 20, 2026
6307cfd
refactor(server): add relay mode for forwarding Sprite events to web …
thruflo Jan 20, 2026
b3ed24a
refactor(loop): simplify to orchestration-only role
thruflo Jan 21, 2026
acddecb
docs(loop): update package doc to reflect orchestration-only role
thruflo Jan 21, 2026
ee3929a
test(spriteloop): add unit tests to achieve >80% coverage
thruflo Jan 21, 2026
42ff99d
test(integration): add disconnect/reconnect integration tests
thruflo Jan 21, 2026
a600f24
docs(AGENTS.md): add durable stream architecture documentation
thruflo Jan 21, 2026
5eac42d
refactor(stream): replace custom FileStore with durable-streams store
thruflo Jan 23, 2026
82f4264
refactor(stream): implement State Protocol event format
thruflo Jan 23, 2026
93cb010
refactor(stream): replace custom client with durable-streams protocol
thruflo Jan 23, 2026
333b355
refactor(input): replace polling with bidirectional state sync
thruflo Jan 23, 2026
58640ec
refactor(server): implement durable-streams HTTP protocol
thruflo Jan 23, 2026
45af18d
refactor(cli): extract SetupSprite duplication into shared sprite.go
thruflo Jan 23, 2026
db4a6dc
fix(server): replace CORS Allow-Origin: * with configurable origins
thruflo Jan 23, 2026
6b0fe5f
fix(server): add rate limiting to /auth endpoint
thruflo Jan 23, 2026
0012ca4
feat(logging): add structured logging for non-fatal errors
thruflo Jan 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,67 @@ runs Claude until completion or blockage, produces a PR.
## Project structure

```
cmd/wisp/ # main entry point
cmd/
wisp/ # main CLI entry point
wisp-sprite/ # binary that runs on Sprite VM
internal/
cli/ # command implementations
config/ # configuration loading
session/ # session management
loop/ # orchestration-only loop (manages Sprite lifecycle)
server/ # web server for browser-based UI
spriteloop/ # iteration loop running on Sprite VM
sprite/ # Sprite client wrapper
state/ # state file handling
stream/ # durable stream types and client
tui/ # terminal UI
pkg/ # public API (if any)
```

## Durable stream architecture

Wisp uses a durable stream architecture to ensure Claude output is never lost
during network disconnections. The key components are:

### Stream package (`internal/stream`)

- `types.go` - Event types: session, task, claude_event, input_request, command, ack
- `filestore.go` - FileStore for persisting events as NDJSON on Sprite
- `client.go` - StreamClient for HTTP/SSE-based event consumption

### Spriteloop package (`internal/spriteloop`)

Runs on the Sprite VM via the `wisp-sprite` binary:

- `loop.go` - Core iteration logic (previously in internal/loop)
- `claude.go` - Claude process execution and output streaming
- `commands.go` - Command processing (kill, background, input_response)
- `server.go` - HTTP server exposing /stream, /command, /state endpoints

### Event flow

```
[Sprite VM] [Client]
Claude process TUI / Web
↓ output
FileStore.Append() StreamClient.Subscribe()
↓ writes ↑ reads
stream.ndjson ←─────────── SSE ──────────────┘
```

### Message types

```go
// Sprite → Client
MessageTypeSession // session state update
MessageTypeTask // task state update
MessageTypeClaudeEvent // Claude output event
MessageTypeInputRequest // request for user input
MessageTypeAck // command acknowledgment

// Client → Sprite
MessageTypeCommand // kill, background, input_response
```

## Go conventions

- Go 1.21+ with modules
Expand Down Expand Up @@ -82,6 +132,18 @@ When you build:
- always use `go install ./cmd/wisp`
- never use `go build -o wisp ./cmd/wisp`

### Cross-compiling wisp-sprite

The `wisp-sprite` binary runs on Sprite VMs (Linux/amd64). Build with:

```bash
make build-sprite
# or manually:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/wisp-sprite ./cmd/wisp-sprite
```

The binary is statically linked (no CGO) to run in the minimal Sprite environment.

## Testing

- Table-driven tests
Expand Down Expand Up @@ -114,15 +176,18 @@ func TestParseState(t *testing.T) {
}
```

Integration tests in `integration_test.go` with build tag:
Integration tests in `internal/integration/` with build tag:

```go
//go:build integration

func TestFullWorkflow(t *testing.T) { ... }
```

Run with: `go test -tags=integration ./...`
Run with: `go test -tags=integration ./internal/integration/...`

Key integration tests:
- `stream_reconnect_test.go` - Tests disconnect/reconnect scenarios for durable streams

IMPORTANT: When you run integration tests that could hang, make sure you use tight timeouts.

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-integration test-real-sprites test-e2e cleanup-test-sprites clean
.PHONY: build build-sprite test test-integration test-real-sprites test-e2e cleanup-test-sprites clean

# Default Go build flags
GOFLAGS ?= -v
Expand All @@ -8,6 +8,11 @@ build:
go build $(GOFLAGS) -o wisp ./cmd/wisp
go build $(GOFLAGS) -o cleanup-test-sprites ./cmd/cleanup-test-sprites

# Cross-compile wisp-sprite for Linux/amd64 (for Sprite VM deployment)
build-sprite:
@mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o bin/wisp-sprite ./cmd/wisp-sprite

# Run unit tests
test:
go test ./...
Expand Down Expand Up @@ -35,4 +40,5 @@ cleanup-test-sprites-force:
# Clean build artifacts
clean:
rm -f wisp cleanup-test-sprites
rm -rf bin
go clean ./...
212 changes: 212 additions & 0 deletions cmd/wisp-sprite/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Package main provides the wisp-sprite binary entry point.
//
// wisp-sprite runs on the Sprite VM and executes the Claude Code iteration loop.
// It provides an HTTP server for streaming events and receiving commands from
// TUI and web clients. The loop continues until completion, blockage, or user
// action (kill/background).
//
// Usage:
//
// wisp-sprite [flags]
//
// Flags:
//
// -port HTTP server port (default: 8374)
// -session-dir Session files directory (default: /var/local/wisp/session)
// -work-dir Working directory for Claude (default: /var/local/wisp/repos)
// -template-dir Template directory (default: /var/local/wisp/templates)
// -token Bearer token for authentication (optional)
// -session-id Session identifier (default: branch name from session-dir)
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

"github.com/thruflo/wisp/internal/spriteloop"
"github.com/thruflo/wisp/internal/stream"
)

// Default paths on the Sprite VM.
const (
defaultPort = 8374
defaultSessionDir = "/var/local/wisp/session"
defaultRepoPath = "/var/local/wisp/repos"
defaultTemplateDir = "/var/local/wisp/templates"
streamFileName = "stream.ndjson"
)

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}

func run() error {
// Parse command-line flags
var (
port = flag.Int("port", defaultPort, "HTTP server port")
sessionDir = flag.String("session-dir", defaultSessionDir, "Session files directory")
workDir = flag.String("work-dir", defaultRepoPath, "Working directory for Claude")
templateDir = flag.String("template-dir", defaultTemplateDir, "Template files directory")
token = flag.String("token", "", "Bearer token for authentication")
sessionID = flag.String("session-id", "", "Session identifier")
)
flag.Parse()

// Derive session ID from session directory if not provided
sid := *sessionID
if sid == "" {
// Try to read from session state or use directory name
sid = filepath.Base(*sessionDir)
if sid == "session" {
sid = "default"
}
}

// Validate directories exist
if err := validateDir(*sessionDir); err != nil {
return fmt.Errorf("invalid session-dir: %w", err)
}
if err := validateDir(*workDir); err != nil {
return fmt.Errorf("invalid work-dir: %w", err)
}
if err := validateDir(*templateDir); err != nil {
return fmt.Errorf("invalid template-dir: %w", err)
}

// Create context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Setup signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("Received signal %v, shutting down...", sig)
cancel()
}()

// Initialize FileStore for event persistence
streamPath := filepath.Join(*sessionDir, streamFileName)
fileStore, err := stream.NewFileStore(streamPath)
if err != nil {
return fmt.Errorf("failed to create FileStore: %w", err)
}
defer fileStore.Close()

log.Printf("FileStore initialized at %s (last seq: %d)", streamPath, fileStore.LastSeq())

// Create command and input channels
commandCh := make(chan *stream.Command, 10)
inputCh := make(chan string, 1)

// Create the Loop
executor := spriteloop.NewLocalExecutor()
loop := spriteloop.NewLoop(spriteloop.LoopOptions{
SessionID: sid,
RepoPath: *workDir,
SessionDir: *sessionDir,
TemplateDir: *templateDir,
Limits: spriteloop.DefaultLimits(),
ClaudeConfig: spriteloop.DefaultClaudeConfig(),
FileStore: fileStore,
Executor: executor,
StartTime: time.Now(),
})

// Create the CommandProcessor
cmdProcessor := spriteloop.NewCommandProcessor(spriteloop.CommandProcessorOptions{
FileStore: fileStore,
CommandCh: commandCh,
InputCh: inputCh,
})

// Create the HTTP Server
server := spriteloop.NewServer(spriteloop.ServerOptions{
Port: *port,
Token: *token,
FileStore: fileStore,
CommandProcessor: cmdProcessor,
Loop: loop,
})

// Start the HTTP server in background
if err := server.Start(); err != nil {
return fmt.Errorf("failed to start HTTP server: %w", err)
}
log.Printf("HTTP server started on port %d", server.Port())

// Start command processor in background
cmdCtx, cmdCancel := context.WithCancel(ctx)
defer cmdCancel()
go func() {
if err := cmdProcessor.Run(cmdCtx); err != nil && err != context.Canceled {
log.Printf("CommandProcessor error: %v", err)
}
}()

// Route commands from HTTP server to loop
go func() {
for {
select {
case <-ctx.Done():
return
case cmd := <-commandCh:
select {
case loop.CommandCh() <- cmd:
case <-ctx.Done():
return
}
}
}
}()

// Run the main loop
log.Printf("Starting iteration loop for session %s", sid)
result := loop.Run(ctx)

// Graceful shutdown of HTTP server
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := server.Stop(shutdownCtx); err != nil {
log.Printf("Error stopping HTTP server: %v", err)
}

// Log result
log.Printf("Loop completed: reason=%s, iterations=%d", result.Reason, result.Iterations)
if result.Error != nil {
log.Printf("Loop error: %v", result.Error)
}

// Return error for crash exit
if result.Reason == spriteloop.ExitReasonCrash {
return fmt.Errorf("loop crashed: %w", result.Error)
}

return nil
}

// validateDir checks if a directory exists and is accessible.
func validateDir(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", path)
}
return err
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", path)
}
return nil
}
3 changes: 3 additions & 0 deletions internal/cli/abandon.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/spf13/cobra"
"github.com/thruflo/wisp/internal/config"
"github.com/thruflo/wisp/internal/logging"
"github.com/thruflo/wisp/internal/sprite"
"github.com/thruflo/wisp/internal/state"
)
Expand Down Expand Up @@ -125,6 +126,7 @@ func runAbandonAll(ctx context.Context, cwd string, store *state.Store) error {
fmt.Printf("\nAbandoning session '%s'...\n", session.Branch)
if err := abandonSession(ctx, cwd, store, session); err != nil {
fmt.Printf("Warning: failed to abandon session '%s': %v\n", session.Branch, err)
logging.Warn("failed to abandon session", "error", err, "branch", session.Branch)
}
}

Expand All @@ -147,6 +149,7 @@ func abandonSession(ctx context.Context, cwd string, store *state.Store, session
fmt.Printf("Deleting Sprite '%s'...\n", session.SpriteName)
if err := client.Delete(ctx, session.SpriteName); err != nil {
fmt.Printf("Warning: failed to delete Sprite: %v\n", err)
logging.Warn("failed to delete sprite", "error", err, "sprite", session.SpriteName, "branch", session.Branch)
} else {
fmt.Println("Sprite deleted.")
}
Expand Down
Loading
Loading