diff --git a/README.md b/README.md index 19f21e1..16e390a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Task You -A personal task management system with a beautiful terminal UI, SQLite storage, and background task execution via pluggable AI agents (Claude Code or OpenAI Codex CLI). +A personal task management system with a beautiful terminal UI, SQLite storage, and background task execution via pluggable AI agents (Claude Code, OpenAI Codex, Gemini, or OpenClaw). ## Screenshots @@ -24,7 +24,7 @@ A personal task management system with a beautiful terminal UI, SQLite storage, - **Kanban Board** - Visual task management with 4 columns (Backlog, In Progress, Blocked, Done) - **Git Worktrees** - Each task runs in an isolated worktree, no conflicts between parallel tasks -- **Pluggable Executors** - Choose between Claude Code or OpenAI Codex CLI per task +- **Pluggable Executors** - Choose between Claude Code, OpenAI Codex, Gemini, or OpenClaw per task - **Ghost Text Autocomplete** - LLM-powered suggestions for task titles and descriptions as you type - **VS Code-style Fuzzy Search** - Quick task navigation with smart matching (e.g., "dsno" matches "diseno website") - **Markdown Rendering** - Task descriptions render with proper formatting in the detail view @@ -197,11 +197,12 @@ Task You supports multiple AI executors for processing tasks. You can choose the | Claude (default) | `claude` | [Claude Code](https://claude.ai/claude-code) - Anthropic's coding agent with session resumption | | Codex | `codex` | [OpenAI Codex CLI](https://github.com/openai/codex) - OpenAI's coding assistant | | Gemini | `gemini` | [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) - Google's Gemini-based coding assistant | +| OpenClaw | `openclaw` | [OpenClaw](https://openclaw.ai) - Open-source personal AI assistant with session resumption | -Both executors run in tmux windows with the same worktree isolation and environment variables. The main differences: +All executors run in tmux windows with the same worktree isolation and environment variables. The main differences: -- **Claude Code** supports session resumption - when you retry a task, Claude continues with full conversation history -- **Codex** starts fresh on each execution but receives the full prompt with any feedback +- **Claude Code** and **OpenClaw** support session resumption - when you retry a task, they continue with full conversation history +- **Codex** and **Gemini** start fresh on each execution but receive the full prompt with any feedback ### Installing Executors @@ -216,6 +217,10 @@ npm install -g @openai/codex # Google Gemini CLI # See https://ai.google.dev/gemini-api/docs/cli for installation instructions + +# OpenClaw +npm install -g openclaw@latest +openclaw onboard # Run setup wizard ``` ### How Task Executors Work @@ -299,7 +304,7 @@ Claude Code supports **session resumption** - when you retry a task or press `R` This means when you retry a blocked task with feedback, Claude doesn't start over—it continues the conversation with full awareness of what it already tried. -**Note:** Codex does not support session resumption. When retrying a Codex task, it receives the full prompt including any feedback, but starts a fresh session. +**Note:** Codex and Gemini do not support session resumption. When retrying these tasks, they receive the full prompt including any feedback, but start a fresh session. Claude Code and OpenClaw support full session resumption. #### Lifecycle & Cleanup diff --git a/internal/db/tasks.go b/internal/db/tasks.go index ad920fc..4897a75 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -68,9 +68,10 @@ const ( // Task executors const ( - ExecutorClaude = "claude" // Claude Code CLI (default) - ExecutorCodex = "codex" // OpenAI Codex CLI - ExecutorGemini = "gemini" // Google Gemini CLI + ExecutorClaude = "claude" // Claude Code CLI (default) + ExecutorCodex = "codex" // OpenAI Codex CLI + ExecutorGemini = "gemini" // Google Gemini CLI + ExecutorOpenClaw = "openclaw" // OpenClaw AI assistant (https://openclaw.ai) ) // DefaultExecutor returns the default executor if none is specified. diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 5630ea9..20780b0 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -148,6 +148,7 @@ func New(database *db.DB, cfg *config.Config) *Executor { e.executorFactory.Register(NewClaudeExecutor(e)) e.executorFactory.Register(NewCodexExecutor(e)) e.executorFactory.Register(NewGeminiExecutor(e)) + e.executorFactory.Register(NewOpenClawExecutor(e)) return e } @@ -177,6 +178,7 @@ func NewWithLogging(database *db.DB, cfg *config.Config, w io.Writer) *Executor e.executorFactory.Register(NewClaudeExecutor(e)) e.executorFactory.Register(NewCodexExecutor(e)) e.executorFactory.Register(NewGeminiExecutor(e)) + e.executorFactory.Register(NewOpenClawExecutor(e)) return e } diff --git a/internal/executor/openclaw_executor.go b/internal/executor/openclaw_executor.go new file mode 100644 index 0000000..e574104 --- /dev/null +++ b/internal/executor/openclaw_executor.go @@ -0,0 +1,341 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/charmbracelet/log" +) + +// OpenClawExecutor implements TaskExecutor for OpenClaw AI assistant. +// OpenClaw is an open-source personal AI assistant that runs locally, +// capable of automating tasks across your digital life. +// See: https://openclaw.ai +// +// CLI Reference (using openclaw tui for interactive terminal UI): +// - openclaw tui --session - Connect to a session +// - openclaw tui --message "prompt" - Send initial message after connecting +// - openclaw tui --thinking - Set reasoning depth (off/minimal/low/medium/high) +type OpenClawExecutor struct { + executor *Executor + logger *log.Logger + suspendedTasks map[int64]time.Time +} + +// NewOpenClawExecutor creates a new OpenClaw executor. +func NewOpenClawExecutor(e *Executor) *OpenClawExecutor { + return &OpenClawExecutor{ + executor: e, + logger: e.logger, + suspendedTasks: make(map[int64]time.Time), + } +} + +// Name returns the executor name. +func (o *OpenClawExecutor) Name() string { + return db.ExecutorOpenClaw +} + +// IsAvailable checks if the openclaw CLI is installed. +func (o *OpenClawExecutor) IsAvailable() bool { + _, err := exec.LookPath("openclaw") + return err == nil +} + +// Execute runs a task using the OpenClaw CLI. +func (o *OpenClawExecutor) Execute(ctx context.Context, task *db.Task, workDir, prompt string) ExecResult { + return o.runOpenClaw(ctx, task, workDir, prompt, "", false) +} + +// Resume resumes a previous session using OpenClaw's --session-id flag. +// If no session ID exists, starts fresh with the full prompt + feedback. +func (o *OpenClawExecutor) Resume(ctx context.Context, task *db.Task, workDir, prompt, feedback string) ExecResult { + return o.runOpenClaw(ctx, task, workDir, prompt, feedback, true) +} + +func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workDir, prompt, feedback string, isResume bool) ExecResult { + paths := o.executor.claudePathsForProject(task.Project) + + if !o.IsAvailable() { + o.executor.logLine(task.ID, "error", "openclaw CLI is not installed - run: npm i -g openclaw@latest && openclaw onboard") + return ExecResult{Message: "openclaw CLI is not installed"} + } + + if _, err := exec.LookPath("tmux"); err != nil { + o.executor.logLine(task.ID, "error", "tmux is not installed - required for task execution") + return ExecResult{Message: "tmux is not installed"} + } + + daemonSession, err := ensureTmuxDaemon() + if err != nil { + o.logger.Error("could not create task-daemon session", "error", err) + o.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux daemon: %s", err.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create tmux daemon: %s", err.Error())} + } + + windowName := TmuxWindowName(task.ID) + windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName) + + killAllWindowsByNameAllSessions(windowName) + + // Build the prompt content + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + o.logger.Error("could not create temp file", "error", err) + o.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create temp file: %s", err.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create temp file: %s", err.Error())} + } + + // Prepend working directory context - OpenClaw needs to know where to work + var fullPrompt strings.Builder + fullPrompt.WriteString("## Working Directory\n\n") + fullPrompt.WriteString(fmt.Sprintf("You are working in a git worktree at: `%s`\n\n", workDir)) + fullPrompt.WriteString("IMPORTANT: All file operations (reading, writing, creating files) MUST be done within this directory. ") + fullPrompt.WriteString("Do NOT use your default workspace. Always use absolute paths or paths relative to this working directory.\n\n") + fullPrompt.WriteString("---\n\n") + fullPrompt.WriteString(prompt) + if isResume && feedback != "" { + fullPrompt.WriteString("\n\n## User Feedback\n\n") + fullPrompt.WriteString(feedback) + } + promptFile.WriteString(fullPrompt.String()) + promptFile.Close() + defer os.Remove(promptFile.Name()) + + worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") + if worktreeSessionID == "" { + worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + envPrefix := claudeEnvPrefix(paths.configDir) + + // Build OpenClaw command with appropriate flags + // Use openclaw tui for interactive terminal UI (not openclaw agent which is one-shot) + // Use --session for session continuity, --message for initial prompt + sessionKey := fmt.Sprintf("task-%d", task.ID) + if task.ClaudeSessionID != "" { + sessionKey = task.ClaudeSessionID + } else { + // Save session key for future resumption + if err := o.executor.db.UpdateTaskClaudeSessionID(task.ID, sessionKey); err != nil { + o.logger.Warn("failed to save session ID", "task", task.ID, "error", err) + } + } + + thinkingFlag := buildOpenClawThinkingFlag() + + // openclaw tui --session --message "prompt" --thinking + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sopenclaw tui --session %s %s--message "$(cat %q)"`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, envPrefix, sessionKey, thinkingFlag, promptFile.Name()) + + actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) + if tmuxErr != nil { + o.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession) + o.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create tmux window: %s", tmuxErr.Error())} + } + + if actualSession != daemonSession { + windowTarget = fmt.Sprintf("%s:%s", actualSession, windowName) + daemonSession = actualSession + } + + time.Sleep(200 * time.Millisecond) + + if err := o.executor.db.UpdateTaskDaemonSession(task.ID, daemonSession); err != nil { + o.logger.Warn("failed to save daemon session", "task", task.ID, "error", err) + } + if windowID := getWindowID(daemonSession, windowName); windowID != "" { + if err := o.executor.db.UpdateTaskWindowID(task.ID, windowID); err != nil { + o.logger.Warn("failed to save window ID", "task", task.ID, "error", err) + } + } + + o.executor.ensureShellPane(windowTarget, workDir, task.ID, task.Port, task.WorktreePath, paths.configDir) + o.executor.configureTmuxWindow(windowTarget) + + result := o.executor.pollTmuxSession(ctx, task.ID, windowTarget) + + return ExecResult(result) +} + +// GetProcessID returns the PID of the OpenClaw process for a task. +func (o *OpenClawExecutor) GetProcessID(taskID int64) int { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + windowName := TmuxWindowName(taskID) + + out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-a", "-F", "#{session_name}:#{window_name}:#{pane_index} #{pane_pid}").Output() + if err != nil { + return 0 + } + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + target := parts[0] + pidStr := parts[1] + if !strings.Contains(target, windowName) { + continue + } + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + cmdOut, _ := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() + if strings.Contains(string(cmdOut), "openclaw") || strings.Contains(string(cmdOut), "node") { + return pid + } + // Check child processes (openclaw runs via node) + childOut, err := exec.CommandContext(ctx, "pgrep", "-P", strconv.Itoa(pid), "-f", "openclaw").Output() + if err == nil && len(childOut) > 0 { + childPid, err := strconv.Atoi(strings.TrimSpace(string(childOut))) + if err == nil { + return childPid + } + } + // Also check for node processes + nodeOut, err := exec.CommandContext(ctx, "pgrep", "-P", strconv.Itoa(pid), "node").Output() + if err == nil && len(nodeOut) > 0 { + nodePid, err := strconv.Atoi(strings.TrimSpace(string(nodeOut))) + if err == nil { + return nodePid + } + } + } + return 0 +} + +// Kill terminates the OpenClaw process for a task. +func (o *OpenClawExecutor) Kill(taskID int64) bool { + pid := o.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + o.logger.Debug("Failed to find OpenClaw process", "pid", pid, "error", err) + return false + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + o.logger.Debug("Failed to terminate OpenClaw process", "pid", pid, "error", err) + return false + } + o.logger.Info("Terminated OpenClaw process", "task", taskID, "pid", pid) + delete(o.suspendedTasks, taskID) + return true +} + +// Suspend pauses the OpenClaw process for a task. +func (o *OpenClawExecutor) Suspend(taskID int64) bool { + pid := o.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + o.logger.Debug("Failed to find process", "pid", pid, "error", err) + return false + } + if err := proc.Signal(syscall.SIGTSTP); err != nil { + o.logger.Debug("Failed to suspend process", "pid", pid, "error", err) + return false + } + o.suspendedTasks[taskID] = time.Now() + o.logger.Info("Suspended OpenClaw process", "task", taskID, "pid", pid) + o.executor.logLine(taskID, "system", "OpenClaw suspended (idle timeout)") + return true +} + +// IsSuspended reports whether the OpenClaw process is suspended for a task. +func (o *OpenClawExecutor) IsSuspended(taskID int64) bool { + _, suspended := o.suspendedTasks[taskID] + return suspended +} + +// ResumeProcess resumes a previously suspended OpenClaw process. +func (o *OpenClawExecutor) ResumeProcess(taskID int64) bool { + if !o.IsSuspended(taskID) { + return false + } + pid := o.GetProcessID(taskID) + if pid == 0 { + delete(o.suspendedTasks, taskID) + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + delete(o.suspendedTasks, taskID) + return false + } + if err := proc.Signal(syscall.SIGCONT); err != nil { + o.logger.Debug("Failed to resume process", "pid", pid, "error", err) + return false + } + delete(o.suspendedTasks, taskID) + o.logger.Info("Resumed OpenClaw process", "task", taskID, "pid", pid) + o.executor.logLine(taskID, "system", "OpenClaw resumed") + return true +} + +// BuildCommand returns the shell command to start an interactive OpenClaw session. +func (o *OpenClawExecutor) BuildCommand(task *db.Task, sessionID, prompt string) string { + worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") + if worktreeSessionID == "" { + worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + // Build session key - use existing session ID or create one from task ID + sessionKey := fmt.Sprintf("task-%d", task.ID) + if sessionID != "" { + sessionKey = sessionID + } else if task.ClaudeSessionID != "" { + sessionKey = task.ClaudeSessionID + } + + thinkingFlag := buildOpenClawThinkingFlag() + + envVars := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath) + + if prompt != "" { + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + o.logger.Error("BuildCommand: failed to create temp file", "error", err) + return fmt.Sprintf(`%s openclaw tui --session %s %s`, + envVars, sessionKey, thinkingFlag) + } + promptFile.WriteString(prompt) + promptFile.Close() + return fmt.Sprintf(`%s openclaw tui --session %s %s--message "$(cat %q)"; rm -f %q`, + envVars, sessionKey, thinkingFlag, promptFile.Name(), promptFile.Name()) + } + + return fmt.Sprintf(`%s openclaw tui --session %s %s`, + envVars, sessionKey, thinkingFlag) +} + +// buildOpenClawThinkingFlag returns the --thinking flag based on environment config. +func buildOpenClawThinkingFlag() string { + level := strings.TrimSpace(os.Getenv("OPENCLAW_THINKING")) + if level == "" { + // Default to high thinking for complex tasks + level = "high" + } + return fmt.Sprintf("--thinking %s ", level) +} + diff --git a/internal/ui/app.go b/internal/ui/app.go index 06b78ee..94b2c14 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -361,6 +361,8 @@ func taskExecutorDisplayName(task *db.Task) string { return "Claude" case db.ExecutorGemini: return "Gemini" + case db.ExecutorOpenClaw: + return "OpenClaw" default: // Unknown executor, capitalize first letter if len(task.Executor) > 0 { diff --git a/internal/ui/detail.go b/internal/ui/detail.go index b22fd6f..4964781 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -128,6 +128,8 @@ func (m *DetailModel) executorDisplayName() string { return "Claude" case db.ExecutorGemini: return "Gemini" + case db.ExecutorOpenClaw: + return "OpenClaw" default: // Unknown executor, capitalize first letter if len(m.task.Executor) > 0 { diff --git a/internal/ui/form.go b/internal/ui/form.go index a09593b..9fdfb63 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -138,7 +138,7 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int) *FormMo project: task.Project, originalProject: task.Project, // Track original project for detecting changes executor: executor, - executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini}, + executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorOpenClaw}, isEdit: true, prURL: task.PRURL, prNumber: task.PRNumber, @@ -261,7 +261,7 @@ func NewFormModel(database *db.DB, width, height int, workingDir string) *FormMo height: height, focused: FieldProject, executor: db.DefaultExecutor(), - executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini}, + executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorOpenClaw}, autocompleteSvc: autocompleteSvc, autocompleteEnabled: autocompleteEnabled, taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24),