diff --git a/cmd/task/main.go b/cmd/task/main.go index b94985f0..10ec38c1 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -524,7 +524,7 @@ Examples: } // Validate executor if provided - validExecutors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorPi, db.ExecutorOpenCode, db.ExecutorOpenClaw} + validExecutors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorPi, db.ExecutorOpenCode, db.ExecutorOpenClaw, db.ExecutorVibe} if taskExecutor != "" { validExecutor := false for _, e := range validExecutors { @@ -626,7 +626,7 @@ Examples: createCmd.Flags().String("body", "", "Task body/description (if no title, AI generates from body)") createCmd.Flags().StringP("type", "t", "", "Task type: code, writing, thinking (default: code)") createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)") - createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, pi, opencode, openclaw (default: claude)") + createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, pi, opencode, openclaw, vibe (default: claude)") createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution") createCmd.Flags().String("tags", "", "Task tags (comma-separated)") createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column") @@ -1210,7 +1210,7 @@ Examples: // Validate executor if provided if taskExecutor != "" { - validExecutors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorPi, db.ExecutorOpenCode, db.ExecutorOpenClaw} + validExecutors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorPi, db.ExecutorOpenCode, db.ExecutorOpenClaw, db.ExecutorVibe} validExecutor := false for _, e := range validExecutors { if e == taskExecutor { @@ -1269,7 +1269,7 @@ Examples: updateCmd.Flags().String("body", "", "Update task body/description") updateCmd.Flags().StringP("type", "t", "", "Update task type: code, writing, thinking") updateCmd.Flags().StringP("project", "p", "", "Update project name") - updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, pi, opencode, openclaw") + updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, pi, opencode, openclaw, vibe") updateCmd.Flags().String("tags", "", "Update task tags (comma-separated)") updateCmd.Flags().Bool("pinned", false, "Pin or unpin the task") rootCmd.AddCommand(updateCmd) diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 3507e62b..33ba340c 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -79,6 +79,7 @@ const ( ExecutorOpenClaw = "openclaw" // OpenClaw AI assistant (https://openclaw.ai) ExecutorOpenCode = "opencode" // OpenCode AI assistant (https://opencode.ai) ExecutorPi = "pi" // Pi coding agent (https://github.com/mariozechner/pi-coding-agent) + ExecutorVibe = "vibe" // Mistral Vibe executor ) // DefaultExecutor returns the default executor if none is specified. diff --git a/internal/executor/dangerous_mode_test.go b/internal/executor/dangerous_mode_test.go index b39c99ca..dfb2a58d 100644 --- a/internal/executor/dangerous_mode_test.go +++ b/internal/executor/dangerous_mode_test.go @@ -80,6 +80,13 @@ func TestExecutorInterfaceImplementation(t *testing.T) { supportsDangerousMode: false, // Pi does not support dangerous mode dangerousFlag: "", }, + { + name: "Vibe executor", + executorName: db.ExecutorVibe, + supportsSessionResume: false, // Vibe does not support session resume + supportsDangerousMode: true, // Vibe supports --dangerous flag + dangerousFlag: "--dangerous", + }, } for _, tt := range tests { @@ -1021,7 +1028,7 @@ func TestBuildCommandIncludesEnvironmentVariables(t *testing.T) { WorktreePath: "/home/user/projects/myapp/.task-worktrees/42-fix-bug", } - executors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorOpenClaw, db.ExecutorOpenCode} + executors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorOpenClaw, db.ExecutorOpenCode, db.ExecutorVibe} for _, name := range executors { t.Run(name, func(t *testing.T) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 89e33fcb..67c78a2a 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -169,6 +169,7 @@ func New(database *db.DB, cfg *config.Config) *Executor { e.executorFactory.Register(NewOpenClawExecutor(e)) e.executorFactory.Register(NewOpenCodeExecutor(e)) e.executorFactory.Register(NewPiExecutor(e)) + e.executorFactory.Register(NewVibeExecutor(e)) return e } @@ -207,6 +208,7 @@ func NewWithLogging(database *db.DB, cfg *config.Config, w io.Writer) *Executor e.executorFactory.Register(NewOpenClawExecutor(e)) e.executorFactory.Register(NewOpenCodeExecutor(e)) e.executorFactory.Register(NewPiExecutor(e)) + e.executorFactory.Register(NewVibeExecutor(e)) return e } @@ -2994,6 +2996,90 @@ func (e *Executor) resumeGeminiWithMode(task *db.Task, workDir string, dangerous return true } +// resumeVibeWithMode kills the current Vibe process and restarts with the specified mode. +// If dangerousMode is true, uses --dangerous flag. +func (e *Executor) resumeVibeWithMode(task *db.Task, workDir string, dangerousMode bool) bool { + taskID := task.ID + paths := e.claudePathsForProject(task.Project) + + modeStr := "safe" + if dangerousMode { + modeStr = "dangerous" + } + e.logLine(taskID, "system", fmt.Sprintf("Restarting Vibe in %s mode", modeStr)) + + if _, err := exec.LookPath("tmux"); err != nil { + e.logLine(taskID, "system", "Tmux not available - cannot resume") + return false + } + + windowName := TmuxWindowName(taskID) + // Kill ALL existing windows with this name (handles duplicates) + killAllWindowsByNameAllSessions(windowName) + + daemonSession, err := ensureTmuxDaemon() + if err != nil { + e.logger.Warn("could not create task-daemon session", "error", err) + return false + } + + windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName) + + taskSessionID := os.Getenv("WORKTREE_SESSION_ID") + if taskSessionID == "" { + taskSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + // Build dangerous flag + dangerousFlag := "" + if dangerousMode { + flag := strings.TrimSpace(os.Getenv("VIBE_DANGEROUS_ARGS")) + if flag == "" { + flag = "--dangerous" + } + dangerousFlag = flag + " " + } + + // Build script - Vibe doesn't support session resume, so we start fresh + envPrefix := claudeEnvPrefix(paths.configDir) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %svibe %s`, + taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag) + + actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, e.getProjectDir(task.Project)) + if tmuxErr != nil { + e.logger.Warn("tmux failed to create window", "error", tmuxErr, "session", daemonSession) + return false + } + + if actualSession != daemonSession { + windowTarget = fmt.Sprintf("%s:%s", actualSession, windowName) + daemonSession = actualSession + } + + time.Sleep(200 * time.Millisecond) + + if err := e.db.UpdateTaskDaemonSession(taskID, daemonSession); err != nil { + e.logger.Warn("failed to save daemon session", "task", taskID, "error", err) + } + + if windowID := getWindowID(daemonSession, windowName); windowID != "" { + if err := e.db.UpdateTaskWindowID(taskID, windowID); err != nil { + e.logger.Warn("failed to save window ID", "task", taskID, "error", err) + } + } + + e.ensureShellPane(windowTarget, workDir, task.ID, task.Port, task.WorktreePath, paths.configDir) + e.configureTmuxWindow(windowTarget) + + if err := e.db.UpdateTaskDangerousMode(taskID, dangerousMode); err != nil { + e.logger.Warn("could not update task dangerous mode", "error", err) + } + + e.logLine(taskID, "system", fmt.Sprintf("Vibe restarted in %s mode", modeStr)) + + return true +} + // FindClaudeSessionID finds the most recent claude session ID for a workDir using the default config dir. // Exported for use by the UI to check for resumable sessions. func FindClaudeSessionID(workDir string) string { diff --git a/internal/executor/vibe_executor.go b/internal/executor/vibe_executor.go new file mode 100644 index 00000000..7625ba0b --- /dev/null +++ b/internal/executor/vibe_executor.go @@ -0,0 +1,321 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/log" + + "github.com/bborn/workflow/internal/db" +) + +// VibeExecutor implements TaskExecutor for Mistral Vibe. +type VibeExecutor struct { + executor *Executor + logger *log.Logger + suspendedTasks map[int64]time.Time +} + +// NewVibeExecutor creates a new Vibe executor. +func NewVibeExecutor(e *Executor) *VibeExecutor { + return &VibeExecutor{ + executor: e, + logger: e.logger, + suspendedTasks: make(map[int64]time.Time), + } +} + +// Name returns the executor name. +func (v *VibeExecutor) Name() string { + return db.ExecutorVibe +} + +// IsAvailable checks if the vibe CLI is installed. +func (v *VibeExecutor) IsAvailable() bool { + _, err := exec.LookPath("vibe") + return err == nil +} + +// Execute runs a task using the Vibe CLI. +func (v *VibeExecutor) Execute(ctx context.Context, task *db.Task, workDir, prompt string) ExecResult { + return v.runVibe(ctx, task, workDir, prompt, "", false) +} + +// Resume runs Vibe again with the full prompt plus feedback since sessions are stateless. +func (v *VibeExecutor) Resume(ctx context.Context, task *db.Task, workDir, prompt, feedback string) ExecResult { + return v.runVibe(ctx, task, workDir, prompt, feedback, true) +} + +func (v *VibeExecutor) runVibe(ctx context.Context, task *db.Task, workDir, prompt, feedback string, isResume bool) ExecResult { + paths := v.executor.claudePathsForProject(task.Project) + + if !v.IsAvailable() { + v.executor.logLine(task.ID, "error", "vibe CLI is not installed") + return ExecResult{Message: "vibe CLI is not installed"} + } + + if _, err := exec.LookPath("tmux"); err != nil { + v.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 { + v.logger.Error("could not create task-daemon session", "error", err) + v.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) + + // Kill ALL existing windows with this name (handles duplicates) + killAllWindowsByNameAllSessions(windowName) + + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + v.logger.Error("could not create temp file", "error", err) + v.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()) + + sessionID := os.Getenv("WORKTREE_SESSION_ID") + if sessionID == "" { + sessionID = fmt.Sprintf("%d", os.Getpid()) + } + + envPrefix := claudeEnvPrefix(paths.configDir) + dangerousFlag := buildVibeDangerousFlag(task.DangerousMode) + // Vibe uses -p (--prompt) to pass initial prompt + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %svibe %s-p "$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, promptFile.Name()) + + actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, v.executor.getProjectDir(task.Project)) + if tmuxErr != nil { + v.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession) + v.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 := v.executor.db.UpdateTaskDaemonSession(task.ID, daemonSession); err != nil { + v.logger.Warn("failed to save daemon session", "task", task.ID, "error", err) + } + if windowID := getWindowID(daemonSession, windowName); windowID != "" { + if err := v.executor.db.UpdateTaskWindowID(task.ID, windowID); err != nil { + v.logger.Warn("failed to save window ID", "task", task.ID, "error", err) + } + } + + v.executor.ensureShellPane(windowTarget, workDir, task.ID, task.Port, task.WorktreePath, paths.configDir) + v.executor.configureTmuxWindow(windowTarget) + + result := v.executor.pollTmuxSession(ctx, task.ID, windowTarget) + + return ExecResult(result) +} + +// GetProcessID returns the PID of the Vibe process for a task. +func (v *VibeExecutor) 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), "vibe") { + return pid + } + childOut, err := exec.CommandContext(ctx, "pgrep", "-P", strconv.Itoa(pid), "vibe").Output() + if err == nil && len(childOut) > 0 { + childPid, err := strconv.Atoi(strings.TrimSpace(string(childOut))) + if err == nil { + return childPid + } + } + } + return 0 +} + +// Kill terminates the Vibe process for a task. +func (v *VibeExecutor) Kill(taskID int64) bool { + pid := v.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + v.logger.Debug("Failed to find Vibe process", "pid", pid, "error", err) + return false + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + v.logger.Debug("Failed to terminate Vibe process", "pid", pid, "error", err) + return false + } + v.logger.Info("Terminated Vibe process", "task", taskID, "pid", pid) + delete(v.suspendedTasks, taskID) + return true +} + +// Suspend pauses the Vibe process for a task. +func (v *VibeExecutor) Suspend(taskID int64) bool { + pid := v.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + v.logger.Debug("Failed to find process", "pid", pid, "error", err) + return false + } + if err := sendSIGTSTP(proc); err != nil { + v.logger.Debug("Failed to suspend process", "pid", pid, "error", err) + return false + } + v.suspendedTasks[taskID] = time.Now() + v.logger.Info("Suspended Vibe process", "task", taskID, "pid", pid) + v.executor.logLine(taskID, "system", "Vibe suspended (idle timeout)") + return true +} + +// IsSuspended reports whether the Vibe process is suspended for a task. +func (v *VibeExecutor) IsSuspended(taskID int64) bool { + _, suspended := v.suspendedTasks[taskID] + return suspended +} + +// ResumeProcess resumes a previously suspended Vibe process. +func (v *VibeExecutor) ResumeProcess(taskID int64) bool { + if !v.IsSuspended(taskID) { + return false + } + pid := v.GetProcessID(taskID) + if pid == 0 { + delete(v.suspendedTasks, taskID) + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + delete(v.suspendedTasks, taskID) + return false + } + if err := sendSIGCONT(proc); err != nil { + v.logger.Debug("Failed to resume process", "pid", pid, "error", err) + return false + } + delete(v.suspendedTasks, taskID) + v.logger.Info("Resumed Vibe process", "task", taskID, "pid", pid) + v.executor.logLine(taskID, "system", "Vibe resumed") + return true +} + +// BuildCommand returns the shell command to start an interactive Vibe session. +func (v *VibeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) string { + dangerousFlag := buildVibeDangerousFlag(task.DangerousMode) + + worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") + if worktreeSessionID == "" { + worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + if prompt != "" { + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + v.logger.Error("BuildCommand: failed to create temp file", "error", err) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q vibe %s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag) + } + promptFile.WriteString(prompt) + promptFile.Close() + // Use -p (--prompt) to pass initial prompt + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q vibe %s-p "$(cat %q)"; rm -f %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, promptFile.Name(), promptFile.Name()) + } + + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q vibe %s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag) +} + +func buildVibeDangerousFlag(enabled bool) string { + useDanger := enabled || os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" + if !useDanger { + return "" + } + flag := strings.TrimSpace(os.Getenv("VIBE_DANGEROUS_ARGS")) + if flag == "" { + flag = "--dangerous" + } + if !strings.HasSuffix(flag, " ") { + flag += " " + } + return flag +} + +// ---- Session and Dangerous Mode Support ---- + +// SupportsSessionResume returns false - Vibe doesn't support session resume. +func (v *VibeExecutor) SupportsSessionResume() bool { + return false +} + +// SupportsDangerousMode returns true - Vibe supports --dangerous flag. +func (v *VibeExecutor) SupportsDangerousMode() bool { + return true +} + +// FindSessionID is not supported for Vibe. +func (v *VibeExecutor) FindSessionID(workDir string) string { + return "" +} + +// ResumeDangerous kills the current Vibe process and restarts with --dangerous. +func (v *VibeExecutor) ResumeDangerous(task *db.Task, workDir string) bool { + return v.executor.resumeVibeWithMode(task, workDir, true) +} + +// ResumeSafe kills the current Vibe process and restarts without the dangerous flag. +func (v *VibeExecutor) ResumeSafe(task *db.Task, workDir string) bool { + return v.executor.resumeVibeWithMode(task, workDir, false) +} \ No newline at end of file