From c912a0b76b61b6d544df4237a939369ae935c14f Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 30 Jan 2026 11:28:46 -0600 Subject: [PATCH 1/5] feat(executor): add OpenClaw as a task executor Add support for OpenClaw (https://openclaw.ai) as a pluggable task executor, enabling users to delegate complex workflows and AI-powered tasks through the platform. Key features: - Uses `openclaw agent --message` to pass prompts - Supports session resumption via `--session-id` flag - Configurable thinking depth via OPENCLAW_THINKING env var - Dangerous/auto-approve mode via OPENCLAW_DANGEROUS_ARGS env var - Full tmux integration matching existing executors Co-Authored-By: Claude Opus 4.5 --- README.md | 17 +- internal/db/tasks.go | 7 +- internal/executor/executor.go | 2 + internal/executor/openclaw_executor.go | 358 +++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 9 deletions(-) create mode 100644 internal/executor/openclaw_executor.go 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..c5ade3f --- /dev/null +++ b/internal/executor/openclaw_executor.go @@ -0,0 +1,358 @@ +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: +// - openclaw agent --message "prompt" - Run a prompt +// - openclaw agent --session-id - Resume a session +// - openclaw agent --thinking - Set reasoning depth (low/medium/high) +// - openclaw agent --local - Use embedded mode instead of Gateway +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())} + } + + fullPrompt := prompt + if isResume && feedback != "" { + fullPrompt = prompt + "\n\n## User Feedback\n\n" + feedback + } + promptFile.WriteString(fullPrompt) + 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 --session-id for session continuity, --message for the prompt + // Use --local for embedded mode (doesn't require Gateway) + sessionFlag := "" + if task.ClaudeSessionID != "" { + // Resume existing session + sessionFlag = fmt.Sprintf("--session-id %s ", task.ClaudeSessionID) + } else { + // Create new session ID based on task ID for future resumption + newSessionID := fmt.Sprintf("task-%d", task.ID) + sessionFlag = fmt.Sprintf("--session-id %s ", newSessionID) + // Save session ID for future resumption + if err := o.executor.db.UpdateTaskClaudeSessionID(task.ID, newSessionID); err != nil { + o.logger.Warn("failed to save session ID", "task", task.ID, "error", err) + } + } + + thinkingFlag := buildOpenClawThinkingFlag() + dangerousFlag := buildOpenClawDangerousFlag(task.DangerousMode) + + // openclaw agent --message "prompt" --session-id --local --thinking + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sopenclaw agent %s%s%s--message "$(cat %q)"`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, envPrefix, sessionFlag, thinkingFlag, dangerousFlag, 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 flag - use existing session ID or create one from task ID + sessionFlag := "" + if sessionID != "" { + sessionFlag = fmt.Sprintf("--session-id %s ", sessionID) + } else if task.ClaudeSessionID != "" { + sessionFlag = fmt.Sprintf("--session-id %s ", task.ClaudeSessionID) + } else { + sessionFlag = fmt.Sprintf("--session-id task-%d ", task.ID) + } + + thinkingFlag := buildOpenClawThinkingFlag() + dangerousFlag := buildOpenClawDangerousFlag(task.DangerousMode) + + 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 agent %s%s%s`, + envVars, sessionFlag, thinkingFlag, dangerousFlag) + } + promptFile.WriteString(prompt) + promptFile.Close() + return fmt.Sprintf(`%s openclaw agent %s%s%s--message "$(cat %q)"; rm -f %q`, + envVars, sessionFlag, thinkingFlag, dangerousFlag, promptFile.Name(), promptFile.Name()) + } + + return fmt.Sprintf(`%s openclaw agent %s%s%s`, + envVars, sessionFlag, thinkingFlag, dangerousFlag) +} + +// 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) +} + +// buildOpenClawDangerousFlag returns flags for dangerous/auto-approve mode. +func buildOpenClawDangerousFlag(enabled bool) string { + useDanger := enabled || os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" + if !useDanger { + return "" + } + flag := strings.TrimSpace(os.Getenv("OPENCLAW_DANGEROUS_ARGS")) + if flag == "" { + // OpenClaw uses --local for embedded mode which auto-approves actions + flag = "--local" + } + if !strings.HasSuffix(flag, " ") { + flag += " " + } + return flag +} From d51569d7db44a5ce000886db2f7dcd295a8d0996 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 30 Jan 2026 13:06:50 -0600 Subject: [PATCH 2/5] fix(ui): add OpenClaw to executor selection in task form Add OpenClaw to the list of available executors in: - Task creation form - Task edit form - Executor display name helpers Co-Authored-By: Claude Opus 4.5 --- internal/ui/app.go | 2 ++ internal/ui/detail.go | 2 ++ internal/ui/form.go | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) 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), From 7b9ccd601eafb2d2af4fb233fb6057479a23e82d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 30 Jan 2026 13:26:29 -0600 Subject: [PATCH 3/5] fix(executor): use openclaw tui instead of one-shot agent command The `openclaw agent` command is a one-shot API call that returns immediately, which doesn't work with our tmux-based polling architecture. Switch to `openclaw tui` which is an interactive terminal UI that stays running. Changes: - Use `openclaw tui` instead of `openclaw agent` - Use `--session ` instead of `--session-id ` - Remove unused dangerous mode flag (tui doesn't have it) Co-Authored-By: Claude Opus 4.5 --- internal/executor/openclaw_executor.go | 71 +++++++++----------------- 1 file changed, 23 insertions(+), 48 deletions(-) diff --git a/internal/executor/openclaw_executor.go b/internal/executor/openclaw_executor.go index c5ade3f..29e35e3 100644 --- a/internal/executor/openclaw_executor.go +++ b/internal/executor/openclaw_executor.go @@ -19,11 +19,10 @@ import ( // capable of automating tasks across your digital life. // See: https://openclaw.ai // -// CLI Reference: -// - openclaw agent --message "prompt" - Run a prompt -// - openclaw agent --session-id - Resume a session -// - openclaw agent --thinking - Set reasoning depth (low/medium/high) -// - openclaw agent --local - Use embedded mode instead of Gateway +// 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 @@ -110,28 +109,23 @@ func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workD envPrefix := claudeEnvPrefix(paths.configDir) // Build OpenClaw command with appropriate flags - // Use --session-id for session continuity, --message for the prompt - // Use --local for embedded mode (doesn't require Gateway) - sessionFlag := "" + // 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 != "" { - // Resume existing session - sessionFlag = fmt.Sprintf("--session-id %s ", task.ClaudeSessionID) + sessionKey = task.ClaudeSessionID } else { - // Create new session ID based on task ID for future resumption - newSessionID := fmt.Sprintf("task-%d", task.ID) - sessionFlag = fmt.Sprintf("--session-id %s ", newSessionID) - // Save session ID for future resumption - if err := o.executor.db.UpdateTaskClaudeSessionID(task.ID, newSessionID); err != nil { + // 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() - dangerousFlag := buildOpenClawDangerousFlag(task.DangerousMode) - // openclaw agent --message "prompt" --session-id --local --thinking - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sopenclaw agent %s%s%s--message "$(cat %q)"`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, envPrefix, sessionFlag, thinkingFlag, dangerousFlag, promptFile.Name()) + // 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 { @@ -297,18 +291,15 @@ func (o *OpenClawExecutor) BuildCommand(task *db.Task, sessionID, prompt string) worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) } - // Build session flag - use existing session ID or create one from task ID - sessionFlag := "" + // Build session key - use existing session ID or create one from task ID + sessionKey := fmt.Sprintf("task-%d", task.ID) if sessionID != "" { - sessionFlag = fmt.Sprintf("--session-id %s ", sessionID) + sessionKey = sessionID } else if task.ClaudeSessionID != "" { - sessionFlag = fmt.Sprintf("--session-id %s ", task.ClaudeSessionID) - } else { - sessionFlag = fmt.Sprintf("--session-id task-%d ", task.ID) + sessionKey = task.ClaudeSessionID } thinkingFlag := buildOpenClawThinkingFlag() - dangerousFlag := buildOpenClawDangerousFlag(task.DangerousMode) envVars := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q`, task.ID, worktreeSessionID, task.Port, task.WorktreePath) @@ -317,17 +308,17 @@ func (o *OpenClawExecutor) BuildCommand(task *db.Task, sessionID, prompt string) 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 agent %s%s%s`, - envVars, sessionFlag, thinkingFlag, dangerousFlag) + return fmt.Sprintf(`%s openclaw tui --session %s %s`, + envVars, sessionKey, thinkingFlag) } promptFile.WriteString(prompt) promptFile.Close() - return fmt.Sprintf(`%s openclaw agent %s%s%s--message "$(cat %q)"; rm -f %q`, - envVars, sessionFlag, thinkingFlag, dangerousFlag, promptFile.Name(), promptFile.Name()) + 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 agent %s%s%s`, - envVars, sessionFlag, thinkingFlag, dangerousFlag) + return fmt.Sprintf(`%s openclaw tui --session %s %s`, + envVars, sessionKey, thinkingFlag) } // buildOpenClawThinkingFlag returns the --thinking flag based on environment config. @@ -340,19 +331,3 @@ func buildOpenClawThinkingFlag() string { return fmt.Sprintf("--thinking %s ", level) } -// buildOpenClawDangerousFlag returns flags for dangerous/auto-approve mode. -func buildOpenClawDangerousFlag(enabled bool) string { - useDanger := enabled || os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" - if !useDanger { - return "" - } - flag := strings.TrimSpace(os.Getenv("OPENCLAW_DANGEROUS_ARGS")) - if flag == "" { - // OpenClaw uses --local for embedded mode which auto-approves actions - flag = "--local" - } - if !strings.HasSuffix(flag, " ") { - flag += " " - } - return flag -} From a1cc9bbf72724b99052b13f817a6455a42b05584 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 30 Jan 2026 13:33:30 -0600 Subject: [PATCH 4/5] fix(executor): add working directory context to OpenClaw prompts OpenClaw uses its own workspace by default. Add explicit working directory instructions to the prompt so it knows to work in the task's worktree instead. Co-Authored-By: Claude Opus 4.5 --- internal/executor/openclaw_executor.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/executor/openclaw_executor.go b/internal/executor/openclaw_executor.go index 29e35e3..7f2a352 100644 --- a/internal/executor/openclaw_executor.go +++ b/internal/executor/openclaw_executor.go @@ -93,11 +93,19 @@ func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workD return ExecResult{Message: fmt.Sprintf("failed to create temp file: %s", err.Error())} } - fullPrompt := prompt + // Prepend working directory context - OpenClaw needs to know where to work + var fullPrompt strings.Builder + fullPrompt.WriteString(fmt.Sprintf("## 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 = prompt + "\n\n## User Feedback\n\n" + feedback + fullPrompt.WriteString("\n\n## User Feedback\n\n") + fullPrompt.WriteString(feedback) } - promptFile.WriteString(fullPrompt) + promptFile.WriteString(fullPrompt.String()) promptFile.Close() defer os.Remove(promptFile.Name()) From 544ed65a92eb4904e455d292861a0ae9938043ad Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 30 Jan 2026 13:37:40 -0600 Subject: [PATCH 5/5] fix: remove unnecessary fmt.Sprintf Co-Authored-By: Claude Opus 4.5 --- internal/executor/openclaw_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/executor/openclaw_executor.go b/internal/executor/openclaw_executor.go index 7f2a352..e574104 100644 --- a/internal/executor/openclaw_executor.go +++ b/internal/executor/openclaw_executor.go @@ -95,7 +95,7 @@ func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workD // Prepend working directory context - OpenClaw needs to know where to work var fullPrompt strings.Builder - fullPrompt.WriteString(fmt.Sprintf("## Working Directory\n\n")) + 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")