diff --git a/internal/db/dependencies.go b/internal/db/dependencies.go index 636e1348..e05bb7ef 100644 --- a/internal/db/dependencies.go +++ b/internal/db/dependencies.go @@ -104,7 +104,7 @@ func (db *DB) RemoveDependency(blockerID, blockedID int64) error { // GetBlockers returns all tasks that block the given task. func (db *DB) GetBlockers(taskID int64) ([]*Task, error) { rows, err := db.Query(` - SELECT t.id, t.title, t.body, t.status, t.type, t.project, COALESCE(t.executor, 'claude'), + SELECT t.id, t.title, t.body, t.status, t.type, t.project, COALESCE(t.executor, 'claude'), COALESCE(t.model, ''), t.worktree_path, t.branch_name, t.port, t.claude_session_id, COALESCE(t.daemon_session, ''), COALESCE(t.tmux_window_id, ''), COALESCE(t.claude_pane_id, ''), COALESCE(t.shell_pane_id, ''), @@ -128,7 +128,7 @@ func (db *DB) GetBlockers(taskID int64) ([]*Task, error) { // GetBlockedBy returns all tasks that are blocked by the given task. func (db *DB) GetBlockedBy(taskID int64) ([]*Task, error) { rows, err := db.Query(` - SELECT t.id, t.title, t.body, t.status, t.type, t.project, COALESCE(t.executor, 'claude'), + SELECT t.id, t.title, t.body, t.status, t.type, t.project, COALESCE(t.executor, 'claude'), COALESCE(t.model, ''), t.worktree_path, t.branch_name, t.port, t.claude_session_id, COALESCE(t.daemon_session, ''), COALESCE(t.tmux_window_id, ''), COALESCE(t.claude_pane_id, ''), COALESCE(t.shell_pane_id, ''), @@ -303,7 +303,7 @@ func scanTaskRows(rows *sql.Rows) ([]*Task, error) { for rows.Next() { t := &Task{} err := rows.Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index f352bd77..9ec9cfaa 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -249,6 +249,8 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN archive_commit TEXT DEFAULT ''`, // Commit hash at time of archiving `ALTER TABLE tasks ADD COLUMN archive_worktree_path TEXT DEFAULT ''`, // Original worktree path before archiving `ALTER TABLE tasks ADD COLUMN archive_branch_name TEXT DEFAULT ''`, // Original branch name before archiving + // Model override for executor (e.g., "opus", "sonnet", "claude-opus-4-6") + `ALTER TABLE tasks ADD COLUMN model TEXT DEFAULT ''`, // Executor model override (empty = executor default) } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index b087391e..c35d0111 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -18,6 +18,7 @@ type Task struct { Type string Project string Executor string // Task executor: "claude" (default), "codex", "gemini" + Model string // Model override for the executor (e.g., "opus", "sonnet", "claude-opus-4-6") WorktreePath string BranchName string Port int // Unique port for running the application in this task's worktree @@ -126,9 +127,9 @@ func (db *DB) CreateTask(t *Task) error { } result, err := db.Exec(` - INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags) + INSERT INTO tasks (title, body, status, type, project, executor, model, pinned, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Model, t.Pinned, t.Tags) if err != nil { return fmt.Errorf("insert task: %w", err) } @@ -167,7 +168,7 @@ func (db *DB) CreateTask(t *Task) error { func (db *DB) GetTask(id int64) (*Task, error) { t := &Task{} err := db.QueryRow(` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -179,7 +180,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') FROM tasks WHERE id = ? `, id).Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -210,7 +211,7 @@ type ListTasksOptions struct { // ListTasks retrieves tasks with optional filters. func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { query := ` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -265,7 +266,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { for rows.Next() { t := &Task{} err := rows.Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -288,7 +289,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { t := &Task{} err := db.QueryRow(` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -302,7 +303,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { ORDER BY created_at DESC, id DESC LIMIT 1 `).Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -329,7 +330,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { // Build search query with LIKE clauses sqlQuery := ` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -362,7 +363,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { for rows.Next() { t := &Task{} err := rows.Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -455,13 +456,13 @@ func (db *DB) UpdateTask(t *Task) error { _, err := db.Exec(` UPDATE tasks SET - title = ?, body = ?, status = ?, type = ?, project = ?, executor = ?, + title = ?, body = ?, status = ?, type = ?, project = ?, executor = ?, model = ?, worktree_path = ?, branch_name = ?, port = ?, claude_session_id = ?, daemon_session = ?, pr_url = ?, pr_number = ?, dangerous_mode = ?, pinned = ?, tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Model, t.WorktreePath, t.BranchName, t.Port, t.ClaudeSessionID, t.DaemonSession, t.PRURL, t.PRNumber, t.DangerousMode, t.Pinned, t.Tags, t.ID) @@ -703,7 +704,7 @@ func (db *DB) RetryTask(id int64, feedback string) error { func (db *DB) GetNextQueuedTask() (*Task, error) { t := &Task{} err := db.QueryRow(` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -718,7 +719,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { ORDER BY created_at ASC LIMIT 1 `, StatusQueued).Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -739,7 +740,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { // GetQueuedTasks returns all queued tasks (waiting to be processed). func (db *DB) GetQueuedTasks() ([]*Task, error) { rows, err := db.Query(` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -762,7 +763,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { for rows.Next() { t := &Task{} if err := rows.Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, @@ -782,7 +783,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { // These are candidates for automatic closure when their PR is merged. func (db *DB) GetTasksWithBranches() ([]*Task, error) { rows, err := db.Query(` - SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), COALESCE(model, ''), worktree_path, branch_name, port, claude_session_id, COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), @@ -805,7 +806,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { for rows.Next() { t := &Task{} if err := rows.Scan( - &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, &t.Model, &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, diff --git a/internal/executor/claude_executor.go b/internal/executor/claude_executor.go index 78233630..3edb3b99 100644 --- a/internal/executor/claude_executor.go +++ b/internal/executor/claude_executor.go @@ -81,6 +81,12 @@ func (c *ClaudeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s dangerousFlag = "--dangerously-skip-permissions " } + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Get session ID for environment worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") if worktreeSessionID == "" { @@ -99,8 +105,8 @@ func (c *ClaudeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s // Build command - resume if we have a session ID, otherwise start fresh if sessionID != "" { - cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s--chrome --resume %s`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, systemPromptFlag, sessionID) + cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s%s--chrome --resume %s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, systemPromptFlag, sessionID) if systemFile != nil { cmd += fmt.Sprintf(`; rm -f %q`, systemFile.Name()) } @@ -113,8 +119,8 @@ func (c *ClaudeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s promptFile, err := os.CreateTemp("", "task-prompt-*.txt") if err != nil { c.logger.Error("BuildCommand: failed to create temp file", "error", err) - cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s--chrome`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, systemPromptFlag) + cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s%s--chrome`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, systemPromptFlag) if systemFile != nil { cmd += fmt.Sprintf(`; rm -f %q`, systemFile.Name()) } @@ -123,16 +129,16 @@ func (c *ClaudeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s promptFile.WriteString(prompt) promptFile.Close() - cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s--chrome "$(cat %q)"; rm -f %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, systemPromptFlag, promptFile.Name(), promptFile.Name()) + cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s%s--chrome "$(cat %q)"; rm -f %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, systemPromptFlag, promptFile.Name(), promptFile.Name()) if systemFile != nil { cmd += fmt.Sprintf(` %q`, systemFile.Name()) } return cmd } - cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s--chrome`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, systemPromptFlag) + cmd := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s%s%s--chrome`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, systemPromptFlag) if systemFile != nil { cmd += fmt.Sprintf(`; rm -f %q`, systemFile.Name()) } diff --git a/internal/executor/codex_executor.go b/internal/executor/codex_executor.go index 7f6bb0af..d9f445b7 100644 --- a/internal/executor/codex_executor.go +++ b/internal/executor/codex_executor.go @@ -139,6 +139,12 @@ func (c *CodexExecutor) runCodex(ctx context.Context, task *db.Task, workDir, pr dangerousFlag = "--dangerously-bypass-approvals-and-sandbox " } + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Check for existing session to resume (validate file exists first) resumeFlag := "" existingSessionID := task.ClaudeSessionID @@ -156,8 +162,8 @@ func (c *CodexExecutor) runCodex(ctx context.Context, task *db.Task, workDir, pr } envPrefix := claudeEnvPrefix(paths.configDir) - script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %scodex %s%s"$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, resumeFlag, promptFile.Name()) + script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %scodex %s%s%s"$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, resumeFlag, promptFile.Name()) // Create new window in task-daemon session actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) @@ -356,6 +362,12 @@ func (c *CodexExecutor) BuildCommand(task *db.Task, sessionID, prompt string) st worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) } + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Build resume flag if we have a session ID resumeFlag := "" if sessionID != "" { @@ -368,20 +380,20 @@ func (c *CodexExecutor) BuildCommand(task *db.Task, sessionID, prompt string) st promptFile, err := os.CreateTemp("", "task-prompt-*.txt") if err != nil { c.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 codex %s%s`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q codex %s%s%s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag) } // Include system instructions in prompt (AGENTS.md could overwrite project files) fullPrompt := prompt + "\n\n" + c.executor.buildSystemInstructions() promptFile.WriteString(fullPrompt) promptFile.Close() - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q codex %s%s"$(cat %q)"; rm -f %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag, promptFile.Name(), promptFile.Name()) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q codex %s%s%s"$(cat %q)"; rm -f %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag, promptFile.Name(), promptFile.Name()) } - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q codex %s%s`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q codex %s%s%s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag) } // ---- Session and Dangerous Mode Support ---- diff --git a/internal/executor/executor.go b/internal/executor/executor.go index ea761318..259dc374 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -923,11 +923,19 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { if len(attachmentPaths) > 0 { feedbackWithAttachments = retryFeedback + "\n" + e.getAttachmentsSection(task.ID, attachmentPaths, workDir) } - e.logLine(task.ID, "system", fmt.Sprintf("Resuming previous session with feedback (executor: %s)", executorName)) + modelInfo := "" + if task.Model != "" { + modelInfo = fmt.Sprintf(", model: %s", task.Model) + } + e.logLine(task.ID, "system", fmt.Sprintf("Resuming previous session with feedback (executor: %s%s)", executorName, modelInfo)) execResult := taskExecutor.Resume(taskCtx, task, workDir, prompt, feedbackWithAttachments) result = execResult.toInternal() } else { - e.logLine(task.ID, "system", fmt.Sprintf("Starting new session (executor: %s)", executorName)) + modelInfo := "" + if task.Model != "" { + modelInfo = fmt.Sprintf(", model: %s", task.Model) + } + e.logLine(task.ID, "system", fmt.Sprintf("Starting new session (executor: %s%s)", executorName, modelInfo)) execResult := taskExecutor.Execute(taskCtx, task, workDir, prompt) result = execResult.toInternal() } @@ -1986,6 +1994,11 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt if task.DangerousMode || os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" { dangerousFlag = "--dangerously-skip-permissions " } + // Use --model if task has a model override + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) @@ -1997,8 +2010,8 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt envPrefix := claudeEnvPrefix(paths.configDir) if existingSessionID != "" && ClaudeSessionExists(existingSessionID, workDir, paths.configDir) { e.logLine(task.ID, "system", fmt.Sprintf("Resuming existing session %s", existingSessionID)) - script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s--chrome --resume %s "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, systemPromptFlag, existingSessionID, promptFile.Name()) + script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s%s--chrome --resume %s "$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, systemPromptFlag, existingSessionID, promptFile.Name()) } else { if existingSessionID != "" { e.logLine(task.ID, "system", fmt.Sprintf("Session %s no longer exists, starting fresh", existingSessionID)) @@ -2007,8 +2020,8 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt e.logger.Warn("failed to clear stale session ID", "task", task.ID, "error", err) } } - script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s--chrome "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, systemPromptFlag, promptFile.Name()) + script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s%s--chrome "$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, systemPromptFlag, promptFile.Name()) } // Create new window in task-daemon session (with retry logic for race conditions) @@ -2160,12 +2173,17 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir, if task.DangerousMode || os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" { dangerousFlag = "--dangerously-skip-permissions " } + // Use --model if task has a model override + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s--chrome --resume %s "$(cat %q)"`, - task.ID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, systemPromptFlag, claudeSessionID, feedbackFile.Name()) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s%s--chrome --resume %s "$(cat %q)"`, + task.ID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, systemPromptFlag, claudeSessionID, feedbackFile.Name()) // Create new window in task-daemon session (with retry logic for race conditions) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) @@ -2320,9 +2338,14 @@ func (e *Executor) resumeClaudeDangerous(task *db.Task, workDir string) bool { } // Force dangerous mode regardless of WORKTREE_DANGEROUS_MODE setting + // Use --model if task has a model override + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude --dangerously-skip-permissions --chrome --resume %s`, - taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, claudeSessionID) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude --dangerously-skip-permissions %s--chrome --resume %s`, + taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, modelFlag, claudeSessionID) // Create new window in task-daemon session (with retry logic for race conditions) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) @@ -2485,9 +2508,14 @@ func (e *Executor) resumeClaudeSafe(task *db.Task, workDir string) bool { } // Resume without --dangerously-skip-permissions (safe mode) + // Use --model if task has a model override + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude --chrome --resume %s`, - taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, claudeSessionID) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s--chrome --resume %s`, + taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, modelFlag, claudeSessionID) // Create new window in task-daemon session (with retry logic for race conditions) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) @@ -2602,10 +2630,16 @@ func (e *Executor) resumeCodexWithMode(task *db.Task, workDir string, dangerousM dangerousFlag = "--dangerously-bypass-approvals-and-sandbox " } + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Build script with --resume flag envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %scodex %s--resume %s`, - taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, sessionID) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %scodex %s%s--resume %s`, + taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, sessionID) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) if tmuxErr != nil { @@ -2710,10 +2744,16 @@ func (e *Executor) resumeGeminiWithMode(task *db.Task, workDir string, dangerous dangerousFlag = flag + " " } + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Build script with --resume flag envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s--resume %s`, - taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, sessionID) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s%s--resume %s`, + taskID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, sessionID) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) if tmuxErr != nil { diff --git a/internal/executor/gemini_executor.go b/internal/executor/gemini_executor.go index f8a64003..5cae5beb 100644 --- a/internal/executor/gemini_executor.go +++ b/internal/executor/gemini_executor.go @@ -121,9 +121,16 @@ func (g *GeminiExecutor) runGemini(ctx context.Context, task *db.Task, workDir, envPrefix := claudeEnvPrefix(paths.configDir) dangerousFlag := buildGeminiDangerousFlag(task.DangerousMode) + + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + // Use -i (--prompt-interactive) to pass initial prompt while keeping interactive mode - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s%s-i "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, resumeFlag, promptFile.Name()) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s%s%s-i "$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, modelFlag, resumeFlag, promptFile.Name()) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script) if tmuxErr != nil { @@ -285,6 +292,12 @@ func (g *GeminiExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s dangerousFlag := buildGeminiDangerousFlag(task.DangerousMode) + // Build model flag + modelFlag := "" + if task.Model != "" { + modelFlag = fmt.Sprintf("--model %s ", task.Model) + } + worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") if worktreeSessionID == "" { worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) @@ -300,18 +313,18 @@ func (g *GeminiExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s promptFile, err := os.CreateTemp("", "task-prompt-*.txt") if err != nil { g.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 gemini %s%s`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q gemini %s%s%s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag) } promptFile.WriteString(prompt) promptFile.Close() // Use -i (--prompt-interactive) to pass initial prompt while keeping interactive mode - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q gemini %s%s-i "$(cat %q)"; rm -f %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag, promptFile.Name(), promptFile.Name()) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q gemini %s%s%s-i "$(cat %q)"; rm -f %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag, promptFile.Name(), promptFile.Name()) } - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q gemini %s%s`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, resumeFlag) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q gemini %s%s%s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, dangerousFlag, modelFlag, resumeFlag) } func buildGeminiDangerousFlag(enabled bool) string { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2f54b4e9..f35a3b90 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -233,6 +233,14 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "type": "string", "description": "Initial status (backlog, queued, defaults to backlog)", }, + "executor": map[string]interface{}{ + "type": "string", + "description": "Task executor (claude, codex, gemini). Defaults to claude.", + }, + "model": map[string]interface{}{ + "type": "string", + "description": "Model override for the executor (e.g., 'opus', 'sonnet', 'haiku' for Claude). Empty means use executor default.", + }, }, "required": []string{"title"}, }, @@ -500,6 +508,8 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { project, _ := params.Arguments["project"].(string) taskType, _ := params.Arguments["type"].(string) status, _ := params.Arguments["status"].(string) + executor, _ := params.Arguments["executor"].(string) + model, _ := params.Arguments["model"].(string) // Default project to current task's project if project == "" { @@ -514,11 +524,13 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { } newTask := &db.Task{ - Title: title, - Body: body, - Project: project, - Type: taskType, - Status: status, + Title: title, + Body: body, + Project: project, + Type: taskType, + Status: status, + Executor: executor, + Model: model, } if err := s.db.CreateTask(newTask); err != nil { diff --git a/internal/ui/debug.go b/internal/ui/debug.go index ae71f827..2868d04b 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -63,6 +63,7 @@ type DebugForm struct { Project string `json:"project"` Type string `json:"type"` Executor string `json:"executor"` + Model string `json:"model"` } type DebugModals struct { @@ -171,6 +172,7 @@ func (m *AppModel) GenerateDebugState() DebugState { Project: formModel.project, Type: formModel.taskType, Executor: formModel.executor, + Model: formModel.model, } if m.currentView == ViewEditTask { s.Form.Title = "Edit Task" diff --git a/internal/ui/form.go b/internal/ui/form.go index a1463a4a..fc75b8c1 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -28,6 +28,7 @@ const ( FieldAttachments // Moved after body for proximity - drag works from any field FieldType FieldExecutor + FieldModel FieldCount ) @@ -60,6 +61,9 @@ type FormModel struct { executorIdx int executors []string availableExecutors []string // Original list of available executors (for rebuilding when project changes) + model string // Model override (e.g., "opus", "sonnet", "claude-opus-4-6") + modelIdx int + models []string // Available model choices (first is "" for default) queue bool attachments []string // Parsed file paths attachmentCursor int // Index of the currently selected attachment chip @@ -103,6 +107,21 @@ type autocompleteSuggestionMsg struct { debounceID int // To verify response is still relevant } +// modelsForExecutor returns the available model choices for a given executor. +// The first entry is always "" (meaning "use executor default"). +func modelsForExecutor(executor string) []string { + switch executor { + case db.ExecutorClaude: + return []string{"", "opus", "sonnet", "haiku"} + case db.ExecutorCodex: + return []string{"", "o4-mini", "o3", "gpt-4.1", "codex-mini"} + case db.ExecutorGemini: + return []string{"", "gemini-2.5-pro", "gemini-2.5-flash"} + default: + return []string{""} + } +} + // buildExecutorList creates the list of executors for the form. // Only available (installed) executors are included in the list. // Executors are sorted by usage count (most used first) for the current project. @@ -180,6 +199,16 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab executorDisplay = executors[0] } + // Build model list based on executor + models := modelsForExecutor(executorDisplay) + modelIdx := 0 + for i, m := range models { + if m == task.Model { + modelIdx = i + break + } + } + m := &FormModel{ db: database, width: width, @@ -192,6 +221,9 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab executorIdx: executorIdx, executors: executors, availableExecutors: availableExecutors, // Store for rebuilding when project changes + model: task.Model, + modelIdx: modelIdx, + models: models, isEdit: true, prURL: task.PRURL, prNumber: task.PRNumber, @@ -370,6 +402,11 @@ func NewFormModel(database *db.DB, width, height int, workingDir string, availab // Load last used executor for the selected project (overrides default if available) m.loadLastExecutorForProject() + // Initialize model list based on selected executor + m.models = modelsForExecutor(m.executor) + m.modelIdx = 0 + m.model = "" + // Title input m.titleInput = textinput.New() m.titleInput.Placeholder = "What needs to be done? (optional if details provided)" @@ -602,7 +639,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } // On last field, submit - if m.focused == FieldExecutor { + if m.focused == FieldModel { m.parseAttachments() m.submitted = true return m, nil @@ -631,6 +668,12 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focused == FieldExecutor && len(m.executors) > 0 { m.executorIdx = (m.executorIdx - 1 + len(m.executors)) % len(m.executors) m.executor = m.executors[m.executorIdx] + m.rebuildModelListForExecutor() + return m, nil + } + if m.focused == FieldModel && len(m.models) > 0 { + m.modelIdx = (m.modelIdx - 1 + len(m.models)) % len(m.models) + m.model = m.models[m.modelIdx] return m, nil } @@ -654,6 +697,12 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focused == FieldExecutor && len(m.executors) > 0 { m.executorIdx = (m.executorIdx + 1) % len(m.executors) m.executor = m.executors[m.executorIdx] + m.rebuildModelListForExecutor() + return m, nil + } + if m.focused == FieldModel && len(m.models) > 0 { + m.modelIdx = (m.modelIdx + 1) % len(m.models) + m.model = m.models[m.modelIdx] return m, nil } @@ -692,7 +741,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } // Type-to-select for selector fields - if m.focused == FieldProject || m.focused == FieldType || m.focused == FieldExecutor { + if m.focused == FieldProject || m.focused == FieldType || m.focused == FieldExecutor || m.focused == FieldModel { key := msg.String() if len(key) == 1 && unicode.IsLetter(rune(key[0])) { m.selectByPrefix(strings.ToLower(key)) @@ -890,6 +939,19 @@ func (m *FormModel) selectByPrefix(prefix string) { if strings.HasPrefix(strings.ToLower(e), prefix) { m.executorIdx = i m.executor = e + m.rebuildModelListForExecutor() + return + } + } + case FieldModel: + for i, mdl := range m.models { + label := mdl + if label == "" { + label = "default" + } + if strings.HasPrefix(strings.ToLower(label), prefix) { + m.modelIdx = i + m.model = mdl return } } @@ -974,10 +1036,21 @@ func (m *FormModel) rebuildExecutorListForProject() { } } +// rebuildModelListForExecutor updates the model list when the executor changes. +func (m *FormModel) rebuildModelListForExecutor() { + m.models = modelsForExecutor(m.executor) + m.modelIdx = 0 + m.model = "" +} + func (m *FormModel) focusNext() { m.blurAll() m.cancelAutocomplete() m.focused = (m.focused + 1) % FieldCount + // Skip model field if there's only one model choice (not selectable) + if m.focused == FieldModel && len(m.models) <= 1 { + m.focused = (m.focused + 1) % FieldCount + } m.focusCurrent() } @@ -985,6 +1058,10 @@ func (m *FormModel) focusPrev() { m.blurAll() m.cancelAutocomplete() m.focused = (m.focused - 1 + FieldCount) % FieldCount + // Skip model field if there's only one model choice (not selectable) + if m.focused == FieldModel && len(m.models) <= 1 { + m.focused = (m.focused - 1 + FieldCount) % FieldCount + } m.focusCurrent() } @@ -1397,6 +1474,25 @@ func (m *FormModel) View() string { b.WriteString(cursor + " " + labelStyle.Render("Executor") + m.renderSelector(m.executors, m.executorIdx, m.focused == FieldExecutor, selectedStyle, optionStyle, dimStyle)) b.WriteString("\n\n") + // Model selector (only show if executor has model choices) + if len(m.models) > 1 { + cursor = " " + if m.focused == FieldModel { + cursor = cursorStyle.Render("▸") + } + // Display labels: "" becomes "default" + modelLabels := make([]string, len(m.models)) + for i, mdl := range m.models { + if mdl == "" { + modelLabels[i] = "default" + } else { + modelLabels[i] = mdl + } + } + b.WriteString(cursor + " " + labelStyle.Render("Model") + m.renderSelector(modelLabels, m.modelIdx, m.focused == FieldModel, selectedStyle, optionStyle, dimStyle)) + b.WriteString("\n\n") + } + // Cancel confirmation message if m.showCancelConfirm { confirmStyle := lipgloss.NewStyle(). @@ -1463,6 +1559,7 @@ func (m *FormModel) GetDBTask() *db.Task { Type: m.taskType, Project: m.project, Executor: m.executor, + Model: m.model, PRURL: m.prURL, PRNumber: m.prNumber, }