From 8b792e305b6c4de8cb6a9af8cd8c9d905cf3d674 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 10 Jan 2026 18:08:53 -0600 Subject: [PATCH 1/4] docs: add Sprites integration design document Explores using Sprites (sprites.dev) as isolated cloud execution environments for Claude instances. This could simplify or replace the current `taskd` cloud deployment approach. Key findings: - Sprites provide VM-level isolation with persistent filesystems - Designed specifically for AI agent workloads - ~$0.46 for a 4-hour Claude session - Could eliminate need for dedicated cloud server - Hook callbacks would work via HTTP Proposes phased implementation from PoC to full cloud mode. Co-Authored-By: Claude Opus 4.5 --- docs/sprites-integration-design.md | 348 +++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/sprites-integration-design.md diff --git a/docs/sprites-integration-design.md b/docs/sprites-integration-design.md new file mode 100644 index 00000000..7b3c7105 --- /dev/null +++ b/docs/sprites-integration-design.md @@ -0,0 +1,348 @@ +# Sprites Integration Design Document + +## Overview + +This document explores how [Sprites](https://sprites.dev) could be integrated into the `task` workflow system to provide isolated, cloud-based execution environments for Claude instances. Sprites are hardware-isolated Linux sandboxes with persistent filesystems, designed specifically for running AI agents like Claude Code. + +## Current Architecture + +### How Tasks Execute Today + +``` +Task Queue → Executor → tmux window → Claude CLI + ↓ + Git Worktree (isolation) + ↓ + Hooks → Status Updates → Database +``` + +The current system: +1. Creates a Git worktree for each task (file isolation) +2. Launches Claude in a tmux window within a `task-daemon` session +3. Uses Claude hooks to track status (PreToolUse, PostToolUse, Notification, Stop) +4. Runs on the same machine as the `task` daemon (local or remote server via `taskd`) + +### Current Limitations + +- **Resource contention**: All Claude instances share the same machine's resources +- **Security**: Claude runs with the same permissions as the user +- **Scalability**: Limited by single machine capacity +- **Isolation**: Only git worktrees provide isolation (no process/network isolation) +- **Cloud complexity**: Running `taskd` on a remote server requires full server setup + +## Sprites Overview + +### What Are Sprites? + +Sprites are Firecracker-based VMs that provide: +- **Hardware isolation**: Each sprite is a separate VM +- **Persistent filesystem**: ext4 filesystem that persists between runs +- **Fast checkpoints**: ~300ms snapshot creation, <1s restore +- **Network policies**: DNS-based egress filtering +- **HTTP endpoints**: Each sprite gets a unique URL +- **Resource flexibility**: Up to 8 CPUs, 16GB RAM per sprite + +### Pricing + +- CPU: $0.07/CPU-hour (6.25% minimum per second) +- Memory: $0.04375/GB-hour (250MB minimum per second) +- Storage: $0.00068/GB-hour +- Example: 4-hour Claude Code session ≈ $0.46 + +### API Capabilities + +``` +POST /v1/sprites - Create sprite +GET /v1/sprites - List sprites +GET /v1/sprites/{name} - Get sprite details +PUT /v1/sprites/{name} - Update sprite +DELETE /v1/sprites/{name} - Delete sprite +WebSocket /v1/sprites/{name}/exec - Execute commands with stdin/stdout streaming +``` + +## Integration Proposal + +### Architecture with Sprites + +``` +Task Queue → Executor → Sprites API + ↓ + Create Sprite Instance + ↓ + Clone repo + setup worktree + ↓ + Launch Claude in Sprite + ↓ + Stream output via WebSocket + ↓ + Hooks callback to task daemon + ↓ + Cleanup: push branch, delete sprite +``` + +### Key Benefits + +1. **True Isolation**: Each task runs in its own VM, not just a separate directory +2. **Security**: Network policies can restrict what Claude can access +3. **Scalability**: Spawn many sprites in parallel across Fly.io's infrastructure +4. **Simplified Cloud**: No need to maintain our own `taskd` server +5. **Cost Efficiency**: Pay only for actual usage, not idle server time +6. **Checkpoints**: Save sprite state for resumable tasks + +### Execution Modes + +#### Mode 1: Full Sprite Execution (Recommended) + +Each task gets a dedicated sprite that: +1. Clones the repository +2. Sets up the worktree and branch +3. Runs Claude with full isolation +4. Pushes changes when complete +5. Gets deleted after task completion + +```go +// Pseudocode for sprite-based execution +func (e *Executor) runClaudeSprite(ctx context.Context, task Task) execResult { + // Create sprite + sprite, err := sprites.Create(ctx, sprites.CreateParams{ + Name: fmt.Sprintf("task-%d-%s", task.ID, task.Slug), + }) + defer sprites.Delete(ctx, sprite.Name) + + // Setup environment + sprites.Exec(ctx, sprite.Name, fmt.Sprintf(` + git clone %s /workspace + cd /workspace + git checkout -b %s + # Install claude CLI + npm install -g @anthropic-ai/claude-code + `, task.RepoURL, task.BranchName)) + + // Run Claude with hooks configured to call back to our server + sprites.Exec(ctx, sprite.Name, fmt.Sprintf(` + export TASK_ID=%d + export TASK_CALLBACK_URL=%s + claude --chrome "%s" + `, task.ID, callbackURL, task.Prompt)) + + // Push results + sprites.Exec(ctx, sprite.Name, ` + git add -A + git commit -m "Task completion" + git push origin HEAD + `) +} +``` + +#### Mode 2: Hybrid Execution + +Use sprites only for untrusted or resource-intensive tasks: +- Keep local tmux execution for quick, trusted tasks +- Use sprites for tasks marked as "cloud" or "isolated" +- User can choose per-task or set project defaults + +#### Mode 3: Persistent Sprite Pool + +Maintain warm sprites with pre-configured environments: +- Create checkpoints with common tools installed +- Restore from checkpoint for faster startup +- Useful for projects with complex dependencies + +### Hook Integration + +Claude hooks need to communicate back to the task daemon. Options: + +**Option A: HTTP Callbacks** +```go +// Sprite-side hook script +hooks: + Stop: + - command: "curl -X POST $TASK_CALLBACK_URL/hook -d '{\"event\":\"Stop\",\"task_id\":$TASK_ID}'" +``` + +**Option B: Sprite HTTP Endpoint** +- Each sprite has a unique URL +- Task daemon polls sprite endpoint for status +- Less real-time but simpler + +**Option C: WebSocket Streaming** +- Maintain WebSocket connection to sprite's exec endpoint +- Parse Claude output in real-time +- Most responsive but more complex + +### Database and State Management + +The current SQLite database stays local. Sprites are ephemeral execution environments: + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Local Machine │ │ Sprites │ +│ │ │ │ +│ ┌───────────┐ │ HTTP │ ┌────────────┐ │ +│ │ task DB │◄─┼─────────┼──│ Claude │ │ +│ │ (SQLite) │ │ Hooks │ │ Instance │ │ +│ └───────────┘ │ │ └────────────┘ │ +│ │ │ │ +│ ┌───────────┐ │ API │ ┌────────────┐ │ +│ │ Executor │──┼─────────┼─►│ Sprite │ │ +│ └───────────┘ │ │ │ (VM) │ │ +└─────────────────┘ └──────────────────┘ +``` + +### Git Integration + +Sprites need git access: + +1. **SSH Keys**: Generate per-sprite keys or use deploy tokens +2. **HTTPS + Token**: Pass GitHub token as environment variable +3. **Sparse Checkout**: Only clone necessary files for large repos + +### Network Policies + +Sprites allow DNS-based egress filtering: + +```go +// Restrict Claude to only necessary services +sprites.SetPolicy(ctx, sprite.Name, sprites.Policy{ + AllowDomains: []string{ + "api.anthropic.com", // Claude API + "github.com", // Git operations + "registry.npmjs.org", // Package installation + // Project-specific domains + }, +}) +``` + +This is a significant security improvement over the current model. + +## Implementation Phases + +### Phase 1: Proof of Concept + +1. Add Sprites Go SDK dependency +2. Create `runClaudeSprite()` function alongside existing `runClaude()` +3. Add `--sprite` flag to `task execute` command +4. Test with simple tasks + +### Phase 2: Core Integration + +1. Add sprite configuration to database (API token, default settings) +2. Implement hook callbacks over HTTP +3. Add sprite status to task detail view +4. Handle sprite failures gracefully (fallback to local) + +### Phase 3: Advanced Features + +1. Checkpoint support for resumable tasks +2. Network policy configuration per project +3. Warm sprite pools for faster startup +4. Cost tracking and reporting + +### Phase 4: Full Cloud Mode + +1. Remove need for `taskd` server entirely +2. All execution happens on sprites +3. Database could be hosted (e.g., Turso) or synced +4. Truly serverless task execution + +## Configuration + +```yaml +# ~/.config/task/config.yaml +sprites: + enabled: true + token: "${SPRITES_TOKEN}" + default_mode: "hybrid" # local, sprite, hybrid + + # Resource defaults + resources: + cpus: 2 + memory_gb: 4 + + # Network policies + policies: + default: + - "api.anthropic.com" + - "github.com" + + # Per-project overrides + projects: + my-project: + mode: "sprite" + policies: + - "api.openai.com" # Additional allowed domain +``` + +## Trade-offs + +### Advantages + +| Aspect | Current (tmux) | With Sprites | +|--------|----------------|--------------| +| Isolation | Git worktree only | Full VM isolation | +| Security | User permissions | Network policies | +| Scalability | Single machine | Cloud infrastructure | +| Cost | Server uptime | Pay-per-use | +| Setup | Complex for cloud | API key only | +| Startup | Instant | ~2-5 seconds | + +### Disadvantages + +1. **Latency**: Sprite creation adds startup time (~2-5s) +2. **Connectivity**: Requires internet access +3. **Cost for heavy usage**: Many long-running tasks add up +4. **Complexity**: Another external dependency +5. **Debugging**: Harder to attach/debug remote sprites + +### When to Use Each + +**Use Local tmux when:** +- Quick iterations during development +- Tasks that need real-time user interaction +- No internet connectivity +- Cost optimization for heavy usage + +**Use Sprites when:** +- Running untrusted code +- Need strong isolation guarantees +- Scaling beyond single machine +- Simplified cloud deployment + +## Alternative Approaches Considered + +### 1. Keep Current Architecture + +Pros: Already works, no new dependencies +Cons: Limited isolation, scaling challenges + +### 2. Docker/Podman Containers + +Pros: Industry standard, local execution +Cons: Not as isolated as VMs, more setup required + +### 3. Other Cloud VMs (EC2, GCE) + +Pros: More control +Cons: More expensive, slower startup, more to manage + +### 4. Firecracker Directly + +Pros: Same tech as Sprites, more control +Cons: Significant infrastructure to build + +**Conclusion**: Sprites provides the right abstraction - VM-level isolation with minimal operational overhead, specifically designed for AI agent workloads. + +## Next Steps + +1. [ ] Set up Sprites account and get API token +2. [ ] Experiment with SDK in isolated branch +3. [ ] Prototype `runClaudeSprite()` function +4. [ ] Test hook callbacks over HTTP +5. [ ] Measure startup latency and costs +6. [ ] Document findings and refine approach + +## References + +- [Sprites API Documentation](https://sprites.dev/api) +- [Sprites Go SDK](https://github.com/superfly/sprites-go) +- [Current Executor Implementation](../internal/executor/executor.go) From 8a3c9c60a173e14c020e4175e189fe756c7ae20a Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 10 Jan 2026 19:28:31 -0600 Subject: [PATCH 2/4] feat: add Sprites integration for cloud-based Claude execution Add support for running Claude tasks on Fly.io Sprites - isolated cloud VMs that enable dangerous mode safely. Key features: - `task sprite init ` - Create sprite, clone repo, setup deps - `task sprite status` - Show sprite status for projects - `task sprite destroy ` - Delete a project's sprite - `task sprite attach ` - SSH into sprite's tmux session - `task sprite sync ` - Pull latest code, update deps - `task sprite checkpoint ` - Save sprite state Architecture: - One sprite per project (persistent dev environment) - Claude runs in dangerous mode inside isolated VM - Hook events stream via tail -f over WebSocket - User input sent via tmux send-keys Database: - Added sprite_name and sprite_status columns to projects table Executor: - Automatically uses sprite if project has one configured - Hook streaming via tail -f /tmp/task-hooks.log - Polls for completion, respects status from hooks Co-Authored-By: Claude Opus 4.5 --- cmd/task/main.go | 3 + cmd/task/sprite.go | 637 +++++++++++++++++++++++++++ docs/sprites-design.md | 191 ++++++++ docs/sprites-discussion.md | 311 +++++++++++++ docs/sprites-integration-design.md | 348 --------------- go.mod | 3 + go.sum | 6 + internal/db/sqlite.go | 3 + internal/db/tasks.go | 30 +- internal/executor/executor.go | 13 +- internal/executor/executor_sprite.go | 160 +++++++ internal/sprites/client.go | 313 +++++++++++++ 12 files changed, 1658 insertions(+), 360 deletions(-) create mode 100644 cmd/task/sprite.go create mode 100644 docs/sprites-design.md create mode 100644 docs/sprites-discussion.md delete mode 100644 docs/sprites-integration-design.md create mode 100644 internal/executor/executor_sprite.go create mode 100644 internal/sprites/client.go diff --git a/cmd/task/main.go b/cmd/task/main.go index dfb28208..0c73469d 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -1089,6 +1089,9 @@ Examples: // Cloud subcommand rootCmd.AddCommand(createCloudCommand()) + // Sprite subcommand (cloud execution via Fly.io Sprites) + rootCmd.AddCommand(createSpriteCommand()) + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go new file mode 100644 index 00000000..b93f1c27 --- /dev/null +++ b/cmd/task/sprite.go @@ -0,0 +1,637 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + sprites "github.com/superfly/sprites-go" +) + +// Sprite settings keys +const ( + SettingSpriteToken = "sprite_token" // Sprites API token +) + +// Sprite status values +const ( + SpriteStatusReady = "ready" + SpriteStatusCheckpointed = "checkpointed" + SpriteStatusError = "error" +) + +// Styles for sprite command output +var ( + spriteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#61AFEF")) + spriteCheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) + spritePendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) + spriteErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) +) + +// getSpriteClient creates a Sprites API client. +func getSpriteClient() (*sprites.Client, error) { + // First try environment variable + token := os.Getenv("SPRITES_TOKEN") + + // Fall back to database setting + if token == "" { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + defer database.Close() + + token, _ = database.GetSetting(SettingSpriteToken) + } + + if token == "" { + return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task config set sprite_token ") + } + + return sprites.New(token), nil +} + +// createSpriteCommand creates the sprite subcommand with all its children. +func createSpriteCommand() *cobra.Command { + spriteCmd := &cobra.Command{ + Use: "sprite", + Short: "Manage project sprites (cloud execution environments)", + Long: `Sprite management for running tasks in isolated cloud environments. + +Sprites are persistent, isolated Linux VMs that run Claude in dangerous mode safely. +Each project gets its own sprite with its development environment. + +Commands: + init - Initialize a sprite for a project + status - Show sprite status for projects + destroy - Delete a project's sprite + attach - Attach to a sprite's tmux session + sync - Sync code and dependencies to sprite`, + Run: func(cmd *cobra.Command, args []string) { + // Show sprite status by default + showSpriteStatus("") + }, + } + + // sprite init + initCmd := &cobra.Command{ + Use: "init ", + Short: "Initialize a sprite for a project", + Long: `Create a new sprite for a project and set up the development environment. + +This will: +1. Create a new sprite VM +2. Clone the project repository +3. Install dependencies (detected automatically) +4. Create an initial checkpoint +5. Mark the project for sprite execution`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteInit(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(initCmd) + + // sprite status + statusCmd := &cobra.Command{ + Use: "status [project]", + Short: "Show sprite status for projects", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + project := "" + if len(args) > 0 { + project = args[0] + } + showSpriteStatus(project) + }, + } + spriteCmd.AddCommand(statusCmd) + + // sprite destroy + destroyCmd := &cobra.Command{ + Use: "destroy ", + Short: "Delete a project's sprite", + Long: `Destroy the sprite VM for a project. This will delete all data on the sprite.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteDestroy(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(destroyCmd) + + // sprite attach + attachCmd := &cobra.Command{ + Use: "attach ", + Short: "Attach to a sprite's tmux session", + Long: `Open an interactive shell to the sprite and attach to the tmux session.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + taskID, _ := cmd.Flags().GetInt64("task") + if err := runSpriteAttach(args[0], taskID); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + attachCmd.Flags().Int64P("task", "t", 0, "Attach to a specific task window") + spriteCmd.AddCommand(attachCmd) + + // sprite sync + syncCmd := &cobra.Command{ + Use: "sync ", + Short: "Sync code and dependencies to sprite", + Long: `Pull latest code and reinstall dependencies if needed.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteSync(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(syncCmd) + + // sprite checkpoint + checkpointCmd := &cobra.Command{ + Use: "checkpoint ", + Short: "Create a checkpoint of the sprite", + Long: `Save the current state of the sprite for later restoration.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runSpriteCheckpoint(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + }, + } + spriteCmd.AddCommand(checkpointCmd) + + return spriteCmd +} + +// showSpriteStatus displays sprite status for one or all projects. +func showSpriteStatus(projectName string) { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + var projects []*db.Project + if projectName != "" { + p, err := database.GetProjectByName(projectName) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + if p == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Project not found: "+projectName)) + os.Exit(1) + } + projects = []*db.Project{p} + } else { + projects, err = database.ListProjects() + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + } + + fmt.Println(spriteTitleStyle.Render("Sprite Status")) + fmt.Println() + + hasSprites := false + for _, p := range projects { + if p.SpriteName == "" { + continue + } + hasSprites = true + + statusIcon := "●" + statusStyle := spriteCheckStyle + switch p.SpriteStatus { + case SpriteStatusReady: + statusStyle = spriteCheckStyle + case SpriteStatusCheckpointed: + statusStyle = spritePendingStyle + case SpriteStatusError: + statusStyle = spriteErrorStyle + default: + statusStyle = dimStyle + } + + fmt.Printf(" %s %s\n", boldStyle.Render(p.Name), statusStyle.Render(statusIcon+" "+p.SpriteStatus)) + fmt.Printf(" Sprite: %s\n", dimStyle.Render(p.SpriteName)) + } + + if !hasSprites { + fmt.Println(dimStyle.Render(" No sprites configured.")) + fmt.Println(dimStyle.Render(" Run 'task sprite init ' to create one.")) + } +} + +// runSpriteInit initializes a sprite for a project. +func runSpriteInit(projectName string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Get project + project, err := database.GetProjectByName(projectName) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + if project == nil { + return fmt.Errorf("project not found: %s", projectName) + } + + // Check if sprite already exists + if project.SpriteName != "" { + return fmt.Errorf("sprite already exists for project %s (name: %s)", projectName, project.SpriteName) + } + + // Get sprite client + client, err := getSpriteClient() + if err != nil { + return err + } + + // Generate sprite name + spriteName := fmt.Sprintf("task-%s-%d", projectName, time.Now().Unix()) + fmt.Printf("Creating sprite: %s\n", spriteName) + + // Create sprite + ctx := context.Background() + sprite, err := client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return fmt.Errorf("create sprite: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) + + // Get git remote URL + gitRemote, err := getGitRemote(project.Path) + if err != nil { + // Clean up sprite on failure + sprite.Destroy() + return fmt.Errorf("get git remote: %w", err) + } + + // Clone repository + fmt.Printf("Cloning repository: %s\n", gitRemote) + cmd := sprite.Command("git", "clone", gitRemote, "/workspace") + if output, err := cmd.CombinedOutput(); err != nil { + sprite.Destroy() + return fmt.Errorf("clone repository: %w\n%s", err, string(output)) + } + fmt.Println(spriteCheckStyle.Render("✓ Repository cloned")) + + // Detect and run setup commands + fmt.Println("Setting up development environment...") + setupCommands := detectSetupCommands(sprite) + for _, setupCmd := range setupCommands { + fmt.Printf(" Running: %s\n", setupCmd) + cmd := sprite.Command("sh", "-c", "cd /workspace && "+setupCmd) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + fmt.Printf(" %s\n", dimStyle.Render(string(output))) + } else { + fmt.Println(spriteCheckStyle.Render(" ✓ Done")) + } + } + + // Install Claude CLI + fmt.Println("Installing Claude CLI...") + cmd = sprite.Command("npm", "install", "-g", "@anthropic-ai/claude-code") + if _, err := cmd.CombinedOutput(); err != nil { + fmt.Printf(" %s: Could not install Claude CLI: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Claude CLI installed")) + } + + // Initialize tmux session + fmt.Println("Initializing tmux session...") + cmd = sprite.Command("tmux", "new-session", "-d", "-s", "task-daemon") + cmd.Run() // Ignore error if session exists + + // Create checkpoint + fmt.Println("Creating initial checkpoint...") + checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, "initial-setup") + if err != nil { + fmt.Printf(" %s: Could not create checkpoint: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + // Wait for checkpoint to complete + if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { + return nil // Just wait for completion + }); err != nil { + fmt.Printf(" %s: Checkpoint error: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Checkpoint created")) + } + } + + // Update project in database + project.SpriteName = spriteName + project.SpriteStatus = SpriteStatusReady + if err := database.UpdateProject(project); err != nil { + return fmt.Errorf("update project: %w", err) + } + + fmt.Println() + fmt.Println(spriteCheckStyle.Render("✓ Sprite ready!")) + fmt.Printf(" Tasks for %s will now execute on the sprite.\n", projectName) + fmt.Printf(" Use 'task execute --sprite' or 'task sprite attach %s'\n", projectName) + + return nil +} + +// detectSetupCommands checks for common project files and returns setup commands. +func detectSetupCommands(sprite *sprites.Sprite) []string { + var commands []string + + // Check for various project files + checks := []struct { + file string + command string + }{ + {"Gemfile", "bundle install"}, + {"package-lock.json", "npm ci"}, + {"yarn.lock", "yarn install --frozen-lockfile"}, + {"pnpm-lock.yaml", "pnpm install --frozen-lockfile"}, + {"requirements.txt", "pip install -r requirements.txt"}, + {"pyproject.toml", "pip install -e '.[dev]' 2>/dev/null || pip install -e ."}, + {"go.mod", "go mod download"}, + {"Cargo.toml", "cargo fetch"}, + {"bin/setup", "./bin/setup"}, + } + + for _, check := range checks { + cmd := sprite.Command("test", "-f", "/workspace/"+check.file) + if cmd.Run() == nil { + commands = append(commands, check.command) + } + } + + return commands +} + +// runSpriteDestroy destroys a project's sprite. +func runSpriteDestroy(projectName string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Get project + project, err := database.GetProjectByName(projectName) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + if project == nil { + return fmt.Errorf("project not found: %s", projectName) + } + + if project.SpriteName == "" { + return fmt.Errorf("no sprite configured for project: %s", projectName) + } + + // Get sprite client + client, err := getSpriteClient() + if err != nil { + return err + } + + fmt.Printf("Destroying sprite: %s\n", project.SpriteName) + + // Destroy sprite + ctx := context.Background() + if err := client.DestroySprite(ctx, project.SpriteName); err != nil { + // Log but continue - sprite might already be gone + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) + } + + // Update project in database + project.SpriteName = "" + project.SpriteStatus = "" + if err := database.UpdateProject(project); err != nil { + return fmt.Errorf("update project: %w", err) + } + + fmt.Println(spriteCheckStyle.Render("✓ Project updated")) + return nil +} + +// runSpriteAttach attaches to a sprite's tmux session. +func runSpriteAttach(projectName string, taskID int64) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Get project + project, err := database.GetProjectByName(projectName) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + if project == nil { + return fmt.Errorf("project not found: %s", projectName) + } + + if project.SpriteName == "" { + return fmt.Errorf("no sprite configured for project: %s", projectName) + } + + // Get sprite client + client, err := getSpriteClient() + if err != nil { + return err + } + + sprite := client.Sprite(project.SpriteName) + + // Build tmux command + tmuxCmd := "tmux attach -t task-daemon" + if taskID > 0 { + tmuxCmd = fmt.Sprintf("tmux select-window -t task-daemon:task-%d && tmux attach -t task-daemon", taskID) + } + + fmt.Printf("Attaching to sprite: %s\n", project.SpriteName) + fmt.Println(dimStyle.Render("Press Ctrl+B, D to detach")) + fmt.Println() + + // Run interactive command + cmd := sprite.Command("sh", "-c", tmuxCmd) + cmd.SetTTY(true) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// runSpriteSync syncs code and dependencies to a sprite. +func runSpriteSync(projectName string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Get project + project, err := database.GetProjectByName(projectName) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + if project == nil { + return fmt.Errorf("project not found: %s", projectName) + } + + if project.SpriteName == "" { + return fmt.Errorf("no sprite configured for project: %s", projectName) + } + + // Get sprite client + client, err := getSpriteClient() + if err != nil { + return err + } + + sprite := client.Sprite(project.SpriteName) + + fmt.Println("Syncing sprite...") + + // Get current HEAD before pull + cmd := sprite.Command("git", "-C", "/workspace", "rev-parse", "HEAD") + oldHead, _ := cmd.Output() + + // Pull latest + fmt.Println(" Pulling latest code...") + cmd = sprite.Command("git", "-C", "/workspace", "fetch", "origin") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("fetch: %w\n%s", err, string(output)) + } + + cmd = sprite.Command("git", "-C", "/workspace", "reset", "--hard", "origin/main") + if output, err := cmd.CombinedOutput(); err != nil { + // Try master if main doesn't exist + cmd = sprite.Command("git", "-C", "/workspace", "reset", "--hard", "origin/master") + if output2, err2 := cmd.CombinedOutput(); err2 != nil { + return fmt.Errorf("reset: %w\n%s", err, string(output)+"\n"+string(output2)) + } + } + fmt.Println(spriteCheckStyle.Render(" ✓ Code updated")) + + // Get new HEAD + cmd = sprite.Command("git", "-C", "/workspace", "rev-parse", "HEAD") + newHead, _ := cmd.Output() + + // Check if deps changed + if strings.TrimSpace(string(oldHead)) != strings.TrimSpace(string(newHead)) { + // Check for dependency file changes + depFiles := []string{"Gemfile.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "go.sum", "Cargo.lock"} + depsChanged := false + + for _, f := range depFiles { + cmd = sprite.Command("git", "-C", "/workspace", "diff", "--name-only", strings.TrimSpace(string(oldHead)), strings.TrimSpace(string(newHead)), "--", f) + if output, _ := cmd.Output(); len(strings.TrimSpace(string(output))) > 0 { + depsChanged = true + break + } + } + + if depsChanged { + fmt.Println(" Dependencies changed, reinstalling...") + setupCommands := detectSetupCommands(sprite) + for _, setupCmd := range setupCommands { + fmt.Printf(" Running: %s\n", setupCmd) + cmd := sprite.Command("sh", "-c", "cd /workspace && "+setupCmd) + if _, err := cmd.CombinedOutput(); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } + } + fmt.Println(spriteCheckStyle.Render(" ✓ Dependencies updated")) + } + } + + fmt.Println(spriteCheckStyle.Render("✓ Sync complete")) + return nil +} + +// runSpriteCheckpoint creates a checkpoint of the sprite. +func runSpriteCheckpoint(projectName string) error { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Get project + project, err := database.GetProjectByName(projectName) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + if project == nil { + return fmt.Errorf("project not found: %s", projectName) + } + + if project.SpriteName == "" { + return fmt.Errorf("no sprite configured for project: %s", projectName) + } + + // Get sprite client + client, err := getSpriteClient() + if err != nil { + return err + } + + sprite := client.Sprite(project.SpriteName) + ctx := context.Background() + + fmt.Printf("Creating checkpoint for %s...\n", project.SpriteName) + + checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, fmt.Sprintf("manual-%s", time.Now().Format("2006-01-02-150405"))) + if err != nil { + return fmt.Errorf("create checkpoint: %w", err) + } + + // Wait for checkpoint to complete + if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { + return nil // Just wait for completion + }); err != nil { + return fmt.Errorf("checkpoint failed: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Checkpoint created")) + + // Update status + project.SpriteStatus = SpriteStatusCheckpointed + if err := database.UpdateProject(project); err != nil { + return fmt.Errorf("update project: %w", err) + } + + return nil +} diff --git a/docs/sprites-design.md b/docs/sprites-design.md new file mode 100644 index 00000000..0ce63f11 --- /dev/null +++ b/docs/sprites-design.md @@ -0,0 +1,191 @@ +# Sprites Integration Design + +## Summary + +Use [Sprites](https://sprites.dev) (Fly.io's managed sandbox VMs) as isolated cloud execution environments for Claude. One sprite per project, persistent dev environment, dangerous mode enabled safely. + +## The Model + +**One sprite per project, not per task.** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Sprite: my-rails-app │ +│ │ +│ /workspace/ Persistent filesystem │ +│ ├── app/ (deps installed once) │ +│ ├── Gemfile.lock │ +│ └── .task-worktrees/ │ +│ ├── 42-fix-auth/ ← Task isolation │ +│ └── 43-add-feature/ │ +│ │ +│ tmux: task-daemon Same as local model │ +│ ├── task-42 (Claude, dangerous mode) │ +│ └── task-43 (Claude, dangerous mode) │ +│ │ +│ Network policy: github.com, api.anthropic.com, rubygems.org│ +└─────────────────────────────────────────────────────────────┘ +``` + +This mirrors the local architecture exactly. Same tmux session, same worktree isolation, same hooks. Just running remotely. + +## Why This Matters + +### Dangerous Mode Becomes Safe + +Currently `--dangerously-skip-permissions` is too risky—Claude has access to everything on your machine. In a sprite: + +- Claude can do anything... inside the sandbox +- Network restricted to whitelisted domains +- Can't touch other projects or your local files +- Worst case: destroy sprite, restore from checkpoint + +**Result:** Tasks execute without permission prompts. Much faster. + +### Simpler Than taskd + +Current cloud setup requires provisioning a VPS, configuring SSH/systemd, installing deps, keeping it updated, paying for idle time. + +Sprites setup: +```bash +$ task sprite init my-project +# Creates sprite, clones repo, installs deps, checkpoints +# Done. +``` + +### Persistent Dev Environment + +Setup happens once per project: +- `bundle install` runs during init +- Gems persist across tasks +- Checkpoint when idle (~$0.01/day storage) +- Restore in <1 second when needed + +## Architecture + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Local Machine │ │ Sprite │ +│ │ │ │ +│ task daemon │ │ tmux + Claude │ +│ ├── orchestrates │ │ ├── runs tasks │ +│ ├── stores DB │ │ ├── dangerous mode │ +│ └── serves TUI │ │ └── writes hooks │ +│ │ │ │ +│ TUI ◄── DB updates │ │ /tmp/task-hooks.log │ +│ │ │ │ │ +└────────────┬────────────┘ └─────────┼───────────────┘ + │ │ + │ sprites.Exec("tail -f") │ + └────────────────────────────────┘ + WebSocket stream +``` + +## Hook Communication + +**Problem:** Claude runs on sprite, but database is local. How do hooks sync? + +**Solution:** `tail -f` via Sprites exec WebSocket. + +``` +Sprite Local +────── ───── +Claude hook fires + │ + ▼ +echo '{"event":"Stop"}' >> + /tmp/task-hooks.log sprites.Exec("tail -f hooks.log") + │ + ▼ (~30ms latency) + Parse JSON + Update database + TUI refreshes +``` + +Hooks just append JSON to a file. Local daemon tails it over the existing WebSocket. Real-time, no extra infrastructure. + +### User Input (Reverse Direction) + +When Claude needs input, user responds in TUI: + +``` +TUI Sprite +─── ────── +User types "yes" + │ + ▼ +sprites.Exec("tmux send-keys + -t task-42 'yes' Enter") Claude receives keystroke + Continues working +``` + +Same mechanism we use locally—just remote tmux. + +**Round-trip latency:** ~100-150ms. Feels instant. + +## CLI Commands + +```bash +# Lifecycle +task sprite init # Create sprite, clone, setup, checkpoint +task sprite status [project] # Check sprite state +task sprite sync # Pull latest code, update deps if needed +task sprite attach # SSH + tmux attach (interactive) +task sprite checkpoint # Manual checkpoint +task sprite destroy # Delete sprite + +# Execution +task execute --sprite # Run task on sprite +task project edit --execution sprite # Make sprite the default +``` + +## Cost + +| Resource | Price | +|----------|-------| +| CPU | $0.07/CPU-hour | +| Memory | $0.04/GB-hour | +| Storage | $0.00068/GB-hour | + +**Medium sprite (2 CPU, 4GB):** ~$0.32/hour active + +| Usage Pattern | Monthly Cost | +|---------------|--------------| +| Light (2 hrs/day) | ~$19 | +| Moderate (4 hrs/day) | ~$35 | +| Heavy (6 hrs/day) | ~$63 | +| Idle (checkpoint only) | ~$5 | + +Comparable to a VPS for light/moderate use, more expensive for heavy use—but managed and isolated. + +## Trade-offs + +| Aspect | Local/taskd | Sprites | +|--------|-------------|---------| +| Isolation | Worktrees only | Full VM | +| Dangerous mode | Risky | Safe | +| Server management | You do it | Managed | +| Offline work | ✓ Works | ✗ Needs internet | +| Startup latency | Instant | ~1s (restore) | +| Cost model | Fixed | Pay-per-use | +| Vendor dependency | None | Fly.io | + +## Open Questions + +1. **Fly.io dependency acceptable?** It's opt-in, but still a vendor lock-in for that feature. + +2. **Git credentials in sprite?** SSH key per sprite? GitHub token passed at runtime? + +3. **Claude auth?** How does Claude authenticate inside the sprite? + +4. **Multi-user?** Shared sprite per project, or one per user? + +## Recommendation + +Build as an **experimental opt-in feature**: +- Keep local execution as default +- Keep taskd for users who prefer it +- Add `task sprite` commands for those who want managed cloud + isolation +- Gather feedback, iterate + +The per-project model reuses existing architecture (tmux, worktrees, hooks) while solving the "cloud without server ops" and "safe dangerous mode" problems. diff --git a/docs/sprites-discussion.md b/docs/sprites-discussion.md new file mode 100644 index 00000000..375dd0eb --- /dev/null +++ b/docs/sprites-discussion.md @@ -0,0 +1,311 @@ +# Sprites Integration: A Discussion + +*A dialogue between two perspectives on adopting Sprites for cloud-based Claude execution* + +--- + +## The Proposal + +Replace or augment the current `taskd` cloud execution model with Sprites - Fly.io's managed sandbox environments designed for AI agents. + +--- + +## Alex (Advocate for Sprites) + +### Opening Statement + +The current `taskd` approach requires users to provision and maintain their own cloud server. That's a significant barrier. You need to: + +1. Have a VPS or cloud instance running 24/7 +2. Configure SSH, systemd, and security +3. Keep the server updated and patched +4. Pay for idle time when no tasks are running +5. Manage SSH keys and GitHub credentials on the server + +With Sprites, we eliminate all of that. One API key, and you're running Claude in isolated VMs in the cloud. The `task cloud init` wizard becomes `task cloud login` - enter your Sprites token, done. + +### On the Fly.io Dependency + +Yes, users would need a Fly.io account. But consider what they need *now* for cloud execution: + +- A cloud provider account (AWS, DigitalOcean, Hetzner, etc.) +- SSH access configured +- A running server ($5-20/month minimum, always on) +- Technical knowledge to debug server issues + +Sprites trades one dependency for another - but it's a *managed* dependency. Fly.io handles: +- VM provisioning +- Security patching +- Network isolation +- Resource scaling + +The $30 free trial credit is enough for ~65 hours of Claude sessions. That's plenty to evaluate whether this works for your workflow. + +### On Cost + +Let's do the math: + +**Current cloud model (taskd on a VPS):** +- Minimum viable server: ~$5/month = $60/year +- That's 24/7 whether you use it or not +- Plus your time maintaining it + +**Sprites model:** +- 4-hour Claude session: ~$0.46 +- 130 four-hour sessions = $60 +- You only pay for what you use + +If you're running fewer than 130 substantial Claude sessions per year, Sprites is cheaper. If you're running more, the VPS makes sense - but at that volume, you probably want dedicated infrastructure anyway. + +### On Isolation and Security + +This is where Sprites really shines. Currently: + +- Claude runs with your user's full permissions +- It can access any file your user can access +- Network access is unrestricted +- A malicious prompt could theoretically exfiltrate data + +With Sprites: + +- Each task runs in a hardware-isolated VM +- Network policies can whitelist only necessary domains +- The sprite gets deleted after task completion +- No persistent access to your local machine + +This is *meaningful* security improvement. We're running an AI agent that can execute arbitrary code. Isolation matters. + +### On Complexity vs. Simplicity + +The current architecture is elegant for local use. But "run `taskd` on a server" introduces real complexity: + +- SSH tunneling for the TUI +- Database synchronization concerns +- Server maintenance burden +- Debugging remote issues + +Sprites simplifies this: your local `task` daemon orchestrates remote execution via a REST API. The complexity is Fly.io's problem, not yours. + +--- + +## Jordan (Skeptic / Devil's Advocate) + +### Opening Statement + +I appreciate the vision, but I have concerns about coupling core functionality to a third-party service. Let me push back on several points. + +### On the Fly.io Dependency + +This isn't just "a dependency" - it's a *hard* dependency on a specific vendor for core functionality. Consider: + +1. **What if Fly.io raises prices?** The $0.07/CPU-hour could become $0.14. Our users are locked in. + +2. **What if Fly.io goes down?** Their outages become our outages. Users can't execute cloud tasks at all. + +3. **What if Fly.io discontinues Sprites?** It's a relatively new product. If it doesn't work out for them, our users are stranded. + +4. **What about enterprise users?** Many companies won't approve sending code to a third-party service. They have their own cloud infrastructure. + +The current model (bring your own server) is vendor-agnostic. It works on any Linux box. That's a feature, not a bug. + +### On the "Simplicity" Argument + +Yes, `task cloud init` is complex. But it's *one-time* complexity that results in infrastructure you control. With Sprites: + +- Every task execution depends on network connectivity to Fly.io +- Every task execution depends on Sprites API being available +- You're sending your code and prompts through their infrastructure +- You're trusting their isolation claims + +"Simple" sometimes means "someone else's complexity that you can't inspect or control." + +### On Local Development Experience + +The current tmux model has a massive advantage: you can `tmux attach` and interact with Claude in real-time. You can see exactly what it's doing. You can type corrections mid-task. + +With Sprites, we lose that direct interactivity. Yes, we can stream output, but: + +- There's network latency on every keystroke +- Attaching to a remote sprite is more complex than `tmux attach` +- The debugging experience degrades + +For many users, the ability to watch and intervene is a core feature. + +### On Cost (A Different Perspective) + +The $0.46 per 4-hour session sounds cheap, but consider actual usage patterns: + +- Developer runs 5-10 tasks per day during active development +- Many tasks are quick iterations, but the sprite still needs to spin up +- Startup time (~2-5 seconds) adds friction to the workflow +- Failed tasks still cost money + +A $5/month VPS runs unlimited tasks with zero marginal cost. For active users, that math flips quickly. + +Also: the VPS runs 24/7, which means it can: +- Run scheduled tasks +- Process webhooks +- Serve as a persistent development environment + +A sprite is ephemeral by design. + +### On Security (The Other Side) + +The security argument cuts both ways: + +1. **You're sending code to Fly.io's infrastructure.** For open source projects, maybe that's fine. For proprietary code, that's a compliance conversation. + +2. **Sprites need git credentials.** Either you're passing tokens to each sprite (security risk) or setting up some credential proxy (complexity). + +3. **Hook callbacks need a reachable endpoint.** Either your local machine needs to be addressable from the internet (security risk) or you're polling (latency). + +The current model keeps everything on infrastructure you control. + +### On the "Right Tool" Question + +What problem are we actually solving? + +- If users want isolation, we could use local containers (Docker, Podman) +- If users want cloud execution, they can already use taskd +- If users want pay-per-use, they could run taskd on a spot instance + +Sprites solves a specific problem: "I want managed, isolated cloud execution with minimal setup." Is that problem common enough to justify the integration complexity and vendor lock-in? + +--- + +## Alex's Rebuttal + +### On Vendor Lock-in + +Fair point, but we're not proposing to *replace* local execution - we're *adding* an option. The architecture would be: + +``` +task execute --local # Current tmux model (default) +task execute --sprite # New Sprites model (opt-in) +task execute --cloud # Current taskd model (still works) +``` + +Users choose based on their needs. Vendor lock-in only applies if they choose the Sprites path. + +### On Enterprise Concerns + +Enterprise users probably aren't using `task` as-is anyway - they'd fork it and customize. But Sprites does have SOC 2 compliance (Fly.io is enterprise-ready). Still, point taken: we should keep taskd as an option. + +### On Interactivity + +This is my biggest concession. The tmux attach experience is genuinely better for interactive debugging. We could mitigate with: + +- Rich output streaming to the TUI +- A `task sprite attach` command that opens a shell to the sprite +- Keeping local execution as the default for development + +But yes, the experience is different. + +### On the Core Question + +The problem we're solving: "Cloud execution without server management." + +The current answer (`taskd`) works, but requires DevOps skills. Sprites lowers the barrier dramatically. That might expand who can use cloud execution from "people comfortable managing servers" to "anyone with a Fly.io account." + +--- + +## Jordan's Rebuttal + +### On "It's Optional" + +Optional features still have costs: + +1. **Maintenance burden:** Two execution paths to maintain, test, and debug +2. **Documentation complexity:** Users need to understand which mode to use when +3. **Cognitive overhead:** "Should I use local, sprite, or cloud?" + +Every feature we add is a feature we maintain forever. + +### On the User Base + +Let's be honest about who uses `task`: + +- Developers comfortable with CLI tools +- People who can navigate git worktrees +- Likely comfortable with basic server setup + +Is "I want cloud execution but can't manage a VPS" actually a common user profile? Or are we solving a theoretical problem? + +### On Alternatives + +Before committing to Sprites, shouldn't we consider: + +1. **Improve taskd setup:** Make `task cloud init` even simpler, more reliable +2. **Docker-based local isolation:** Same security benefits, no external dependency +3. **Support multiple cloud backends:** Abstract an interface, let users plug in Sprites OR their own runners + +Option 3 is more work, but results in better architecture. If we build a proper "remote executor" abstraction, Sprites becomes one implementation - not the only one. + +--- + +## Synthesis: Where Does This Leave Us? + +### Points of Agreement + +1. **Cloud execution is valuable** - Both perspectives agree remote execution has its place +2. **Current taskd setup is complex** - There's room for improvement +3. **Isolation matters** - Running arbitrary AI-generated code in isolation is a good idea +4. **Local should stay default** - The tmux experience is core to the product + +### Points of Contention + +1. **Is vendor dependency acceptable?** - Depends on user priorities +2. **Is the simplicity worth the trade-offs?** - Subjective +3. **Is this solving a real problem?** - Needs user research + +### Possible Paths Forward + +**Path A: Full Sprites Integration** +- Add Sprites as a first-class execution option +- Accept the Fly.io dependency +- Keep local and taskd as alternatives +- Target: Users who want managed cloud without server ops + +**Path B: Abstract Remote Executor** +- Define a "RemoteExecutor" interface +- Implement Sprites as one backend +- Also support: Docker, Podman, SSH-to-server +- More work upfront, more flexibility long-term + +**Path C: Improve What We Have** +- Make taskd setup more reliable +- Add optional Docker isolation for local execution +- Skip the Sprites dependency entirely +- Focus on polishing existing features + +**Path D: Wait and See** +- Document the Sprites option in design docs +- Let users experiment manually if interested +- Revisit if there's demand +- Avoid premature optimization + +--- + +## Open Questions + +1. How many users actually want cloud execution today? +2. What's the typical task profile - many short tasks or few long ones? +3. Would users trust sending their code to Fly.io? +4. Is interactive debugging (tmux attach) essential or nice-to-have? +5. What's our maintenance bandwidth for new execution backends? + +--- + +## Conclusion + +Both perspectives have merit. The decision ultimately depends on: + +- **Target user profile:** How technical are they? What do they value? +- **Project priorities:** Simplicity vs. flexibility? Features vs. maintenance? +- **Risk tolerance:** Is vendor dependency acceptable? + +This isn't a clear-cut technical decision - it's a product direction question that deserves user input before we commit significant engineering effort. + +--- + +*Document created for discussion purposes. No decisions have been made.* diff --git a/docs/sprites-integration-design.md b/docs/sprites-integration-design.md deleted file mode 100644 index 7b3c7105..00000000 --- a/docs/sprites-integration-design.md +++ /dev/null @@ -1,348 +0,0 @@ -# Sprites Integration Design Document - -## Overview - -This document explores how [Sprites](https://sprites.dev) could be integrated into the `task` workflow system to provide isolated, cloud-based execution environments for Claude instances. Sprites are hardware-isolated Linux sandboxes with persistent filesystems, designed specifically for running AI agents like Claude Code. - -## Current Architecture - -### How Tasks Execute Today - -``` -Task Queue → Executor → tmux window → Claude CLI - ↓ - Git Worktree (isolation) - ↓ - Hooks → Status Updates → Database -``` - -The current system: -1. Creates a Git worktree for each task (file isolation) -2. Launches Claude in a tmux window within a `task-daemon` session -3. Uses Claude hooks to track status (PreToolUse, PostToolUse, Notification, Stop) -4. Runs on the same machine as the `task` daemon (local or remote server via `taskd`) - -### Current Limitations - -- **Resource contention**: All Claude instances share the same machine's resources -- **Security**: Claude runs with the same permissions as the user -- **Scalability**: Limited by single machine capacity -- **Isolation**: Only git worktrees provide isolation (no process/network isolation) -- **Cloud complexity**: Running `taskd` on a remote server requires full server setup - -## Sprites Overview - -### What Are Sprites? - -Sprites are Firecracker-based VMs that provide: -- **Hardware isolation**: Each sprite is a separate VM -- **Persistent filesystem**: ext4 filesystem that persists between runs -- **Fast checkpoints**: ~300ms snapshot creation, <1s restore -- **Network policies**: DNS-based egress filtering -- **HTTP endpoints**: Each sprite gets a unique URL -- **Resource flexibility**: Up to 8 CPUs, 16GB RAM per sprite - -### Pricing - -- CPU: $0.07/CPU-hour (6.25% minimum per second) -- Memory: $0.04375/GB-hour (250MB minimum per second) -- Storage: $0.00068/GB-hour -- Example: 4-hour Claude Code session ≈ $0.46 - -### API Capabilities - -``` -POST /v1/sprites - Create sprite -GET /v1/sprites - List sprites -GET /v1/sprites/{name} - Get sprite details -PUT /v1/sprites/{name} - Update sprite -DELETE /v1/sprites/{name} - Delete sprite -WebSocket /v1/sprites/{name}/exec - Execute commands with stdin/stdout streaming -``` - -## Integration Proposal - -### Architecture with Sprites - -``` -Task Queue → Executor → Sprites API - ↓ - Create Sprite Instance - ↓ - Clone repo + setup worktree - ↓ - Launch Claude in Sprite - ↓ - Stream output via WebSocket - ↓ - Hooks callback to task daemon - ↓ - Cleanup: push branch, delete sprite -``` - -### Key Benefits - -1. **True Isolation**: Each task runs in its own VM, not just a separate directory -2. **Security**: Network policies can restrict what Claude can access -3. **Scalability**: Spawn many sprites in parallel across Fly.io's infrastructure -4. **Simplified Cloud**: No need to maintain our own `taskd` server -5. **Cost Efficiency**: Pay only for actual usage, not idle server time -6. **Checkpoints**: Save sprite state for resumable tasks - -### Execution Modes - -#### Mode 1: Full Sprite Execution (Recommended) - -Each task gets a dedicated sprite that: -1. Clones the repository -2. Sets up the worktree and branch -3. Runs Claude with full isolation -4. Pushes changes when complete -5. Gets deleted after task completion - -```go -// Pseudocode for sprite-based execution -func (e *Executor) runClaudeSprite(ctx context.Context, task Task) execResult { - // Create sprite - sprite, err := sprites.Create(ctx, sprites.CreateParams{ - Name: fmt.Sprintf("task-%d-%s", task.ID, task.Slug), - }) - defer sprites.Delete(ctx, sprite.Name) - - // Setup environment - sprites.Exec(ctx, sprite.Name, fmt.Sprintf(` - git clone %s /workspace - cd /workspace - git checkout -b %s - # Install claude CLI - npm install -g @anthropic-ai/claude-code - `, task.RepoURL, task.BranchName)) - - // Run Claude with hooks configured to call back to our server - sprites.Exec(ctx, sprite.Name, fmt.Sprintf(` - export TASK_ID=%d - export TASK_CALLBACK_URL=%s - claude --chrome "%s" - `, task.ID, callbackURL, task.Prompt)) - - // Push results - sprites.Exec(ctx, sprite.Name, ` - git add -A - git commit -m "Task completion" - git push origin HEAD - `) -} -``` - -#### Mode 2: Hybrid Execution - -Use sprites only for untrusted or resource-intensive tasks: -- Keep local tmux execution for quick, trusted tasks -- Use sprites for tasks marked as "cloud" or "isolated" -- User can choose per-task or set project defaults - -#### Mode 3: Persistent Sprite Pool - -Maintain warm sprites with pre-configured environments: -- Create checkpoints with common tools installed -- Restore from checkpoint for faster startup -- Useful for projects with complex dependencies - -### Hook Integration - -Claude hooks need to communicate back to the task daemon. Options: - -**Option A: HTTP Callbacks** -```go -// Sprite-side hook script -hooks: - Stop: - - command: "curl -X POST $TASK_CALLBACK_URL/hook -d '{\"event\":\"Stop\",\"task_id\":$TASK_ID}'" -``` - -**Option B: Sprite HTTP Endpoint** -- Each sprite has a unique URL -- Task daemon polls sprite endpoint for status -- Less real-time but simpler - -**Option C: WebSocket Streaming** -- Maintain WebSocket connection to sprite's exec endpoint -- Parse Claude output in real-time -- Most responsive but more complex - -### Database and State Management - -The current SQLite database stays local. Sprites are ephemeral execution environments: - -``` -┌─────────────────┐ ┌──────────────────┐ -│ Local Machine │ │ Sprites │ -│ │ │ │ -│ ┌───────────┐ │ HTTP │ ┌────────────┐ │ -│ │ task DB │◄─┼─────────┼──│ Claude │ │ -│ │ (SQLite) │ │ Hooks │ │ Instance │ │ -│ └───────────┘ │ │ └────────────┘ │ -│ │ │ │ -│ ┌───────────┐ │ API │ ┌────────────┐ │ -│ │ Executor │──┼─────────┼─►│ Sprite │ │ -│ └───────────┘ │ │ │ (VM) │ │ -└─────────────────┘ └──────────────────┘ -``` - -### Git Integration - -Sprites need git access: - -1. **SSH Keys**: Generate per-sprite keys or use deploy tokens -2. **HTTPS + Token**: Pass GitHub token as environment variable -3. **Sparse Checkout**: Only clone necessary files for large repos - -### Network Policies - -Sprites allow DNS-based egress filtering: - -```go -// Restrict Claude to only necessary services -sprites.SetPolicy(ctx, sprite.Name, sprites.Policy{ - AllowDomains: []string{ - "api.anthropic.com", // Claude API - "github.com", // Git operations - "registry.npmjs.org", // Package installation - // Project-specific domains - }, -}) -``` - -This is a significant security improvement over the current model. - -## Implementation Phases - -### Phase 1: Proof of Concept - -1. Add Sprites Go SDK dependency -2. Create `runClaudeSprite()` function alongside existing `runClaude()` -3. Add `--sprite` flag to `task execute` command -4. Test with simple tasks - -### Phase 2: Core Integration - -1. Add sprite configuration to database (API token, default settings) -2. Implement hook callbacks over HTTP -3. Add sprite status to task detail view -4. Handle sprite failures gracefully (fallback to local) - -### Phase 3: Advanced Features - -1. Checkpoint support for resumable tasks -2. Network policy configuration per project -3. Warm sprite pools for faster startup -4. Cost tracking and reporting - -### Phase 4: Full Cloud Mode - -1. Remove need for `taskd` server entirely -2. All execution happens on sprites -3. Database could be hosted (e.g., Turso) or synced -4. Truly serverless task execution - -## Configuration - -```yaml -# ~/.config/task/config.yaml -sprites: - enabled: true - token: "${SPRITES_TOKEN}" - default_mode: "hybrid" # local, sprite, hybrid - - # Resource defaults - resources: - cpus: 2 - memory_gb: 4 - - # Network policies - policies: - default: - - "api.anthropic.com" - - "github.com" - - # Per-project overrides - projects: - my-project: - mode: "sprite" - policies: - - "api.openai.com" # Additional allowed domain -``` - -## Trade-offs - -### Advantages - -| Aspect | Current (tmux) | With Sprites | -|--------|----------------|--------------| -| Isolation | Git worktree only | Full VM isolation | -| Security | User permissions | Network policies | -| Scalability | Single machine | Cloud infrastructure | -| Cost | Server uptime | Pay-per-use | -| Setup | Complex for cloud | API key only | -| Startup | Instant | ~2-5 seconds | - -### Disadvantages - -1. **Latency**: Sprite creation adds startup time (~2-5s) -2. **Connectivity**: Requires internet access -3. **Cost for heavy usage**: Many long-running tasks add up -4. **Complexity**: Another external dependency -5. **Debugging**: Harder to attach/debug remote sprites - -### When to Use Each - -**Use Local tmux when:** -- Quick iterations during development -- Tasks that need real-time user interaction -- No internet connectivity -- Cost optimization for heavy usage - -**Use Sprites when:** -- Running untrusted code -- Need strong isolation guarantees -- Scaling beyond single machine -- Simplified cloud deployment - -## Alternative Approaches Considered - -### 1. Keep Current Architecture - -Pros: Already works, no new dependencies -Cons: Limited isolation, scaling challenges - -### 2. Docker/Podman Containers - -Pros: Industry standard, local execution -Cons: Not as isolated as VMs, more setup required - -### 3. Other Cloud VMs (EC2, GCE) - -Pros: More control -Cons: More expensive, slower startup, more to manage - -### 4. Firecracker Directly - -Pros: Same tech as Sprites, more control -Cons: Significant infrastructure to build - -**Conclusion**: Sprites provides the right abstraction - VM-level isolation with minimal operational overhead, specifically designed for AI agent workloads. - -## Next Steps - -1. [ ] Set up Sprites account and get API token -2. [ ] Experiment with SDK in isolated branch -3. [ ] Prototype `runClaudeSprite()` function -4. [ ] Test hook callbacks over HTTP -5. [ ] Measure startup latency and costs -6. [ ] Document findings and refine approach - -## References - -- [Sprites API Documentation](https://sprites.dev/api) -- [Sprites Go SDK](https://github.com/superfly/sprites-go) -- [Current Executor Implementation](../internal/executor/executor.go) diff --git a/go.mod b/go.mod index 732a7d25..efa97dbf 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -44,6 +45,7 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -60,6 +62,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/go.sum b/go.sum index 56d0eb24..d0b5c5c8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -83,6 +85,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -129,6 +133,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931 h1:/aoLHUu5q1D2k6Zrh4ueNoUmSx5hxtqYMCtWCBFs/Q8= +github.com/superfly/sprites-go v0.0.0-20260109202230-abba9310f931/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index aa1231bc..c739e74b 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -190,6 +190,9 @@ func (db *DB) migrate() error { `ALTER TABLE projects ADD COLUMN actions TEXT DEFAULT '[]'`, `ALTER TABLE tasks ADD COLUMN worktree_path TEXT DEFAULT ''`, `ALTER TABLE tasks ADD COLUMN branch_name TEXT DEFAULT ''`, + // Sprite support: cloud execution environments + `ALTER TABLE projects ADD COLUMN sprite_name TEXT DEFAULT ''`, + `ALTER TABLE projects ADD COLUMN sprite_status TEXT DEFAULT ''`, } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 16fdbfd0..527c13db 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -493,6 +493,8 @@ type Project struct { Aliases string // comma-separated Instructions string // project-specific instructions for AI Actions []ProjectAction // actions triggered on task events (stored as JSON) + SpriteName string // name of the sprite for cloud execution + SpriteStatus string // sprite status: "", "ready", "checkpointed", "error" CreatedAt LocalTime } @@ -510,9 +512,9 @@ func (p *Project) GetAction(trigger string) *ProjectAction { func (db *DB) CreateProject(p *Project) error { actionsJSON, _ := json.Marshal(p.Actions) result, err := db.Exec(` - INSERT INTO projects (name, path, aliases, instructions, actions) - VALUES (?, ?, ?, ?, ?) - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON)) + INSERT INTO projects (name, path, aliases, instructions, actions, sprite_name, sprite_status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.SpriteName, p.SpriteStatus) if err != nil { return fmt.Errorf("insert project: %w", err) } @@ -525,9 +527,9 @@ func (db *DB) CreateProject(p *Project) error { func (db *DB) UpdateProject(p *Project) error { actionsJSON, _ := json.Marshal(p.Actions) _, err := db.Exec(` - UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ? + UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, sprite_name = ?, sprite_status = ? WHERE id = ? - `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.ID) + `, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.SpriteName, p.SpriteStatus, p.ID) if err != nil { return fmt.Errorf("update project: %w", err) } @@ -558,7 +560,8 @@ func (db *DB) DeleteProject(id int64) error { // ListProjects returns all projects, with "personal" always first. func (db *DB) ListProjects() ([]*Project, error) { rows, err := db.Query(` - SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at FROM projects ORDER BY CASE WHEN name = 'personal' THEN 0 ELSE 1 END, name `) if err != nil { @@ -570,7 +573,7 @@ func (db *DB) ListProjects() ([]*Project, error) { for rows.Next() { p := &Project{} var actionsJSON string - if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } json.Unmarshal([]byte(actionsJSON), &p.Actions) @@ -585,9 +588,10 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { p := &Project{} var actionsJSON string err := db.QueryRow(` - SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at FROM projects WHERE name = ? - `, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt) + `, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt) if err == nil { json.Unmarshal([]byte(actionsJSON), &p.Actions) return p, nil @@ -597,7 +601,11 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { } // Try alias match - rows, err := db.Query(`SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), created_at FROM projects`) + rows, err := db.Query(` + SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), + COALESCE(sprite_name, ''), COALESCE(sprite_status, ''), created_at + FROM projects + `) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } @@ -605,7 +613,7 @@ func (db *DB) GetProjectByName(name string) (*Project, error) { for rows.Next() { p := &Project{} - if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.SpriteName, &p.SpriteStatus, &p.CreatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } json.Unmarshal([]byte(actionsJSON), &p.Actions) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 9c69d873..a8b52a1b 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -378,9 +378,20 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { // Build prompt based on task type prompt := e.buildPrompt(task, attachmentPaths) + // Check if project has a sprite configured for cloud execution + var project *db.Project + if task.Project != "" { + project, _ = e.db.GetProjectByName(task.Project) + } + useSprite := project != nil && project.SpriteName != "" && + (project.SpriteStatus == "ready" || project.SpriteStatus == "checkpointed") + // Run Claude var result execResult - if isRetry { + if useSprite { + // Run on sprite (cloud execution with dangerous mode) + result = e.runClaudeOnSprite(taskCtx, task, project, workDir, prompt) + } else if isRetry { e.logLine(task.ID, "system", "Resuming previous session with feedback") result = e.runClaudeResume(taskCtx, task.ID, workDir, prompt, retryFeedback) } else { diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go new file mode 100644 index 00000000..f7af53a2 --- /dev/null +++ b/internal/executor/executor_sprite.go @@ -0,0 +1,160 @@ +package executor + +import ( + "context" + "fmt" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" +) + +// runClaudeOnSprite runs a task using Claude on a remote sprite. +// This enables dangerous mode safely since the sprite is isolated. +func (e *Executor) runClaudeOnSprite(ctx context.Context, task *db.Task, project *db.Project, workDir, prompt string) execResult { + e.logLine(task.ID, "system", "Starting task on sprite: "+project.SpriteName) + + // Create sprites client + spritesClient, err := sprites.NewClient(e.db) + if err != nil { + e.logLine(task.ID, "error", "Failed to create sprites client: "+err.Error()) + return execResult{Message: err.Error()} + } + + spriteName := project.SpriteName + + // Ensure sprite is active (restore from checkpoint if needed) + e.logLine(task.ID, "system", "Ensuring sprite is active...") + if err := spritesClient.EnsureActive(ctx, spriteName); err != nil { + e.logLine(task.ID, "error", "Failed to activate sprite: "+err.Error()) + return execResult{Message: err.Error()} + } + + // Create worktree on sprite + taskSlug := fmt.Sprintf("%d-%s", task.ID, slugify(task.Title, 30)) + branchName := fmt.Sprintf("task/%d-%s", task.ID, slugify(task.Title, 30)) + e.logLine(task.ID, "system", "Setting up worktree: "+taskSlug) + + spriteWorkDir, err := spritesClient.SetupWorktree(ctx, spriteName, taskSlug, branchName) + if err != nil { + e.logLine(task.ID, "error", "Failed to setup worktree: "+err.Error()) + return execResult{Message: err.Error()} + } + + // Setup hooks configuration on sprite + e.logLine(task.ID, "system", "Configuring Claude hooks...") + if err := spritesClient.SetupHooks(ctx, spriteName, spriteWorkDir, task.ID); err != nil { + e.logLine(task.ID, "error", "Failed to setup hooks: "+err.Error()) + return execResult{Message: err.Error()} + } + + // Start hook streaming + hookEvents, cancelHooks, err := spritesClient.StreamHooks(ctx, spriteName) + if err != nil { + e.logLine(task.ID, "error", "Failed to start hook streaming: "+err.Error()) + return execResult{Message: err.Error()} + } + defer cancelHooks() + + // Process hook events in background + go e.processSpriteHooks(ctx, task.ID, hookEvents) + + // Run Claude on sprite (in tmux) + e.logLine(task.ID, "system", "Starting Claude (dangerous mode enabled)...") + if err := spritesClient.RunClaude(ctx, spriteName, task.ID, spriteWorkDir, prompt); err != nil { + e.logLine(task.ID, "error", "Failed to start Claude: "+err.Error()) + return execResult{Message: err.Error()} + } + + // Poll for completion + return e.pollSpriteExecution(ctx, spritesClient, spriteName, task.ID) +} + +// processSpriteHooks processes hook events from the sprite and updates task status. +func (e *Executor) processSpriteHooks(ctx context.Context, taskID int64, events <-chan sprites.HookEvent) { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + + // Only process events for this task + if event.TaskID != taskID { + continue + } + + switch event.Event { + case "PreToolUse": + e.updateStatus(taskID, db.StatusProcessing) + if event.ToolName != "" { + e.logLine(taskID, "tool", "Using: "+event.ToolName) + } + + case "PostToolUse": + // Keep in processing state + e.updateStatus(taskID, db.StatusProcessing) + + case "Notification": + // Check if it's a permission or idle prompt + if event.Matcher == "idle_prompt" || event.Matcher == "permission_prompt" { + e.updateStatus(taskID, db.StatusBlocked) + e.logLine(taskID, "system", "Waiting for input") + } + + case "Stop": + // Check stop reason + if event.Reason == "end_turn" { + e.updateStatus(taskID, db.StatusBlocked) + e.logLine(taskID, "system", "Claude stopped, waiting for input") + } + } + } + } +} + +// pollSpriteExecution polls for task completion on the sprite. +func (e *Executor) pollSpriteExecution(ctx context.Context, client *sprites.Client, spriteName string, taskID int64) execResult { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return execResult{Interrupted: true} + + case <-ticker.C: + // Check database status (set by hooks) + task, err := e.db.GetTask(taskID) + if err != nil { + continue + } + + // Check if status changed + switch task.Status { + case db.StatusBacklog: + // User interrupted + return execResult{Interrupted: true} + case db.StatusDone: + // Completed via MCP or hooks + return execResult{Success: true} + } + + // Check if tmux window still exists + if !client.TmuxWindowExists(ctx, spriteName, taskID) { + // Window closed - check final status + if task.Status == db.StatusDone { + return execResult{Success: true} + } + if task.Status == db.StatusBlocked { + return execResult{NeedsInput: true, Message: "Task needs input"} + } + // Window closed unexpectedly + return execResult{Message: "Claude process ended"} + } + } + } +} + diff --git a/internal/sprites/client.go b/internal/sprites/client.go new file mode 100644 index 00000000..d4aa4d95 --- /dev/null +++ b/internal/sprites/client.go @@ -0,0 +1,313 @@ +// Package sprites provides a wrapper around the Fly.io Sprites SDK. +package sprites + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/bborn/workflow/internal/db" + sdk "github.com/superfly/sprites-go" +) + +const ( + // SettingSpriteToken is the database key for the Sprites API token. + SettingSpriteToken = "sprite_token" +) + +// HookEvent represents a Claude hook event from the sprite. +type HookEvent struct { + TaskID int64 `json:"task_id"` + Event string `json:"event"` // PreToolUse, PostToolUse, Notification, Stop + ToolName string `json:"tool,omitempty"` + Reason string `json:"reason,omitempty"` + Matcher string `json:"matcher,omitempty"` +} + +// Client wraps the Sprites SDK client. +type Client struct { + sdk *sdk.Client +} + +// NewClient creates a new Sprites client. +// It looks for the token in SPRITES_TOKEN env var first, then falls back to database. +func NewClient(database *db.DB) (*Client, error) { + token := os.Getenv("SPRITES_TOKEN") + if token == "" && database != nil { + token, _ = database.GetSetting(SettingSpriteToken) + } + if token == "" { + return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task config set sprite_token ") + } + return &Client{sdk: sdk.New(token)}, nil +} + +// NewClientWithToken creates a new Sprites client with the given token. +func NewClientWithToken(token string) *Client { + return &Client{sdk: sdk.New(token)} +} + +// SDK returns the underlying SDK client for advanced operations. +func (c *Client) SDK() *sdk.Client { + return c.sdk +} + +// Sprite returns a sprite reference by name. +func (c *Client) Sprite(name string) *sdk.Sprite { + return c.sdk.Sprite(name) +} + +// EnsureActive ensures the sprite is active (not checkpointed). +// If checkpointed, it restores from the latest checkpoint. +func (c *Client) EnsureActive(ctx context.Context, name string) error { + sprite, err := c.sdk.GetSprite(ctx, name) + if err != nil { + return fmt.Errorf("get sprite: %w", err) + } + + // Check if sprite is checkpointed (status might indicate this) + if sprite.Status == "suspended" || sprite.Status == "stopped" { + // Get latest checkpoint + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil { + return fmt.Errorf("list checkpoints: %w", err) + } + if len(checkpoints) == 0 { + return fmt.Errorf("sprite is suspended but has no checkpoints") + } + + // Restore from latest checkpoint + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return fmt.Errorf("restore checkpoint: %w", err) + } + + // Wait for restore to complete + if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }); err != nil { + return fmt.Errorf("restore failed: %w", err) + } + } + + return nil +} + +// Exec runs a command on the sprite and returns the output. +func (c *Client) Exec(ctx context.Context, spriteName string, command string) ([]byte, error) { + sprite := c.sdk.Sprite(spriteName) + cmd := sprite.CommandContext(ctx, "sh", "-c", command) + return cmd.CombinedOutput() +} + +// ExecInteractive runs an interactive command on the sprite. +func (c *Client) ExecInteractive(ctx context.Context, spriteName string, command string, stdin io.Reader, stdout, stderr io.Writer) error { + sprite := c.sdk.Sprite(spriteName) + cmd := sprite.CommandContext(ctx, "sh", "-c", command) + cmd.SetTTY(true) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +// StreamHooks starts streaming hook events from the sprite. +// The returned channel will receive hook events as they occur. +// The returned cancel function should be called to stop streaming. +func (c *Client) StreamHooks(ctx context.Context, spriteName string) (<-chan HookEvent, context.CancelFunc, error) { + ctx, cancel := context.WithCancel(ctx) + events := make(chan HookEvent, 100) + + sprite := c.sdk.Sprite(spriteName) + cmd := sprite.CommandContext(ctx, "tail", "-n0", "-f", "/tmp/task-hooks.log") + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, nil, fmt.Errorf("get stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return nil, nil, fmt.Errorf("start tail: %w", err) + } + + go func() { + defer close(events) + defer cmd.Wait() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + var event HookEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue // Skip malformed lines + } + select { + case events <- event: + case <-ctx.Done(): + return + } + } + }() + + return events, cancel, nil +} + +// SendInput sends input to a task's tmux window on the sprite. +func (c *Client) SendInput(ctx context.Context, spriteName string, taskID int64, input string) error { + windowTarget := fmt.Sprintf("task-daemon:task-%d", taskID) + // Escape single quotes in input + escapedInput := fmt.Sprintf("%q", input) + cmd := fmt.Sprintf("tmux send-keys -t %s %s Enter", windowTarget, escapedInput) + _, err := c.Exec(ctx, spriteName, cmd) + return err +} + +// SetupWorktree creates a worktree on the sprite for the task. +func (c *Client) SetupWorktree(ctx context.Context, spriteName, taskSlug, branchName string) (string, error) { + worktreePath := fmt.Sprintf("/workspace/.task-worktrees/%s", taskSlug) + + // Check if worktree already exists and remove it + c.Exec(ctx, spriteName, fmt.Sprintf("cd /workspace && git worktree remove %s --force 2>/dev/null || true", worktreePath)) + + // Fetch latest + if _, err := c.Exec(ctx, spriteName, "cd /workspace && git fetch origin"); err != nil { + return "", fmt.Errorf("git fetch: %w", err) + } + + // Create worktree + cmd := fmt.Sprintf("cd /workspace && git worktree add %s -b %s origin/HEAD", worktreePath, branchName) + if _, err := c.Exec(ctx, spriteName, cmd); err != nil { + return "", fmt.Errorf("create worktree: %w", err) + } + + return worktreePath, nil +} + +// SetupHooks writes the Claude hooks configuration to the sprite. +func (c *Client) SetupHooks(ctx context.Context, spriteName, workDir string, taskID int64) error { + // Create the hooks config that writes to /tmp/task-hooks.log + hooksConfig := fmt.Sprintf(`{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo '{\"task_id\":%d,\"event\":\"PreToolUse\",\"tool\":\"'\"$TOOL_NAME\"'\"}' >> /tmp/task-hooks.log" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo '{\"task_id\":%d,\"event\":\"PostToolUse\"}' >> /tmp/task-hooks.log" + } + ] + } + ], + "Notification": [ + { + "matcher": "idle_prompt|permission_prompt", + "hooks": [ + { + "type": "command", + "command": "echo '{\"task_id\":%d,\"event\":\"Notification\",\"matcher\":\"'\"$NOTIFICATION_TYPE\"'\"}' >> /tmp/task-hooks.log" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "echo '{\"task_id\":%d,\"event\":\"Stop\",\"reason\":\"'\"$STOP_REASON\"'\"}' >> /tmp/task-hooks.log" + } + ] + } + ] + } +}`, taskID, taskID, taskID, taskID) + + // Write hooks config + claudeDir := fmt.Sprintf("%s/.claude", workDir) + _, err := c.Exec(ctx, spriteName, fmt.Sprintf("mkdir -p %s", claudeDir)) + if err != nil { + return fmt.Errorf("create .claude dir: %w", err) + } + + settingsPath := fmt.Sprintf("%s/settings.local.json", claudeDir) + // Use printf to write the config + cmd := fmt.Sprintf("cat > %s << 'EOFHOOKS'\n%s\nEOFHOOKS", settingsPath, hooksConfig) + _, err = c.Exec(ctx, spriteName, cmd) + if err != nil { + return fmt.Errorf("write hooks config: %w", err) + } + + return nil +} + +// RunClaude runs Claude in a tmux window on the sprite. +func (c *Client) RunClaude(ctx context.Context, spriteName string, taskID int64, workDir, prompt string) error { + windowName := fmt.Sprintf("task-%d", taskID) + sessionName := "task-daemon" + + // Ensure tmux session exists + c.Exec(ctx, spriteName, fmt.Sprintf("tmux new-session -d -s %s 2>/dev/null || true", sessionName)) + + // Kill any existing window + c.Exec(ctx, spriteName, fmt.Sprintf("tmux kill-window -t %s:%s 2>/dev/null || true", sessionName, windowName)) + + // Write prompt to temp file + promptFile := fmt.Sprintf("/tmp/task-prompt-%d.txt", taskID) + promptCmd := fmt.Sprintf("cat > %s << 'EOFPROMPT'\n%s\nEOFPROMPT", promptFile, prompt) + if _, err := c.Exec(ctx, spriteName, promptCmd); err != nil { + return fmt.Errorf("write prompt: %w", err) + } + + // Build Claude command - always use dangerous mode on sprites (they're isolated) + claudeCmd := fmt.Sprintf("TASK_ID=%d claude --dangerously-skip-permissions --chrome \"$(cat %s)\"", taskID, promptFile) + + // Create tmux window and run Claude + tmuxCmd := fmt.Sprintf("tmux new-window -d -t %s -n %s -c %s sh -c %q", + sessionName, windowName, workDir, claudeCmd) + + if _, err := c.Exec(ctx, spriteName, tmuxCmd); err != nil { + return fmt.Errorf("create tmux window: %w", err) + } + + return nil +} + +// TmuxWindowExists checks if a tmux window exists for the task. +func (c *Client) TmuxWindowExists(ctx context.Context, spriteName string, taskID int64) bool { + windowTarget := fmt.Sprintf("task-daemon:task-%d", taskID) + _, err := c.Exec(ctx, spriteName, fmt.Sprintf("tmux list-panes -t %s", windowTarget)) + return err == nil +} + +// Checkpoint creates a checkpoint of the sprite. +func (c *Client) Checkpoint(ctx context.Context, spriteName, comment string) error { + sprite := c.sdk.Sprite(spriteName) + if comment == "" { + comment = fmt.Sprintf("auto-%s", time.Now().Format("2006-01-02-150405")) + } + + stream, err := sprite.CreateCheckpointWithComment(ctx, comment) + if err != nil { + return fmt.Errorf("create checkpoint: %w", err) + } + + return stream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }) +} From 39ca604c083d6bb19b38790fde7bb4580cb86e48 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 11 Jan 2026 19:42:24 -0600 Subject: [PATCH 3/4] refactor: simplify Sprites to single-sprite model Simplify the Sprites integration from per-project sprites to a single shared sprite that runs the entire task daemon + TUI. Changes: - Auto-connect to sprite when SPRITES_TOKEN is set - Add --local flag to force local execution - Remove per-project sprite complexity (executor_sprite.go, client wrapper) - Sprite runs `task -l --dangerous` with TTY attached - User's local machine acts as thin client to cloud sprite This approach is simpler and more cost-effective: one sprite handles all projects, dangerous mode is safe inside the isolated VM. Co-Authored-By: Claude Opus 4.5 --- cmd/task/main.go | 48 ++ cmd/task/sprite.go | 699 +++++++++++---------------- internal/executor/executor.go | 13 +- internal/executor/executor_sprite.go | 160 ------ internal/sprites/client.go | 313 ------------ 5 files changed, 340 insertions(+), 893 deletions(-) delete mode 100644 internal/executor/executor_sprite.go delete mode 100644 internal/sprites/client.go diff --git a/cmd/task/main.go b/cmd/task/main.go index 0c73469d..c5972550 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -87,6 +87,24 @@ func main() { return } + // Check for sprite token - if set and not --local, run on sprite + if !local { + dbPath := db.DefaultPath() + database, _ := db.Open(dbPath) + if database != nil { + if token := getSpriteToken(database); token != "" { + database.Close() + if err := runOnSprite(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Sprite error: "+err.Error())) + fmt.Fprintln(os.Stderr, dimStyle.Render("Use --local to run locally")) + os.Exit(1) + } + return + } + database.Close() + } + } + if local { if err := runLocal(dangerous); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) @@ -1530,6 +1548,36 @@ func loadPrivateKey(path string) (ssh.Signer, error) { return ssh.ParsePrivateKey(data) } +// runOnSprite runs the task TUI on a cloud sprite. +// The sprite runs task in dangerous mode (safe because it's isolated). +func runOnSprite() error { + // Open database to get sprite settings + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer database.Close() + + // Ensure sprite is running (creates/restores as needed) + _, sprite, err := ensureSpriteRunning(database) + if err != nil { + return err + } + + fmt.Println(dimStyle.Render("Connecting to sprite...")) + + // Run task in local + dangerous mode on the sprite + // We use tmux on the sprite so Claude windows work properly + cmd := sprite.Command("task", "-l", "--dangerous") + cmd.SetTTY(true) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + // ClaudeHookInput is the JSON structure Claude sends to hooks via stdin. type ClaudeHookInput struct { SessionID string `json:"session_id"` diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go index b93f1c27..b7a3610b 100644 --- a/cmd/task/sprite.go +++ b/cmd/task/sprite.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "time" "github.com/bborn/workflow/internal/db" @@ -16,14 +15,11 @@ import ( // Sprite settings keys const ( SettingSpriteToken = "sprite_token" // Sprites API token + SettingSpriteName = "sprite_name" // Name of the daemon sprite ) -// Sprite status values -const ( - SpriteStatusReady = "ready" - SpriteStatusCheckpointed = "checkpointed" - SpriteStatusError = "error" -) +// Default sprite name +const defaultSpriteName = "task-daemon" // Styles for sprite command output var ( @@ -33,156 +29,152 @@ var ( spriteErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) ) -// getSpriteClient creates a Sprites API client. -func getSpriteClient() (*sprites.Client, error) { +// getSpriteToken returns the Sprites API token from env or database. +func getSpriteToken(database *db.DB) string { // First try environment variable token := os.Getenv("SPRITES_TOKEN") + if token != "" { + return token + } // Fall back to database setting - if token == "" { - dbPath := db.DefaultPath() - database, err := db.Open(dbPath) - if err != nil { - return nil, fmt.Errorf("open database: %w", err) - } - defer database.Close() - + if database != nil { token, _ = database.GetSetting(SettingSpriteToken) } + return token +} +// getSpriteClient creates a Sprites API client. +func getSpriteClient(database *db.DB) (*sprites.Client, error) { + token := getSpriteToken(database) if token == "" { return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task config set sprite_token ") } - return sprites.New(token), nil } +// getSpriteName returns the name of the daemon sprite. +func getSpriteName(database *db.DB) string { + if database != nil { + name, _ := database.GetSetting(SettingSpriteName) + if name != "" { + return name + } + } + return defaultSpriteName +} + // createSpriteCommand creates the sprite subcommand with all its children. func createSpriteCommand() *cobra.Command { spriteCmd := &cobra.Command{ Use: "sprite", - Short: "Manage project sprites (cloud execution environments)", - Long: `Sprite management for running tasks in isolated cloud environments. + Short: "Manage the cloud sprite for task execution", + Long: `Sprite management for running tasks in the cloud. -Sprites are persistent, isolated Linux VMs that run Claude in dangerous mode safely. -Each project gets its own sprite with its development environment. +When SPRITES_TOKEN is set, 'task' automatically runs on a cloud sprite. +Use these commands to manage the sprite manually. Commands: - init - Initialize a sprite for a project - status - Show sprite status for projects - destroy - Delete a project's sprite - attach - Attach to a sprite's tmux session - sync - Sync code and dependencies to sprite`, + status - Show sprite status + up - Start/restore the sprite + down - Checkpoint and stop the sprite + attach - Attach to the sprite's tmux session + destroy - Delete the sprite entirely + token - Set the Sprites API token`, Run: func(cmd *cobra.Command, args []string) { - // Show sprite status by default - showSpriteStatus("") + showSpriteStatus() }, } - // sprite init - initCmd := &cobra.Command{ - Use: "init ", - Short: "Initialize a sprite for a project", - Long: `Create a new sprite for a project and set up the development environment. - -This will: -1. Create a new sprite VM -2. Clone the project repository -3. Install dependencies (detected automatically) -4. Create an initial checkpoint -5. Mark the project for sprite execution`, - Args: cobra.ExactArgs(1), + // sprite status + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show sprite status", Run: func(cmd *cobra.Command, args []string) { - if err := runSpriteInit(args[0]); err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } + showSpriteStatus() }, } - spriteCmd.AddCommand(initCmd) + spriteCmd.AddCommand(statusCmd) - // sprite status - statusCmd := &cobra.Command{ - Use: "status [project]", - Short: "Show sprite status for projects", - Args: cobra.MaximumNArgs(1), + // sprite up + upCmd := &cobra.Command{ + Use: "up", + Short: "Start or restore the sprite", + Long: `Ensure the sprite is running. Creates it if it doesn't exist, restores from checkpoint if suspended.`, Run: func(cmd *cobra.Command, args []string) { - project := "" - if len(args) > 0 { - project = args[0] + if err := runSpriteUp(); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) } - showSpriteStatus(project) }, } - spriteCmd.AddCommand(statusCmd) + spriteCmd.AddCommand(upCmd) - // sprite destroy - destroyCmd := &cobra.Command{ - Use: "destroy ", - Short: "Delete a project's sprite", - Long: `Destroy the sprite VM for a project. This will delete all data on the sprite.`, - Args: cobra.ExactArgs(1), + // sprite down + downCmd := &cobra.Command{ + Use: "down", + Short: "Checkpoint and stop the sprite", + Long: `Save the sprite state and suspend it. Saves money when not in use.`, Run: func(cmd *cobra.Command, args []string) { - if err := runSpriteDestroy(args[0]); err != nil { + if err := runSpriteDown(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) } }, } - spriteCmd.AddCommand(destroyCmd) + spriteCmd.AddCommand(downCmd) // sprite attach attachCmd := &cobra.Command{ - Use: "attach ", - Short: "Attach to a sprite's tmux session", - Long: `Open an interactive shell to the sprite and attach to the tmux session.`, - Args: cobra.ExactArgs(1), + Use: "attach", + Short: "Attach to the sprite's shell", + Long: `Open an interactive shell session on the sprite.`, Run: func(cmd *cobra.Command, args []string) { - taskID, _ := cmd.Flags().GetInt64("task") - if err := runSpriteAttach(args[0], taskID); err != nil { + if err := runSpriteAttach(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) } }, } - attachCmd.Flags().Int64P("task", "t", 0, "Attach to a specific task window") spriteCmd.AddCommand(attachCmd) - // sprite sync - syncCmd := &cobra.Command{ - Use: "sync ", - Short: "Sync code and dependencies to sprite", - Long: `Pull latest code and reinstall dependencies if needed.`, - Args: cobra.ExactArgs(1), + // sprite destroy + destroyCmd := &cobra.Command{ + Use: "destroy", + Short: "Delete the sprite entirely", + Long: `Permanently delete the sprite and all its data. Use with caution.`, Run: func(cmd *cobra.Command, args []string) { - if err := runSpriteSync(args[0]); err != nil { + if err := runSpriteDestroy(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) } }, } - spriteCmd.AddCommand(syncCmd) + spriteCmd.AddCommand(destroyCmd) - // sprite checkpoint - checkpointCmd := &cobra.Command{ - Use: "checkpoint ", - Short: "Create a checkpoint of the sprite", - Long: `Save the current state of the sprite for later restoration.`, - Args: cobra.ExactArgs(1), + // sprite token + tokenCmd := &cobra.Command{ + Use: "token [token]", + Short: "Set or show the Sprites API token", + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - if err := runSpriteCheckpoint(args[0]); err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) + if len(args) == 0 { + showSpriteToken() + } else { + if err := setSpriteToken(args[0]); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } } }, } - spriteCmd.AddCommand(checkpointCmd) + spriteCmd.AddCommand(tokenCmd) return spriteCmd } -// showSpriteStatus displays sprite status for one or all projects. -func showSpriteStatus(projectName string) { +// showSpriteStatus displays the current sprite status. +func showSpriteStatus() { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -191,61 +183,48 @@ func showSpriteStatus(projectName string) { } defer database.Close() - var projects []*db.Project - if projectName != "" { - p, err := database.GetProjectByName(projectName) - if err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } - if p == nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Project not found: "+projectName)) - os.Exit(1) - } - projects = []*db.Project{p} - } else { - projects, err = database.ListProjects() - if err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) - os.Exit(1) - } - } + token := getSpriteToken(database) + spriteName := getSpriteName(database) fmt.Println(spriteTitleStyle.Render("Sprite Status")) fmt.Println() - hasSprites := false - for _, p := range projects { - if p.SpriteName == "" { - continue - } - hasSprites = true - - statusIcon := "●" - statusStyle := spriteCheckStyle - switch p.SpriteStatus { - case SpriteStatusReady: - statusStyle = spriteCheckStyle - case SpriteStatusCheckpointed: - statusStyle = spritePendingStyle - case SpriteStatusError: - statusStyle = spriteErrorStyle - default: - statusStyle = dimStyle - } + if token == "" { + fmt.Println(dimStyle.Render(" No Sprites token configured.")) + fmt.Println(dimStyle.Render(" Set SPRITES_TOKEN env var or run: task sprite token ")) + return + } + + fmt.Printf(" Token: %s\n", dimStyle.Render("configured")) + fmt.Printf(" Name: %s\n", spriteName) - fmt.Printf(" %s %s\n", boldStyle.Render(p.Name), statusStyle.Render(statusIcon+" "+p.SpriteStatus)) - fmt.Printf(" Sprite: %s\n", dimStyle.Render(p.SpriteName)) + // Try to get sprite status from API + client, err := getSpriteClient(database) + if err != nil { + fmt.Printf(" Status: %s\n", spriteErrorStyle.Render("error - "+err.Error())) + return } - if !hasSprites { - fmt.Println(dimStyle.Render(" No sprites configured.")) - fmt.Println(dimStyle.Render(" Run 'task sprite init ' to create one.")) + ctx := context.Background() + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + fmt.Printf(" Status: %s\n", dimStyle.Render("not created")) + fmt.Println() + fmt.Println(dimStyle.Render(" Run 'task sprite up' to create it, or just run 'task'.")) + return } + + statusStyle := spriteCheckStyle + statusIcon := "●" + if sprite.Status == "suspended" || sprite.Status == "stopped" { + statusStyle = spritePendingStyle + } + fmt.Printf(" Status: %s\n", statusStyle.Render(statusIcon+" "+sprite.Status)) + fmt.Printf(" URL: %s\n", dimStyle.Render(sprite.URL)) } -// runSpriteInit initializes a sprite for a project. -func runSpriteInit(projectName string) error { +// runSpriteUp ensures the sprite is running. +func runSpriteUp() error { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -253,147 +232,114 @@ func runSpriteInit(projectName string) error { } defer database.Close() - // Get project - project, err := database.GetProjectByName(projectName) + client, err := getSpriteClient(database) if err != nil { - return fmt.Errorf("get project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) + return err } - // Check if sprite already exists - if project.SpriteName != "" { - return fmt.Errorf("sprite already exists for project %s (name: %s)", projectName, project.SpriteName) - } + spriteName := getSpriteName(database) + ctx := context.Background() - // Get sprite client - client, err := getSpriteClient() + // Check if sprite exists + sprite, err := client.GetSprite(ctx, spriteName) if err != nil { - return err - } + // Sprite doesn't exist, create it + fmt.Printf("Creating sprite: %s\n", spriteName) + sprite, err = client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return fmt.Errorf("create sprite: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) - // Generate sprite name - spriteName := fmt.Sprintf("task-%s-%d", projectName, time.Now().Unix()) - fmt.Printf("Creating sprite: %s\n", spriteName) + // Save sprite name to database + database.SetSetting(SettingSpriteName, spriteName) - // Create sprite - ctx := context.Background() - sprite, err := client.CreateSprite(ctx, spriteName, nil) - if err != nil { - return fmt.Errorf("create sprite: %w", err) - } + // Set up the sprite with task daemon + if err := setupSprite(client, sprite); err != nil { + return fmt.Errorf("setup sprite: %w", err) + } + } else if sprite.Status == "suspended" || sprite.Status == "stopped" { + // Restore from checkpoint + fmt.Println("Restoring sprite from checkpoint...") + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil || len(checkpoints) == 0 { + return fmt.Errorf("no checkpoints available to restore") + } - fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return fmt.Errorf("restore checkpoint: %w", err) + } - // Get git remote URL - gitRemote, err := getGitRemote(project.Path) - if err != nil { - // Clean up sprite on failure - sprite.Destroy() - return fmt.Errorf("get git remote: %w", err) + if err := restoreStream.ProcessAll(func(msg *sprites.StreamMessage) error { + return nil + }); err != nil { + return fmt.Errorf("restore failed: %w", err) + } + fmt.Println(spriteCheckStyle.Render("✓ Sprite restored")) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite is already running")) } - // Clone repository - fmt.Printf("Cloning repository: %s\n", gitRemote) - cmd := sprite.Command("git", "clone", gitRemote, "/workspace") - if output, err := cmd.CombinedOutput(); err != nil { - sprite.Destroy() - return fmt.Errorf("clone repository: %w\n%s", err, string(output)) + return nil +} + +// setupSprite installs dependencies and task daemon on a new sprite. +func setupSprite(client *sprites.Client, sprite *sprites.Sprite) error { + ctx := context.Background() + + fmt.Println("Setting up sprite...") + + // Install essential packages + steps := []struct { + desc string + cmd string + }{ + {"Installing tmux", "apt-get update && apt-get install -y tmux git"}, + {"Installing Go", "curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -xzf - && echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc"}, + {"Creating workspace", "mkdir -p /workspace"}, + {"Installing Node.js", "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs"}, + {"Installing Claude CLI", "npm install -g @anthropic-ai/claude-code"}, } - fmt.Println(spriteCheckStyle.Render("✓ Repository cloned")) - // Detect and run setup commands - fmt.Println("Setting up development environment...") - setupCommands := detectSetupCommands(sprite) - for _, setupCmd := range setupCommands { - fmt.Printf(" Running: %s\n", setupCmd) - cmd := sprite.Command("sh", "-c", "cd /workspace && "+setupCmd) + for _, step := range steps { + fmt.Printf(" %s...\n", step.desc) + cmd := sprite.CommandContext(ctx, "sh", "-c", step.cmd) if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - fmt.Printf(" %s\n", dimStyle.Render(string(output))) - } else { - fmt.Println(spriteCheckStyle.Render(" ✓ Done")) + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + fmt.Printf(" %s\n", dimStyle.Render(string(output))) } } - // Install Claude CLI - fmt.Println("Installing Claude CLI...") - cmd = sprite.Command("npm", "install", "-g", "@anthropic-ai/claude-code") - if _, err := cmd.CombinedOutput(); err != nil { - fmt.Printf(" %s: Could not install Claude CLI: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Claude CLI installed")) + // Clone and build task + fmt.Println(" Building task daemon...") + buildCmd := ` + cd /workspace && + git clone https://github.com/bborn/taskyou.git task 2>/dev/null || (cd task && git pull) && + cd task && + /usr/local/go/bin/go build -o /usr/local/bin/task ./cmd/task && + /usr/local/go/bin/go build -o /usr/local/bin/taskd ./cmd/taskd + ` + cmd := sprite.CommandContext(ctx, "sh", "-c", buildCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("build task: %w\n%s", err, string(output)) } - // Initialize tmux session - fmt.Println("Initializing tmux session...") - cmd = sprite.Command("tmux", "new-session", "-d", "-s", "task-daemon") - cmd.Run() // Ignore error if session exists - - // Create checkpoint - fmt.Println("Creating initial checkpoint...") + // Create initial checkpoint + fmt.Println(" Creating checkpoint...") checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, "initial-setup") if err != nil { - fmt.Printf(" %s: Could not create checkpoint: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) } else { - // Wait for checkpoint to complete - if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { - return nil // Just wait for completion - }); err != nil { - fmt.Printf(" %s: Checkpoint error: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Checkpoint created")) - } - } - - // Update project in database - project.SpriteName = spriteName - project.SpriteStatus = SpriteStatusReady - if err := database.UpdateProject(project); err != nil { - return fmt.Errorf("update project: %w", err) + checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { return nil }) } - fmt.Println() - fmt.Println(spriteCheckStyle.Render("✓ Sprite ready!")) - fmt.Printf(" Tasks for %s will now execute on the sprite.\n", projectName) - fmt.Printf(" Use 'task execute --sprite' or 'task sprite attach %s'\n", projectName) - + fmt.Println(spriteCheckStyle.Render("✓ Sprite setup complete")) return nil } -// detectSetupCommands checks for common project files and returns setup commands. -func detectSetupCommands(sprite *sprites.Sprite) []string { - var commands []string - - // Check for various project files - checks := []struct { - file string - command string - }{ - {"Gemfile", "bundle install"}, - {"package-lock.json", "npm ci"}, - {"yarn.lock", "yarn install --frozen-lockfile"}, - {"pnpm-lock.yaml", "pnpm install --frozen-lockfile"}, - {"requirements.txt", "pip install -r requirements.txt"}, - {"pyproject.toml", "pip install -e '.[dev]' 2>/dev/null || pip install -e ."}, - {"go.mod", "go mod download"}, - {"Cargo.toml", "cargo fetch"}, - {"bin/setup", "./bin/setup"}, - } - - for _, check := range checks { - cmd := sprite.Command("test", "-f", "/workspace/"+check.file) - if cmd.Run() == nil { - commands = append(commands, check.command) - } - } - - return commands -} - -// runSpriteDestroy destroys a project's sprite. -func runSpriteDestroy(projectName string) error { +// runSpriteDown checkpoints and suspends the sprite. +func runSpriteDown() error { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -401,49 +347,38 @@ func runSpriteDestroy(projectName string) error { } defer database.Close() - // Get project - project, err := database.GetProjectByName(projectName) + client, err := getSpriteClient(database) if err != nil { - return fmt.Errorf("get project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) + return err } - if project.SpriteName == "" { - return fmt.Errorf("no sprite configured for project: %s", projectName) - } + spriteName := getSpriteName(database) + ctx := context.Background() - // Get sprite client - client, err := getSpriteClient() + sprite, err := client.GetSprite(ctx, spriteName) if err != nil { - return err + return fmt.Errorf("sprite not found: %s", spriteName) } - fmt.Printf("Destroying sprite: %s\n", project.SpriteName) - - // Destroy sprite - ctx := context.Background() - if err := client.DestroySprite(ctx, project.SpriteName); err != nil { - // Log but continue - sprite might already be gone - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } else { - fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) + fmt.Println("Creating checkpoint...") + checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, fmt.Sprintf("manual-%s", time.Now().Format("2006-01-02-150405"))) + if err != nil { + return fmt.Errorf("checkpoint failed: %w", err) } - // Update project in database - project.SpriteName = "" - project.SpriteStatus = "" - if err := database.UpdateProject(project); err != nil { - return fmt.Errorf("update project: %w", err) + if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { + return nil + }); err != nil { + return fmt.Errorf("checkpoint failed: %w", err) } - fmt.Println(spriteCheckStyle.Render("✓ Project updated")) + fmt.Println(spriteCheckStyle.Render("✓ Sprite checkpointed and suspended")) + fmt.Println(dimStyle.Render(" Storage costs only while suspended (~$0.01/day per GB)")) return nil } -// runSpriteAttach attaches to a sprite's tmux session. -func runSpriteAttach(projectName string, taskID int64) error { +// runSpriteAttach opens an interactive shell on the sprite. +func runSpriteAttach() error { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -451,39 +386,19 @@ func runSpriteAttach(projectName string, taskID int64) error { } defer database.Close() - // Get project - project, err := database.GetProjectByName(projectName) - if err != nil { - return fmt.Errorf("get project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) - } - - if project.SpriteName == "" { - return fmt.Errorf("no sprite configured for project: %s", projectName) - } - - // Get sprite client - client, err := getSpriteClient() + client, err := getSpriteClient(database) if err != nil { return err } - sprite := client.Sprite(project.SpriteName) - - // Build tmux command - tmuxCmd := "tmux attach -t task-daemon" - if taskID > 0 { - tmuxCmd = fmt.Sprintf("tmux select-window -t task-daemon:task-%d && tmux attach -t task-daemon", taskID) - } + spriteName := getSpriteName(database) + sprite := client.Sprite(spriteName) - fmt.Printf("Attaching to sprite: %s\n", project.SpriteName) - fmt.Println(dimStyle.Render("Press Ctrl+B, D to detach")) + fmt.Println("Attaching to sprite...") + fmt.Println(dimStyle.Render("Press Ctrl+D to detach")) fmt.Println() - // Run interactive command - cmd := sprite.Command("sh", "-c", tmuxCmd) + cmd := sprite.Command("bash") cmd.SetTTY(true) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -492,8 +407,8 @@ func runSpriteAttach(projectName string, taskID int64) error { return cmd.Run() } -// runSpriteSync syncs code and dependencies to a sprite. -func runSpriteSync(projectName string) error { +// runSpriteDestroy permanently deletes the sprite. +func runSpriteDestroy() error { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -501,88 +416,50 @@ func runSpriteSync(projectName string) error { } defer database.Close() - // Get project - project, err := database.GetProjectByName(projectName) - if err != nil { - return fmt.Errorf("get project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) - } - - if project.SpriteName == "" { - return fmt.Errorf("no sprite configured for project: %s", projectName) - } - - // Get sprite client - client, err := getSpriteClient() + client, err := getSpriteClient(database) if err != nil { return err } - sprite := client.Sprite(project.SpriteName) - - fmt.Println("Syncing sprite...") - - // Get current HEAD before pull - cmd := sprite.Command("git", "-C", "/workspace", "rev-parse", "HEAD") - oldHead, _ := cmd.Output() - - // Pull latest - fmt.Println(" Pulling latest code...") - cmd = sprite.Command("git", "-C", "/workspace", "fetch", "origin") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("fetch: %w\n%s", err, string(output)) - } + spriteName := getSpriteName(database) + ctx := context.Background() - cmd = sprite.Command("git", "-C", "/workspace", "reset", "--hard", "origin/main") - if output, err := cmd.CombinedOutput(); err != nil { - // Try master if main doesn't exist - cmd = sprite.Command("git", "-C", "/workspace", "reset", "--hard", "origin/master") - if output2, err2 := cmd.CombinedOutput(); err2 != nil { - return fmt.Errorf("reset: %w\n%s", err, string(output)+"\n"+string(output2)) - } + fmt.Printf("Destroying sprite: %s\n", spriteName) + if err := client.DestroySprite(ctx, spriteName); err != nil { + fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprite destroyed")) } - fmt.Println(spriteCheckStyle.Render(" ✓ Code updated")) - // Get new HEAD - cmd = sprite.Command("git", "-C", "/workspace", "rev-parse", "HEAD") - newHead, _ := cmd.Output() + // Clear sprite name from database + database.SetSetting(SettingSpriteName, "") - // Check if deps changed - if strings.TrimSpace(string(oldHead)) != strings.TrimSpace(string(newHead)) { - // Check for dependency file changes - depFiles := []string{"Gemfile.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "go.sum", "Cargo.lock"} - depsChanged := false - - for _, f := range depFiles { - cmd = sprite.Command("git", "-C", "/workspace", "diff", "--name-only", strings.TrimSpace(string(oldHead)), strings.TrimSpace(string(newHead)), "--", f) - if output, _ := cmd.Output(); len(strings.TrimSpace(string(output))) > 0 { - depsChanged = true - break - } - } + return nil +} - if depsChanged { - fmt.Println(" Dependencies changed, reinstalling...") - setupCommands := detectSetupCommands(sprite) - for _, setupCmd := range setupCommands { - fmt.Printf(" Running: %s\n", setupCmd) - cmd := sprite.Command("sh", "-c", "cd /workspace && "+setupCmd) - if _, err := cmd.CombinedOutput(); err != nil { - fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) - } - } - fmt.Println(spriteCheckStyle.Render(" ✓ Dependencies updated")) - } +// showSpriteToken shows whether a token is configured. +func showSpriteToken() { + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) } + defer database.Close() - fmt.Println(spriteCheckStyle.Render("✓ Sync complete")) - return nil + token := getSpriteToken(database) + if token == "" { + fmt.Println(dimStyle.Render("No Sprites token configured.")) + fmt.Println(dimStyle.Render("Set with: task sprite token ")) + fmt.Println(dimStyle.Render("Or: export SPRITES_TOKEN=")) + } else { + fmt.Println(spriteCheckStyle.Render("✓ Sprites token is configured")) + fmt.Printf(" Token: %s...%s\n", token[:8], token[len(token)-4:]) + } } -// runSpriteCheckpoint creates a checkpoint of the sprite. -func runSpriteCheckpoint(projectName string) error { +// setSpriteToken saves the Sprites API token to the database. +func setSpriteToken(token string) error { dbPath := db.DefaultPath() database, err := db.Open(dbPath) if err != nil { @@ -590,48 +467,54 @@ func runSpriteCheckpoint(projectName string) error { } defer database.Close() - // Get project - project, err := database.GetProjectByName(projectName) - if err != nil { - return fmt.Errorf("get project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) + if err := database.SetSetting(SettingSpriteToken, token); err != nil { + return fmt.Errorf("save token: %w", err) } - if project.SpriteName == "" { - return fmt.Errorf("no sprite configured for project: %s", projectName) - } + fmt.Println(spriteCheckStyle.Render("✓ Sprites token saved")) + return nil +} - // Get sprite client - client, err := getSpriteClient() +// ensureSpriteRunning ensures the sprite is running and returns the sprite reference. +// This is called automatically when task starts with SPRITES_TOKEN set. +func ensureSpriteRunning(database *db.DB) (*sprites.Client, *sprites.Sprite, error) { + client, err := getSpriteClient(database) if err != nil { - return err + return nil, nil, err } - sprite := client.Sprite(project.SpriteName) + spriteName := getSpriteName(database) ctx := context.Background() - fmt.Printf("Creating checkpoint for %s...\n", project.SpriteName) - - checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, fmt.Sprintf("manual-%s", time.Now().Format("2006-01-02-150405"))) + // Check if sprite exists + sprite, err := client.GetSprite(ctx, spriteName) if err != nil { - return fmt.Errorf("create checkpoint: %w", err) - } + // Create sprite + fmt.Println("Creating sprite...") + sprite, err = client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return nil, nil, fmt.Errorf("create sprite: %w", err) + } - // Wait for checkpoint to complete - if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { - return nil // Just wait for completion - }); err != nil { - return fmt.Errorf("checkpoint failed: %w", err) - } - fmt.Println(spriteCheckStyle.Render("✓ Checkpoint created")) + // Save name and set up + database.SetSetting(SettingSpriteName, spriteName) + if err := setupSprite(client, sprite); err != nil { + return nil, nil, err + } + } else if sprite.Status == "suspended" || sprite.Status == "stopped" { + // Restore + fmt.Println("Restoring sprite...") + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil || len(checkpoints) == 0 { + return nil, nil, fmt.Errorf("no checkpoints to restore") + } - // Update status - project.SpriteStatus = SpriteStatusCheckpointed - if err := database.UpdateProject(project); err != nil { - return fmt.Errorf("update project: %w", err) + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return nil, nil, err + } + restoreStream.ProcessAll(func(msg *sprites.StreamMessage) error { return nil }) } - return nil + return client, sprite, nil } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a8b52a1b..9c69d873 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -378,20 +378,9 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { // Build prompt based on task type prompt := e.buildPrompt(task, attachmentPaths) - // Check if project has a sprite configured for cloud execution - var project *db.Project - if task.Project != "" { - project, _ = e.db.GetProjectByName(task.Project) - } - useSprite := project != nil && project.SpriteName != "" && - (project.SpriteStatus == "ready" || project.SpriteStatus == "checkpointed") - // Run Claude var result execResult - if useSprite { - // Run on sprite (cloud execution with dangerous mode) - result = e.runClaudeOnSprite(taskCtx, task, project, workDir, prompt) - } else if isRetry { + if isRetry { e.logLine(task.ID, "system", "Resuming previous session with feedback") result = e.runClaudeResume(taskCtx, task.ID, workDir, prompt, retryFeedback) } else { diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go deleted file mode 100644 index f7af53a2..00000000 --- a/internal/executor/executor_sprite.go +++ /dev/null @@ -1,160 +0,0 @@ -package executor - -import ( - "context" - "fmt" - "time" - - "github.com/bborn/workflow/internal/db" - "github.com/bborn/workflow/internal/sprites" -) - -// runClaudeOnSprite runs a task using Claude on a remote sprite. -// This enables dangerous mode safely since the sprite is isolated. -func (e *Executor) runClaudeOnSprite(ctx context.Context, task *db.Task, project *db.Project, workDir, prompt string) execResult { - e.logLine(task.ID, "system", "Starting task on sprite: "+project.SpriteName) - - // Create sprites client - spritesClient, err := sprites.NewClient(e.db) - if err != nil { - e.logLine(task.ID, "error", "Failed to create sprites client: "+err.Error()) - return execResult{Message: err.Error()} - } - - spriteName := project.SpriteName - - // Ensure sprite is active (restore from checkpoint if needed) - e.logLine(task.ID, "system", "Ensuring sprite is active...") - if err := spritesClient.EnsureActive(ctx, spriteName); err != nil { - e.logLine(task.ID, "error", "Failed to activate sprite: "+err.Error()) - return execResult{Message: err.Error()} - } - - // Create worktree on sprite - taskSlug := fmt.Sprintf("%d-%s", task.ID, slugify(task.Title, 30)) - branchName := fmt.Sprintf("task/%d-%s", task.ID, slugify(task.Title, 30)) - e.logLine(task.ID, "system", "Setting up worktree: "+taskSlug) - - spriteWorkDir, err := spritesClient.SetupWorktree(ctx, spriteName, taskSlug, branchName) - if err != nil { - e.logLine(task.ID, "error", "Failed to setup worktree: "+err.Error()) - return execResult{Message: err.Error()} - } - - // Setup hooks configuration on sprite - e.logLine(task.ID, "system", "Configuring Claude hooks...") - if err := spritesClient.SetupHooks(ctx, spriteName, spriteWorkDir, task.ID); err != nil { - e.logLine(task.ID, "error", "Failed to setup hooks: "+err.Error()) - return execResult{Message: err.Error()} - } - - // Start hook streaming - hookEvents, cancelHooks, err := spritesClient.StreamHooks(ctx, spriteName) - if err != nil { - e.logLine(task.ID, "error", "Failed to start hook streaming: "+err.Error()) - return execResult{Message: err.Error()} - } - defer cancelHooks() - - // Process hook events in background - go e.processSpriteHooks(ctx, task.ID, hookEvents) - - // Run Claude on sprite (in tmux) - e.logLine(task.ID, "system", "Starting Claude (dangerous mode enabled)...") - if err := spritesClient.RunClaude(ctx, spriteName, task.ID, spriteWorkDir, prompt); err != nil { - e.logLine(task.ID, "error", "Failed to start Claude: "+err.Error()) - return execResult{Message: err.Error()} - } - - // Poll for completion - return e.pollSpriteExecution(ctx, spritesClient, spriteName, task.ID) -} - -// processSpriteHooks processes hook events from the sprite and updates task status. -func (e *Executor) processSpriteHooks(ctx context.Context, taskID int64, events <-chan sprites.HookEvent) { - for { - select { - case <-ctx.Done(): - return - case event, ok := <-events: - if !ok { - return - } - - // Only process events for this task - if event.TaskID != taskID { - continue - } - - switch event.Event { - case "PreToolUse": - e.updateStatus(taskID, db.StatusProcessing) - if event.ToolName != "" { - e.logLine(taskID, "tool", "Using: "+event.ToolName) - } - - case "PostToolUse": - // Keep in processing state - e.updateStatus(taskID, db.StatusProcessing) - - case "Notification": - // Check if it's a permission or idle prompt - if event.Matcher == "idle_prompt" || event.Matcher == "permission_prompt" { - e.updateStatus(taskID, db.StatusBlocked) - e.logLine(taskID, "system", "Waiting for input") - } - - case "Stop": - // Check stop reason - if event.Reason == "end_turn" { - e.updateStatus(taskID, db.StatusBlocked) - e.logLine(taskID, "system", "Claude stopped, waiting for input") - } - } - } - } -} - -// pollSpriteExecution polls for task completion on the sprite. -func (e *Executor) pollSpriteExecution(ctx context.Context, client *sprites.Client, spriteName string, taskID int64) execResult { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return execResult{Interrupted: true} - - case <-ticker.C: - // Check database status (set by hooks) - task, err := e.db.GetTask(taskID) - if err != nil { - continue - } - - // Check if status changed - switch task.Status { - case db.StatusBacklog: - // User interrupted - return execResult{Interrupted: true} - case db.StatusDone: - // Completed via MCP or hooks - return execResult{Success: true} - } - - // Check if tmux window still exists - if !client.TmuxWindowExists(ctx, spriteName, taskID) { - // Window closed - check final status - if task.Status == db.StatusDone { - return execResult{Success: true} - } - if task.Status == db.StatusBlocked { - return execResult{NeedsInput: true, Message: "Task needs input"} - } - // Window closed unexpectedly - return execResult{Message: "Claude process ended"} - } - } - } -} - diff --git a/internal/sprites/client.go b/internal/sprites/client.go deleted file mode 100644 index d4aa4d95..00000000 --- a/internal/sprites/client.go +++ /dev/null @@ -1,313 +0,0 @@ -// Package sprites provides a wrapper around the Fly.io Sprites SDK. -package sprites - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "os" - "time" - - "github.com/bborn/workflow/internal/db" - sdk "github.com/superfly/sprites-go" -) - -const ( - // SettingSpriteToken is the database key for the Sprites API token. - SettingSpriteToken = "sprite_token" -) - -// HookEvent represents a Claude hook event from the sprite. -type HookEvent struct { - TaskID int64 `json:"task_id"` - Event string `json:"event"` // PreToolUse, PostToolUse, Notification, Stop - ToolName string `json:"tool,omitempty"` - Reason string `json:"reason,omitempty"` - Matcher string `json:"matcher,omitempty"` -} - -// Client wraps the Sprites SDK client. -type Client struct { - sdk *sdk.Client -} - -// NewClient creates a new Sprites client. -// It looks for the token in SPRITES_TOKEN env var first, then falls back to database. -func NewClient(database *db.DB) (*Client, error) { - token := os.Getenv("SPRITES_TOKEN") - if token == "" && database != nil { - token, _ = database.GetSetting(SettingSpriteToken) - } - if token == "" { - return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task config set sprite_token ") - } - return &Client{sdk: sdk.New(token)}, nil -} - -// NewClientWithToken creates a new Sprites client with the given token. -func NewClientWithToken(token string) *Client { - return &Client{sdk: sdk.New(token)} -} - -// SDK returns the underlying SDK client for advanced operations. -func (c *Client) SDK() *sdk.Client { - return c.sdk -} - -// Sprite returns a sprite reference by name. -func (c *Client) Sprite(name string) *sdk.Sprite { - return c.sdk.Sprite(name) -} - -// EnsureActive ensures the sprite is active (not checkpointed). -// If checkpointed, it restores from the latest checkpoint. -func (c *Client) EnsureActive(ctx context.Context, name string) error { - sprite, err := c.sdk.GetSprite(ctx, name) - if err != nil { - return fmt.Errorf("get sprite: %w", err) - } - - // Check if sprite is checkpointed (status might indicate this) - if sprite.Status == "suspended" || sprite.Status == "stopped" { - // Get latest checkpoint - checkpoints, err := sprite.ListCheckpoints(ctx, "") - if err != nil { - return fmt.Errorf("list checkpoints: %w", err) - } - if len(checkpoints) == 0 { - return fmt.Errorf("sprite is suspended but has no checkpoints") - } - - // Restore from latest checkpoint - restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) - if err != nil { - return fmt.Errorf("restore checkpoint: %w", err) - } - - // Wait for restore to complete - if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { - return nil - }); err != nil { - return fmt.Errorf("restore failed: %w", err) - } - } - - return nil -} - -// Exec runs a command on the sprite and returns the output. -func (c *Client) Exec(ctx context.Context, spriteName string, command string) ([]byte, error) { - sprite := c.sdk.Sprite(spriteName) - cmd := sprite.CommandContext(ctx, "sh", "-c", command) - return cmd.CombinedOutput() -} - -// ExecInteractive runs an interactive command on the sprite. -func (c *Client) ExecInteractive(ctx context.Context, spriteName string, command string, stdin io.Reader, stdout, stderr io.Writer) error { - sprite := c.sdk.Sprite(spriteName) - cmd := sprite.CommandContext(ctx, "sh", "-c", command) - cmd.SetTTY(true) - cmd.Stdin = stdin - cmd.Stdout = stdout - cmd.Stderr = stderr - return cmd.Run() -} - -// StreamHooks starts streaming hook events from the sprite. -// The returned channel will receive hook events as they occur. -// The returned cancel function should be called to stop streaming. -func (c *Client) StreamHooks(ctx context.Context, spriteName string) (<-chan HookEvent, context.CancelFunc, error) { - ctx, cancel := context.WithCancel(ctx) - events := make(chan HookEvent, 100) - - sprite := c.sdk.Sprite(spriteName) - cmd := sprite.CommandContext(ctx, "tail", "-n0", "-f", "/tmp/task-hooks.log") - - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - return nil, nil, fmt.Errorf("get stdout pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - cancel() - return nil, nil, fmt.Errorf("start tail: %w", err) - } - - go func() { - defer close(events) - defer cmd.Wait() - - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - var event HookEvent - if err := json.Unmarshal([]byte(line), &event); err != nil { - continue // Skip malformed lines - } - select { - case events <- event: - case <-ctx.Done(): - return - } - } - }() - - return events, cancel, nil -} - -// SendInput sends input to a task's tmux window on the sprite. -func (c *Client) SendInput(ctx context.Context, spriteName string, taskID int64, input string) error { - windowTarget := fmt.Sprintf("task-daemon:task-%d", taskID) - // Escape single quotes in input - escapedInput := fmt.Sprintf("%q", input) - cmd := fmt.Sprintf("tmux send-keys -t %s %s Enter", windowTarget, escapedInput) - _, err := c.Exec(ctx, spriteName, cmd) - return err -} - -// SetupWorktree creates a worktree on the sprite for the task. -func (c *Client) SetupWorktree(ctx context.Context, spriteName, taskSlug, branchName string) (string, error) { - worktreePath := fmt.Sprintf("/workspace/.task-worktrees/%s", taskSlug) - - // Check if worktree already exists and remove it - c.Exec(ctx, spriteName, fmt.Sprintf("cd /workspace && git worktree remove %s --force 2>/dev/null || true", worktreePath)) - - // Fetch latest - if _, err := c.Exec(ctx, spriteName, "cd /workspace && git fetch origin"); err != nil { - return "", fmt.Errorf("git fetch: %w", err) - } - - // Create worktree - cmd := fmt.Sprintf("cd /workspace && git worktree add %s -b %s origin/HEAD", worktreePath, branchName) - if _, err := c.Exec(ctx, spriteName, cmd); err != nil { - return "", fmt.Errorf("create worktree: %w", err) - } - - return worktreePath, nil -} - -// SetupHooks writes the Claude hooks configuration to the sprite. -func (c *Client) SetupHooks(ctx context.Context, spriteName, workDir string, taskID int64) error { - // Create the hooks config that writes to /tmp/task-hooks.log - hooksConfig := fmt.Sprintf(`{ - "hooks": { - "PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "echo '{\"task_id\":%d,\"event\":\"PreToolUse\",\"tool\":\"'\"$TOOL_NAME\"'\"}' >> /tmp/task-hooks.log" - } - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "echo '{\"task_id\":%d,\"event\":\"PostToolUse\"}' >> /tmp/task-hooks.log" - } - ] - } - ], - "Notification": [ - { - "matcher": "idle_prompt|permission_prompt", - "hooks": [ - { - "type": "command", - "command": "echo '{\"task_id\":%d,\"event\":\"Notification\",\"matcher\":\"'\"$NOTIFICATION_TYPE\"'\"}' >> /tmp/task-hooks.log" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "echo '{\"task_id\":%d,\"event\":\"Stop\",\"reason\":\"'\"$STOP_REASON\"'\"}' >> /tmp/task-hooks.log" - } - ] - } - ] - } -}`, taskID, taskID, taskID, taskID) - - // Write hooks config - claudeDir := fmt.Sprintf("%s/.claude", workDir) - _, err := c.Exec(ctx, spriteName, fmt.Sprintf("mkdir -p %s", claudeDir)) - if err != nil { - return fmt.Errorf("create .claude dir: %w", err) - } - - settingsPath := fmt.Sprintf("%s/settings.local.json", claudeDir) - // Use printf to write the config - cmd := fmt.Sprintf("cat > %s << 'EOFHOOKS'\n%s\nEOFHOOKS", settingsPath, hooksConfig) - _, err = c.Exec(ctx, spriteName, cmd) - if err != nil { - return fmt.Errorf("write hooks config: %w", err) - } - - return nil -} - -// RunClaude runs Claude in a tmux window on the sprite. -func (c *Client) RunClaude(ctx context.Context, spriteName string, taskID int64, workDir, prompt string) error { - windowName := fmt.Sprintf("task-%d", taskID) - sessionName := "task-daemon" - - // Ensure tmux session exists - c.Exec(ctx, spriteName, fmt.Sprintf("tmux new-session -d -s %s 2>/dev/null || true", sessionName)) - - // Kill any existing window - c.Exec(ctx, spriteName, fmt.Sprintf("tmux kill-window -t %s:%s 2>/dev/null || true", sessionName, windowName)) - - // Write prompt to temp file - promptFile := fmt.Sprintf("/tmp/task-prompt-%d.txt", taskID) - promptCmd := fmt.Sprintf("cat > %s << 'EOFPROMPT'\n%s\nEOFPROMPT", promptFile, prompt) - if _, err := c.Exec(ctx, spriteName, promptCmd); err != nil { - return fmt.Errorf("write prompt: %w", err) - } - - // Build Claude command - always use dangerous mode on sprites (they're isolated) - claudeCmd := fmt.Sprintf("TASK_ID=%d claude --dangerously-skip-permissions --chrome \"$(cat %s)\"", taskID, promptFile) - - // Create tmux window and run Claude - tmuxCmd := fmt.Sprintf("tmux new-window -d -t %s -n %s -c %s sh -c %q", - sessionName, windowName, workDir, claudeCmd) - - if _, err := c.Exec(ctx, spriteName, tmuxCmd); err != nil { - return fmt.Errorf("create tmux window: %w", err) - } - - return nil -} - -// TmuxWindowExists checks if a tmux window exists for the task. -func (c *Client) TmuxWindowExists(ctx context.Context, spriteName string, taskID int64) bool { - windowTarget := fmt.Sprintf("task-daemon:task-%d", taskID) - _, err := c.Exec(ctx, spriteName, fmt.Sprintf("tmux list-panes -t %s", windowTarget)) - return err == nil -} - -// Checkpoint creates a checkpoint of the sprite. -func (c *Client) Checkpoint(ctx context.Context, spriteName, comment string) error { - sprite := c.sdk.Sprite(spriteName) - if comment == "" { - comment = fmt.Sprintf("auto-%s", time.Now().Format("2006-01-02-150405")) - } - - stream, err := sprite.CreateCheckpointWithComment(ctx, comment) - if err != nil { - return fmt.Errorf("create checkpoint: %w", err) - } - - return stream.ProcessAll(func(msg *sdk.StreamMessage) error { - return nil - }) -} From bdf74ab41504b25fd6cbc99c190829dd24d9a71d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 11 Jan 2026 20:16:01 -0600 Subject: [PATCH 4/4] refactor: sprite is execution env only, not full app host Simplify sprites architecture: sprites are now only used as the execution environment for Claude, not for running the entire task app. Key changes: - Task app runs locally (or on remote server), database stays local - When SPRITES_TOKEN is set, executor runs Claude on sprite instead of local tmux - Hooks stream back from sprite via tail -f on /tmp/task-hooks.jsonl - No database syncing needed - sprite is just a sandbox for Claude New files: - internal/sprites/sprites.go - Shared sprite client/token logic - internal/executor/executor_sprite.go - Sprite execution + hook streaming Flow: 1. User runs `task` locally (normal) 2. Executor checks SPRITES_TOKEN at startup 3. If set, creates SpriteRunner and starts hook listener 4. When task runs, Claude executes on sprite in tmux session 5. Hooks write to file on sprite, executor tails and updates local DB Co-Authored-By: Claude Opus 4.5 --- cmd/task/main.go | 48 ----- cmd/task/sprite.go | 187 +++++++----------- internal/executor/executor.go | 37 ++++ internal/executor/executor_sprite.go | 277 +++++++++++++++++++++++++++ internal/sprites/sprites.go | 106 ++++++++++ 5 files changed, 485 insertions(+), 170 deletions(-) create mode 100644 internal/executor/executor_sprite.go create mode 100644 internal/sprites/sprites.go diff --git a/cmd/task/main.go b/cmd/task/main.go index c5972550..0c73469d 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -87,24 +87,6 @@ func main() { return } - // Check for sprite token - if set and not --local, run on sprite - if !local { - dbPath := db.DefaultPath() - database, _ := db.Open(dbPath) - if database != nil { - if token := getSpriteToken(database); token != "" { - database.Close() - if err := runOnSprite(); err != nil { - fmt.Fprintln(os.Stderr, errorStyle.Render("Sprite error: "+err.Error())) - fmt.Fprintln(os.Stderr, dimStyle.Render("Use --local to run locally")) - os.Exit(1) - } - return - } - database.Close() - } - } - if local { if err := runLocal(dangerous); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) @@ -1548,36 +1530,6 @@ func loadPrivateKey(path string) (ssh.Signer, error) { return ssh.ParsePrivateKey(data) } -// runOnSprite runs the task TUI on a cloud sprite. -// The sprite runs task in dangerous mode (safe because it's isolated). -func runOnSprite() error { - // Open database to get sprite settings - dbPath := db.DefaultPath() - database, err := db.Open(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer database.Close() - - // Ensure sprite is running (creates/restores as needed) - _, sprite, err := ensureSpriteRunning(database) - if err != nil { - return err - } - - fmt.Println(dimStyle.Render("Connecting to sprite...")) - - // Run task in local + dangerous mode on the sprite - // We use tmux on the sprite so Claude windows work properly - cmd := sprite.Command("task", "-l", "--dangerous") - cmd.SetTTY(true) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - // ClaudeHookInput is the JSON structure Claude sends to hooks via stdin. type ClaudeHookInput struct { SessionID string `json:"session_id"` diff --git a/cmd/task/sprite.go b/cmd/task/sprite.go index b7a3610b..4903c84f 100644 --- a/cmd/task/sprite.go +++ b/cmd/task/sprite.go @@ -7,20 +7,12 @@ import ( "time" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" - sprites "github.com/superfly/sprites-go" + sdk "github.com/superfly/sprites-go" ) -// Sprite settings keys -const ( - SettingSpriteToken = "sprite_token" // Sprites API token - SettingSpriteName = "sprite_name" // Name of the daemon sprite -) - -// Default sprite name -const defaultSpriteName = "task-daemon" - // Styles for sprite command output var ( spriteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#61AFEF")) @@ -29,41 +21,6 @@ var ( spriteErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) ) -// getSpriteToken returns the Sprites API token from env or database. -func getSpriteToken(database *db.DB) string { - // First try environment variable - token := os.Getenv("SPRITES_TOKEN") - if token != "" { - return token - } - - // Fall back to database setting - if database != nil { - token, _ = database.GetSetting(SettingSpriteToken) - } - return token -} - -// getSpriteClient creates a Sprites API client. -func getSpriteClient(database *db.DB) (*sprites.Client, error) { - token := getSpriteToken(database) - if token == "" { - return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task config set sprite_token ") - } - return sprites.New(token), nil -} - -// getSpriteName returns the name of the daemon sprite. -func getSpriteName(database *db.DB) string { - if database != nil { - name, _ := database.GetSetting(SettingSpriteName) - if name != "" { - return name - } - } - return defaultSpriteName -} - // createSpriteCommand creates the sprite subcommand with all its children. func createSpriteCommand() *cobra.Command { spriteCmd := &cobra.Command{ @@ -183,8 +140,8 @@ func showSpriteStatus() { } defer database.Close() - token := getSpriteToken(database) - spriteName := getSpriteName(database) + token := sprites.GetToken(database) + spriteName := sprites.GetName(database) fmt.Println(spriteTitleStyle.Render("Sprite Status")) fmt.Println() @@ -199,7 +156,7 @@ func showSpriteStatus() { fmt.Printf(" Name: %s\n", spriteName) // Try to get sprite status from API - client, err := getSpriteClient(database) + client, err := sprites.NewClient(database) if err != nil { fmt.Printf(" Status: %s\n", spriteErrorStyle.Render("error - "+err.Error())) return @@ -232,12 +189,12 @@ func runSpriteUp() error { } defer database.Close() - client, err := getSpriteClient(database) + client, err := sprites.NewClient(database) if err != nil { return err } - spriteName := getSpriteName(database) + spriteName := sprites.GetName(database) ctx := context.Background() // Check if sprite exists @@ -252,7 +209,7 @@ func runSpriteUp() error { fmt.Println(spriteCheckStyle.Render("✓ Sprite created")) // Save sprite name to database - database.SetSetting(SettingSpriteName, spriteName) + database.SetSetting(sprites.SettingName, spriteName) // Set up the sprite with task daemon if err := setupSprite(client, sprite); err != nil { @@ -271,7 +228,7 @@ func runSpriteUp() error { return fmt.Errorf("restore checkpoint: %w", err) } - if err := restoreStream.ProcessAll(func(msg *sprites.StreamMessage) error { + if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }); err != nil { return fmt.Errorf("restore failed: %w", err) @@ -284,8 +241,8 @@ func runSpriteUp() error { return nil } -// setupSprite installs dependencies and task daemon on a new sprite. -func setupSprite(client *sprites.Client, sprite *sprites.Sprite) error { +// setupSprite installs dependencies and configures Claude hooks on a new sprite. +func setupSprite(client *sdk.Client, sprite *sdk.Sprite) error { ctx := context.Background() fmt.Println("Setting up sprite...") @@ -295,8 +252,7 @@ func setupSprite(client *sprites.Client, sprite *sprites.Sprite) error { desc string cmd string }{ - {"Installing tmux", "apt-get update && apt-get install -y tmux git"}, - {"Installing Go", "curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -xzf - && echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc"}, + {"Installing packages", "apt-get update && apt-get install -y tmux git curl"}, {"Creating workspace", "mkdir -p /workspace"}, {"Installing Node.js", "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs"}, {"Installing Claude CLI", "npm install -g @anthropic-ai/claude-code"}, @@ -311,27 +267,58 @@ func setupSprite(client *sprites.Client, sprite *sprites.Sprite) error { } } - // Clone and build task - fmt.Println(" Building task daemon...") - buildCmd := ` - cd /workspace && - git clone https://github.com/bborn/taskyou.git task 2>/dev/null || (cd task && git pull) && - cd task && - /usr/local/go/bin/go build -o /usr/local/bin/task ./cmd/task && - /usr/local/go/bin/go build -o /usr/local/bin/taskd ./cmd/taskd - ` - cmd := sprite.CommandContext(ctx, "sh", "-c", buildCmd) + // Install the hook script that writes to a file for streaming back + fmt.Println(" Installing hook script...") + hookScript := `#!/bin/bash +# task-sprite-hook: Writes Claude hook events to a file for streaming +# The task executor on the local machine tails this file +input=$(cat) +printf '{"task_id":%s,"event":"%s","data":%s}\n' "$TASK_ID" "$1" "$input" >> /tmp/task-hooks.jsonl +` + installHookCmd := fmt.Sprintf(`cat > /usr/local/bin/task-sprite-hook << 'HOOKEOF' +%s +HOOKEOF +chmod +x /usr/local/bin/task-sprite-hook`, hookScript) + + cmd := sprite.CommandContext(ctx, "sh", "-c", installHookCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("install hook script: %w\n%s", err, string(output)) + } + + // Configure Claude to use our hook script + fmt.Println(" Configuring Claude hooks...") + claudeSettings := `{ + "permissions": { + "allow": ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)", "Grep(*)", "Glob(*)", "WebFetch(*)", "Task(*)", "TodoWrite(*)"], + "deny": [] + }, + "hooks": { + "PreToolUse": ["/usr/local/bin/task-sprite-hook PreToolUse"], + "PostToolUse": ["/usr/local/bin/task-sprite-hook PostToolUse"], + "Notification": ["/usr/local/bin/task-sprite-hook Notification"], + "Stop": ["/usr/local/bin/task-sprite-hook Stop"] + } +}` + configureClaudeCmd := fmt.Sprintf(`mkdir -p ~/.claude && cat > ~/.claude/settings.json << 'SETTINGSEOF' +%s +SETTINGSEOF`, claudeSettings) + + cmd = sprite.CommandContext(ctx, "sh", "-c", configureClaudeCmd) if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("build task: %w\n%s", err, string(output)) + return fmt.Errorf("configure claude: %w\n%s", err, string(output)) } + // Initialize the hooks file + cmd = sprite.CommandContext(ctx, "sh", "-c", "touch /tmp/task-hooks.jsonl") + cmd.Run() + // Create initial checkpoint fmt.Println(" Creating checkpoint...") checkpointStream, err := sprite.CreateCheckpointWithComment(ctx, "initial-setup") if err != nil { fmt.Printf(" %s: %s\n", spriteErrorStyle.Render("Warning"), err.Error()) } else { - checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { return nil }) + checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }) } fmt.Println(spriteCheckStyle.Render("✓ Sprite setup complete")) @@ -347,12 +334,12 @@ func runSpriteDown() error { } defer database.Close() - client, err := getSpriteClient(database) + client, err := sprites.NewClient(database) if err != nil { return err } - spriteName := getSpriteName(database) + spriteName := sprites.GetName(database) ctx := context.Background() sprite, err := client.GetSprite(ctx, spriteName) @@ -366,7 +353,7 @@ func runSpriteDown() error { return fmt.Errorf("checkpoint failed: %w", err) } - if err := checkpointStream.ProcessAll(func(msg *sprites.StreamMessage) error { + if err := checkpointStream.ProcessAll(func(msg *sdk.StreamMessage) error { return nil }); err != nil { return fmt.Errorf("checkpoint failed: %w", err) @@ -386,12 +373,12 @@ func runSpriteAttach() error { } defer database.Close() - client, err := getSpriteClient(database) + client, err := sprites.NewClient(database) if err != nil { return err } - spriteName := getSpriteName(database) + spriteName := sprites.GetName(database) sprite := client.Sprite(spriteName) fmt.Println("Attaching to sprite...") @@ -416,12 +403,12 @@ func runSpriteDestroy() error { } defer database.Close() - client, err := getSpriteClient(database) + client, err := sprites.NewClient(database) if err != nil { return err } - spriteName := getSpriteName(database) + spriteName := sprites.GetName(database) ctx := context.Background() fmt.Printf("Destroying sprite: %s\n", spriteName) @@ -432,7 +419,7 @@ func runSpriteDestroy() error { } // Clear sprite name from database - database.SetSetting(SettingSpriteName, "") + database.SetSetting(sprites.SettingName, "") return nil } @@ -447,7 +434,7 @@ func showSpriteToken() { } defer database.Close() - token := getSpriteToken(database) + token := sprites.GetToken(database) if token == "" { fmt.Println(dimStyle.Render("No Sprites token configured.")) fmt.Println(dimStyle.Render("Set with: task sprite token ")) @@ -467,54 +454,10 @@ func setSpriteToken(token string) error { } defer database.Close() - if err := database.SetSetting(SettingSpriteToken, token); err != nil { + if err := database.SetSetting(sprites.SettingToken, token); err != nil { return fmt.Errorf("save token: %w", err) } fmt.Println(spriteCheckStyle.Render("✓ Sprites token saved")) return nil } - -// ensureSpriteRunning ensures the sprite is running and returns the sprite reference. -// This is called automatically when task starts with SPRITES_TOKEN set. -func ensureSpriteRunning(database *db.DB) (*sprites.Client, *sprites.Sprite, error) { - client, err := getSpriteClient(database) - if err != nil { - return nil, nil, err - } - - spriteName := getSpriteName(database) - ctx := context.Background() - - // Check if sprite exists - sprite, err := client.GetSprite(ctx, spriteName) - if err != nil { - // Create sprite - fmt.Println("Creating sprite...") - sprite, err = client.CreateSprite(ctx, spriteName, nil) - if err != nil { - return nil, nil, fmt.Errorf("create sprite: %w", err) - } - - // Save name and set up - database.SetSetting(SettingSpriteName, spriteName) - if err := setupSprite(client, sprite); err != nil { - return nil, nil, err - } - } else if sprite.Status == "suspended" || sprite.Status == "stopped" { - // Restore - fmt.Println("Restoring sprite...") - checkpoints, err := sprite.ListCheckpoints(ctx, "") - if err != nil || len(checkpoints) == 0 { - return nil, nil, fmt.Errorf("no checkpoints to restore") - } - - restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) - if err != nil { - return nil, nil, err - } - restoreStream.ProcessAll(func(msg *sprites.StreamMessage) error { return nil }) - } - - return client, sprite, nil -} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 9c69d873..b6e63e3b 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -18,6 +18,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/github" "github.com/bborn/workflow/internal/hooks" + "github.com/bborn/workflow/internal/sprites" "github.com/charmbracelet/log" ) @@ -52,6 +53,9 @@ type Executor struct { // Silent mode suppresses log output (for TUI embedding) silent bool + + // Sprite runner for cloud execution (nil if not enabled) + spriteRunner *SpriteRunner } // New creates a new executor. @@ -98,6 +102,21 @@ func (e *Executor) Start(ctx context.Context) { e.running = true e.mu.Unlock() + // Initialize sprite runner if sprites are enabled + if sprites.IsEnabled(e.db) { + logFunc := func(format string, args ...interface{}) { + e.logger.Info(fmt.Sprintf(format, args...)) + } + runner, err := NewSpriteRunner(e.db, logFunc) + if err != nil { + e.logger.Error("Failed to initialize sprite runner", "error", err) + } else { + e.spriteRunner = runner + e.spriteRunner.StartHookListener() + e.logger.Info("Sprite runner initialized - Claude will execute on sprite") + } + } + e.logger.Info("Background executor started") go e.worker(ctx) @@ -114,6 +133,11 @@ func (e *Executor) Stop() { close(e.stopCh) e.mu.Unlock() + // Stop sprite runner if running + if e.spriteRunner != nil { + e.spriteRunner.StopHookListener() + } + e.logger.Info("Background executor stopped") } @@ -857,6 +881,19 @@ func (e *Executor) setupClaudeHooks(workDir string, taskID int64) (cleanup func( // runClaude runs a task using Claude CLI in a tmux window for interactive access func (e *Executor) runClaude(ctx context.Context, taskID int64, workDir, prompt string) execResult { + // If sprite runner is available, use cloud execution + if e.spriteRunner != nil { + task, _ := e.db.GetTask(taskID) + if task != nil { + e.logLine(taskID, "system", "Running Claude on sprite (cloud execution)") + if err := e.spriteRunner.RunClaude(ctx, task, workDir, prompt); err != nil { + e.logger.Error("Sprite execution failed", "error", err) + return execResult{Success: false, Message: err.Error()} + } + return execResult{Success: true} + } + } + // Check if tmux is available if _, err := exec.LookPath("tmux"); err != nil { return e.runClaudeDirect(ctx, taskID, workDir, prompt) diff --git a/internal/executor/executor_sprite.go b/internal/executor/executor_sprite.go new file mode 100644 index 00000000..e5c3da41 --- /dev/null +++ b/internal/executor/executor_sprite.go @@ -0,0 +1,277 @@ +package executor + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/sprites" + sdk "github.com/superfly/sprites-go" +) + +// spriteHookEvent represents a hook event received from a sprite. +type spriteHookEvent struct { + TaskID int64 `json:"task_id"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +// spriteHookData is the data field from Claude's hook input. +type spriteHookData struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + NotificationType string `json:"notification_type,omitempty"` + Message string `json:"message,omitempty"` + StopReason string `json:"stop_reason,omitempty"` +} + +// SpriteRunner manages Claude execution on sprites. +type SpriteRunner struct { + db *db.DB + client *sdk.Client + sprite *sdk.Sprite + + // Hook streaming + hookCtx context.Context + hookCancel context.CancelFunc + hookWg sync.WaitGroup + + mu sync.Mutex + logger func(format string, args ...interface{}) +} + +// NewSpriteRunner creates a new sprite runner. +func NewSpriteRunner(database *db.DB, logger func(format string, args ...interface{})) (*SpriteRunner, error) { + ctx := context.Background() + + client, sprite, err := sprites.EnsureRunning(ctx, database) + if err != nil { + return nil, fmt.Errorf("ensure sprite running: %w", err) + } + + // If sprite is nil, we need to create it + if sprite == nil { + spriteName := sprites.GetName(database) + sprite, err = client.CreateSprite(ctx, spriteName, nil) + if err != nil { + return nil, fmt.Errorf("create sprite: %w", err) + } + + // Save sprite name + database.SetSetting(sprites.SettingName, spriteName) + + // Setup will be done by the caller (setupSprite in cmd/task/sprite.go) + // For now, we'll just note that it needs setup + logger("Sprite created but may need setup. Run 'task sprite up' to initialize.") + } + + return &SpriteRunner{ + db: database, + client: client, + sprite: sprite, + logger: logger, + }, nil +} + +// StartHookListener starts the background hook event listener. +func (r *SpriteRunner) StartHookListener() { + r.hookCtx, r.hookCancel = context.WithCancel(context.Background()) + + r.hookWg.Add(1) + go r.streamHooks() +} + +// StopHookListener stops the hook listener. +func (r *SpriteRunner) StopHookListener() { + if r.hookCancel != nil { + r.hookCancel() + } + r.hookWg.Wait() +} + +// streamHooks tails the hooks file on the sprite and processes events. +func (r *SpriteRunner) streamHooks() { + defer r.hookWg.Done() + + for { + select { + case <-r.hookCtx.Done(): + return + default: + } + + // Start tailing the hooks file + cmd := r.sprite.CommandContext(r.hookCtx, "tail", "-n", "0", "-f", "/tmp/task-hooks.jsonl") + stdout, err := cmd.StdoutPipe() + if err != nil { + r.logger("Failed to get stdout pipe: %v", err) + time.Sleep(time.Second) + continue + } + + if err := cmd.Start(); err != nil { + r.logger("Failed to start tail: %v", err) + time.Sleep(time.Second) + continue + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var event spriteHookEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + r.logger("Failed to parse hook event: %v", err) + continue + } + + r.handleHookEvent(&event) + } + + // If we get here, tail exited - wait and retry + cmd.Wait() + select { + case <-r.hookCtx.Done(): + return + case <-time.After(time.Second): + // Retry + } + } +} + +// handleHookEvent processes a hook event from the sprite. +func (r *SpriteRunner) handleHookEvent(event *spriteHookEvent) { + // Parse the hook data + var data spriteHookData + if err := json.Unmarshal(event.Data, &data); err != nil { + r.logger("Failed to parse hook data: %v", err) + return + } + + // Get current task + task, err := r.db.GetTask(event.TaskID) + if err != nil || task == nil { + return + } + + // Only manage status if the task has started + if task.StartedAt == nil { + return + } + + switch event.Event { + case "Stop": + if data.StopReason == "end_turn" && task.Status == db.StatusProcessing { + r.db.UpdateTaskStatus(event.TaskID, db.StatusBlocked) + r.db.AppendTaskLog(event.TaskID, "system", "Waiting for user input") + } + + case "Notification": + if (data.NotificationType == "idle_prompt" || data.NotificationType == "permission_prompt") && + task.Status == db.StatusProcessing { + r.db.UpdateTaskStatus(event.TaskID, db.StatusBlocked) + msg := "Waiting for user input" + if data.NotificationType == "permission_prompt" { + msg = "Waiting for permission" + } + r.db.AppendTaskLog(event.TaskID, "system", msg) + } + + case "PreToolUse", "PostToolUse": + if task.Status == db.StatusBlocked { + r.db.UpdateTaskStatus(event.TaskID, db.StatusProcessing) + r.db.AppendTaskLog(event.TaskID, "system", "Claude resumed working") + } + } +} + +// RunClaude executes Claude on the sprite for a given task. +func (r *SpriteRunner) RunClaude(ctx context.Context, task *db.Task, workDir string, prompt string) error { + r.logger("Running Claude on sprite for task %d", task.ID) + + // Create a tmux session on the sprite for this task + sessionName := fmt.Sprintf("task-%d", task.ID) + + // Kill any existing session with this name + killCmd := r.sprite.CommandContext(ctx, "tmux", "kill-session", "-t", sessionName) + killCmd.Run() // Ignore errors - session may not exist + + // Build the claude command + // Set TASK_ID env var so hooks know which task this is + claudeCmd := fmt.Sprintf(`cd %s && TASK_ID=%d claude --dangerously-skip-permissions -p %q`, + shellEscape(workDir), task.ID, prompt) + + // Create new tmux session running claude + cmd := r.sprite.CommandContext(ctx, "tmux", "new-session", "-d", "-s", sessionName, "-c", workDir, "sh", "-c", claudeCmd) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("start claude session: %w: %s", err, string(output)) + } + + r.logger("Claude started in tmux session %s on sprite", sessionName) + + // Wait for the session to complete (poll for session existence) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + // Check if session still exists + checkCmd := r.sprite.CommandContext(ctx, "tmux", "has-session", "-t", sessionName) + if err := checkCmd.Run(); err != nil { + // Session ended + r.logger("Claude session %s completed", sessionName) + return nil + } + } + } +} + +// shellEscape escapes a string for use in a shell command. +func shellEscape(s string) string { + // Simple escape - wrap in single quotes and escape any single quotes + return "'" + escapeQuotes(s) + "'" +} + +func escapeQuotes(s string) string { + result := "" + for _, c := range s { + if c == '\'' { + result += "'\\''" + } else { + result += string(c) + } + } + return result +} + +// SyncWorkdir syncs the local workdir to the sprite. +// This copies the task's git worktree to the sprite. +func (r *SpriteRunner) SyncWorkdir(ctx context.Context, localPath string, remotePath string) error { + // For now, we'll just create the directory on the sprite + // In the future, we could use rsync or git clone + cmd := r.sprite.CommandContext(ctx, "mkdir", "-p", remotePath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("create remote dir: %w: %s", err, string(output)) + } + + // TODO: Implement actual sync (rsync, git clone, or file transfer) + r.logger("Created remote directory %s on sprite", remotePath) + return nil +} + +// GetSprite returns the sprite reference. +func (r *SpriteRunner) GetSprite() *sdk.Sprite { + return r.sprite +} diff --git a/internal/sprites/sprites.go b/internal/sprites/sprites.go new file mode 100644 index 00000000..71197c1a --- /dev/null +++ b/internal/sprites/sprites.go @@ -0,0 +1,106 @@ +// Package sprites provides shared functionality for Fly.io Sprites integration. +// Sprites are isolated VMs used as execution environments for Claude. +package sprites + +import ( + "context" + "fmt" + "os" + + "github.com/bborn/workflow/internal/db" + sdk "github.com/superfly/sprites-go" +) + +// Settings keys for sprite configuration +const ( + SettingToken = "sprite_token" // Sprites API token + SettingName = "sprite_name" // Name of the daemon sprite +) + +// Default sprite name +const DefaultName = "task-daemon" + +// GetToken returns the Sprites API token from env or database. +func GetToken(database *db.DB) string { + // First try environment variable + token := os.Getenv("SPRITES_TOKEN") + if token != "" { + return token + } + + // Fall back to database setting + if database != nil { + token, _ = database.GetSetting(SettingToken) + } + return token +} + +// GetName returns the name of the daemon sprite. +func GetName(database *db.DB) string { + if database != nil { + name, _ := database.GetSetting(SettingName) + if name != "" { + return name + } + } + return DefaultName +} + +// NewClient creates a Sprites API client. +func NewClient(database *db.DB) (*sdk.Client, error) { + token := GetToken(database) + if token == "" { + return nil, fmt.Errorf("no Sprites token configured. Set SPRITES_TOKEN env var or run: task sprite token ") + } + return sdk.New(token), nil +} + +// EnsureRunning ensures the sprite is running and returns the sprite reference. +// Creates the sprite if it doesn't exist, restores from checkpoint if suspended. +func EnsureRunning(ctx context.Context, database *db.DB) (*sdk.Client, *sdk.Sprite, error) { + client, err := NewClient(database) + if err != nil { + return nil, nil, err + } + + spriteName := GetName(database) + + // Check if sprite exists + sprite, err := client.GetSprite(ctx, spriteName) + if err != nil { + // Sprite doesn't exist - caller should create and set it up + return client, nil, nil + } + + // Restore from checkpoint if suspended + if sprite.Status == "suspended" || sprite.Status == "stopped" { + checkpoints, err := sprite.ListCheckpoints(ctx, "") + if err != nil || len(checkpoints) == 0 { + return nil, nil, fmt.Errorf("sprite is suspended but no checkpoints available") + } + + restoreStream, err := sprite.RestoreCheckpoint(ctx, checkpoints[0].ID) + if err != nil { + return nil, nil, fmt.Errorf("restore checkpoint: %w", err) + } + + if err := restoreStream.ProcessAll(func(msg *sdk.StreamMessage) error { + return nil + }); err != nil { + return nil, nil, fmt.Errorf("restore failed: %w", err) + } + + // Refresh sprite status + sprite, err = client.GetSprite(ctx, spriteName) + if err != nil { + return nil, nil, err + } + } + + return client, sprite, nil +} + +// IsEnabled returns true if sprites are configured (token is set). +func IsEnabled(database *db.DB) bool { + return GetToken(database) != "" +}