Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ Examples:
permissionModeFlag, _ := cmd.Flags().GetString("permission-mode")
tags, _ := cmd.Flags().GetString("tags")
pinned, _ := cmd.Flags().GetBool("pinned")
remoteControl, _ := cmd.Flags().GetBool("remote-control")
branch, _ := cmd.Flags().GetString("branch")
outputJSON, _ := cmd.Flags().GetBool("json")

Expand Down Expand Up @@ -699,6 +700,7 @@ Examples:
Pinned: pinned,
SourceBranch: branch,
PermissionMode: permMode,
RemoteControl: remoteControl,
}

if err := database.CreateTask(task); err != nil {
Expand Down Expand Up @@ -749,6 +751,7 @@ Examples:
createCmd.Flags().String("permission-mode", "", "Permission mode: default (prompt), auto (auto-accept edits), dangerous (skip all). Defaults to the project's setting")
createCmd.Flags().String("tags", "", "Task tags (comma-separated)")
createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column")
createCmd.Flags().Bool("remote-control", false, "Launch Claude with --remote-control (interactive, remote-drivable session)")
createCmd.Flags().StringP("branch", "b", "", "Existing branch to checkout for worktree (e.g., fix/ui-overflow)")
createCmd.Flags().Bool("json", false, "Output in JSON format")
createCmd.RegisterFlagCompletionFunc("project", completeFlagProjects)
Expand Down
66 changes: 66 additions & 0 deletions internal/db/remote_control_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package db

import (
"os"
"path/filepath"
"testing"
)

func newRemoteControlTestDB(t *testing.T) *DB {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
database, err := Open(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() {
database.Close()
os.Remove(dbPath)
})
return database
}

// TestCreateTaskRemoteControlRoundTrip verifies the RemoteControl flag persists
// through CreateTask and is read back by GetTask.
func TestCreateTaskRemoteControlRoundTrip(t *testing.T) {
database := newRemoteControlTestDB(t)
if err := database.CreateProject(&Project{Name: "p", Path: t.TempDir()}); err != nil {
t.Fatalf("create project: %v", err)
}

task := &Task{Title: "T", Status: StatusQueued, Type: TypeCode, Project: "p", RemoteControl: true}
if err := database.CreateTask(task); err != nil {
t.Fatalf("create task: %v", err)
}

got, err := database.GetTask(task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if !got.RemoteControl {
t.Errorf("RemoteControl should be true after round-trip, got %v", got.RemoteControl)
}
}

// TestCreateTaskRemoteControlDefaultsFalse verifies RemoteControl defaults to
// false when not set on the task.
func TestCreateTaskRemoteControlDefaultsFalse(t *testing.T) {
database := newRemoteControlTestDB(t)
if err := database.CreateProject(&Project{Name: "p", Path: t.TempDir()}); err != nil {
t.Fatalf("create project: %v", err)
}

task := &Task{Title: "T", Status: StatusQueued, Type: TypeCode, Project: "p"}
if err := database.CreateTask(task); err != nil {
t.Fatalf("create task: %v", err)
}

got, err := database.GetTask(task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if got.RemoteControl {
t.Errorf("RemoteControl should default to false, got %v", got.RemoteControl)
}
}
2 changes: 2 additions & 0 deletions internal/db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ func (db *DB) migrate() error {
`ALTER TABLE projects ADD COLUMN default_permission_mode TEXT DEFAULT ''`,
// Per-task Claude effort override ("" = use global/Claude default, otherwise low/medium/high/xhigh/max)
`ALTER TABLE tasks ADD COLUMN effort_level TEXT DEFAULT ''`,

`ALTER TABLE tasks ADD COLUMN remote_control INTEGER DEFAULT 0`, // Whether to launch claude with --remote-control
}

for _, m := range alterMigrations {
Expand Down
39 changes: 20 additions & 19 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Task struct {
PRInfoJSON string // Cached PR state as JSON (state, checks, mergeable, etc.)
DangerousMode bool // Whether task is running in dangerous mode (--dangerously-skip-permissions). Kept for backward compat; PermissionMode is authoritative.
PermissionMode string // Permission mode for execution: "default" (prompt), "auto" (acceptEdits), "dangerous" (skip permissions). Empty falls back to DangerousMode/global default.
RemoteControl bool // Whether to launch claude with --remote-control (interactive, remote-drivable)
Pinned bool // Whether the task is pinned to the top of its column
Tags string // Comma-separated tags for categorization (e.g., "customer-support,email,influence-kit")
SourceBranch string // Existing branch to checkout for worktree (e.g., "fix/ui-overflow") instead of creating new branch
Expand Down Expand Up @@ -235,9 +236,9 @@ func (db *DB) CreateTask(t *Task) error {
t.DangerousMode = t.PermissionMode == PermissionModeDangerous

result, err := db.Exec(`
INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode, permission_mode, effort_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.EffortLevel)
INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode, permission_mode, remote_control, effort_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.RemoteControl, t.EffortLevel)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
Expand Down Expand Up @@ -281,7 +282,7 @@ func (db *DB) GetTask(id int64) (*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand All @@ -293,7 +294,7 @@ func (db *DB) GetTask(id int64) (*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand Down Expand Up @@ -326,7 +327,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand Down Expand Up @@ -386,7 +387,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand All @@ -411,7 +412,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand All @@ -425,7 +426,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand Down Expand Up @@ -454,7 +455,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand Down Expand Up @@ -487,7 +488,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand Down Expand Up @@ -589,13 +590,13 @@ func (db *DB) UpdateTask(t *Task) error {
UPDATE tasks SET
title = ?, body = ?, status = ?, type = ?, project = ?, executor = ?,
worktree_path = ?, branch_name = ?, port = ?, claude_session_id = ?,
daemon_session = ?, pr_url = ?, pr_number = ?, pr_info_json = ?, dangerous_mode = ?, permission_mode = ?,
daemon_session = ?, pr_url = ?, pr_number = ?, pr_info_json = ?, dangerous_mode = ?, permission_mode = ?, remote_control = ?,
pinned = ?, tags = ?, source_branch = ?, effort_level = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor,
t.WorktreePath, t.BranchName, t.Port, t.ClaudeSessionID,
t.DaemonSession, t.PRURL, t.PRNumber, t.PRInfoJSON, t.DangerousMode, t.PermissionMode,
t.DaemonSession, t.PRURL, t.PRNumber, t.PRInfoJSON, t.DangerousMode, t.PermissionMode, t.RemoteControl,
t.Pinned, t.Tags, t.SourceBranch, t.EffortLevel, t.ID)
if err != nil {
return fmt.Errorf("update task: %w", err)
Expand Down Expand Up @@ -921,7 +922,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand All @@ -936,7 +937,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand All @@ -959,7 +960,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand All @@ -982,7 +983,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand Down Expand Up @@ -1901,7 +1902,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) {
COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''),
COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''),
COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''),
COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''),
created_at, updated_at, started_at, completed_at,
last_distilled_at, last_accessed_at,
Expand All @@ -1928,7 +1929,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) {
&t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID,
&t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID,
&t.PRURL, &t.PRNumber, &t.PRInfoJSON,
&t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags,
&t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags,
&t.SourceBranch, &t.Summary, &t.EffortLevel,
&t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt,
&t.LastDistilledAt, &t.LastAccessedAt,
Expand Down
27 changes: 27 additions & 0 deletions internal/executor/claude_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/charmbracelet/log"

"github.com/bborn/workflow/internal/db"
)

// shellSingleQuote wraps s in single quotes for safe interpolation into a shell
// command, escaping any embedded single quotes via the standard '\'' idiom.
// Unlike fmt's %q (which produces Go-string quoting and leaves $, backticks,
// and the like live for the shell), this neutralizes shell metacharacters and
// is safe against command injection.
func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// ClaudeExecutor implements TaskExecutor for Claude Code CLI.
// This wraps the existing Claude execution logic in executor.go.
type ClaudeExecutor struct {
Expand Down Expand Up @@ -46,6 +56,23 @@ func effortFlag(level string) string {
return fmt.Sprintf("--effort %s ", level)
}

// rcFlag returns the `--remote-control <name> ` CLI flag (with a trailing
// space) for a task that has Remote Control enabled, or an empty string
// otherwise. The session name is the task title, falling back to `task-<id>`
// when the title is empty. The name is shell-single-quoted because the returned
// flag is concatenated into a script run via `sh -c`, and the title is
// arbitrary user/MCP/daemon-supplied text.
func rcFlag(task *db.Task) string {
if !task.RemoteControl {
return ""
}
rcName := task.Title
if rcName == "" {
rcName = fmt.Sprintf("task-%d", task.ID)
}
return fmt.Sprintf("--remote-control %s ", shellSingleQuote(rcName))
}

// NewClaudeExecutor creates a new Claude executor.
func NewClaudeExecutor(e *Executor) *ClaudeExecutor {
return &ClaudeExecutor{
Expand Down
Loading