From 7c0b779212a3e2d1b6798685cebd9cc8ff6f3737 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 11:19:30 -0400 Subject: [PATCH 1/4] Add optional Remote Control for task sessions Lets a task opt into launching its Claude session as a remote-drivable (--remote-control) session, so it appears at claude.ai/code and on phone while remaining ty-managed. - RemoteControl flag on tasks (DB column + CreateTask/UpdateTask + scans) - ty create --remote-control; remote_control on taskyou_create_task MCP tool - Executor launches claude with --remote-control and suppresses the task-guidance preamble + initial prompt for RC (interactive) sessions --- cmd/task/main.go | 3 +++ internal/db/sqlite.go | 2 ++ internal/db/tasks.go | 39 ++++++++++++++--------------- internal/executor/executor.go | 46 ++++++++++++++++++++++++++++++----- internal/mcp/server.go | 6 +++++ 5 files changed, 71 insertions(+), 25 deletions(-) diff --git a/cmd/task/main.go b/cmd/task/main.go index d91c303a..096e8ab5 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -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") @@ -699,6 +700,7 @@ Examples: Pinned: pinned, SourceBranch: branch, PermissionMode: permMode, + RemoteControl: remoteControl, } if err := database.CreateTask(task); err != nil { @@ -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) diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 3473bfb2..f0b60aa8 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -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 { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index f21a30e5..5906f0c3 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -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 @@ -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) } @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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) @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/internal/executor/executor.go b/internal/executor/executor.go index b780f736..9b209b50 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2292,10 +2292,27 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt } // Permission flag: dangerous, auto (acceptEdits), or none, honoring the task's mode dangerousFlag := claudePermissionFlag(task) + // Remote Control: launch claude as a remote-drivable session (claude.ai/code + phone) + rcFlag := "" + if task.RemoteControl { + rcName := task.Title + if rcName == "" { + rcName = fmt.Sprintf("task-%d", task.ID) + } + rcFlag = fmt.Sprintf("--remote-control %q ", rcName) + } // Build per-task effort override flag (empty = use Claude's global default) effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) + if task.RemoteControl { + systemPromptFlag = "" + } + // Build trailing prompt arg - suppressed for Remote Control so claude starts with a blank session + promptArg := fmt.Sprintf(`"$(cat %q)"`, promptFile.Name()) + if task.RemoteControl { + promptArg = "" + } // Check for existing Claude session to resume instead of starting fresh // Only use stored session ID - no file-based fallback to avoid cross-task contamination @@ -2305,8 +2322,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%s--resume %s "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, effort, 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%s--resume %s %s`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, rcFlag, effort, systemPromptFlag, existingSessionID, promptArg) } else { if existingSessionID != "" { e.logLine(task.ID, "system", fmt.Sprintf("Session %s no longer exists, starting fresh", existingSessionID)) @@ -2315,8 +2332,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%s"$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, effort, systemPromptFlag, promptFile.Name()) + script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s%s%s%s`, + task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, rcFlag, effort, systemPromptFlag, promptArg) } // Create new window in task-daemon session (with retry logic for race conditions) @@ -2465,14 +2482,31 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir, } // Permission flag: dangerous, auto (acceptEdits), or none, honoring the task's mode dangerousFlag := claudePermissionFlag(task) + // Remote Control: launch claude as a remote-drivable session (claude.ai/code + phone) + rcFlag := "" + if task.RemoteControl { + rcName := task.Title + if rcName == "" { + rcName = fmt.Sprintf("task-%d", task.ID) + } + rcFlag = fmt.Sprintf("--remote-control %q ", rcName) + } // Build per-task effort override flag (empty = use Claude's global default) effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) + if task.RemoteControl { + systemPromptFlag = "" + } + // Build trailing prompt arg - suppressed for Remote Control so claude starts with a blank session + promptArg := fmt.Sprintf(`"$(cat %q)"`, feedbackFile.Name()) + if task.RemoteControl { + promptArg = "" + } envPrefix := claudeEnvPrefix(paths.configDir) - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sclaude %s%s%s--resume %s "$(cat %q)"`, - task.ID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, effort, 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%s--resume %s %s`, + task.ID, taskSessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, rcFlag, effort, systemPromptFlag, claudeSessionID, promptArg) // Create new window in task-daemon session (with retry logic for race conditions) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, e.getProjectDir(task.Project)) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 655785a6..864a9d3c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -245,6 +245,10 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "description": "Permission mode for execution: 'default' (prompt), 'auto' (auto-accept edits), or 'dangerous' (skip all prompts). Defaults to the project's configured default.", "enum": []string{"default", "auto", "dangerous"}, }, + "remote_control": map[string]interface{}{ + "type": "boolean", + "description": "Launch the task's Claude session with --remote-control (interactive, remote-drivable)", + }, }, "required": []string{"title"}, }, @@ -556,6 +560,7 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { status, _ := params.Arguments["status"].(string) dangerousMode, _ := params.Arguments["dangerous_mode"].(bool) permissionMode, _ := params.Arguments["permission_mode"].(string) + remoteControl, _ := params.Arguments["remote_control"].(bool) // Default project to current task's project if project == "" { @@ -583,6 +588,7 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { Type: taskType, Status: status, PermissionMode: permissionMode, + RemoteControl: remoteControl, } if err := s.db.CreateTask(newTask); err != nil { From a05e4835e2615be98114d33dd42eea0d9c79c694 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 11:19:30 -0400 Subject: [PATCH 2/4] Extract rcFlag helper + add RemoteControl tests Extract the triplicated Remote Control flag logic into a single rcFlag(task) helper alongside effortFlag/claudePermissionFlag, and replace the two inlined blocks at the claude launch sites with calls to it. Behavior is byte-identical; the systemPromptFlag/promptArg RC-suppression stays inline. Add tests: - internal/db: RemoteControl CreateTask -> GetTask round-trip (true and default-false). - internal/executor: rcFlag unit test (enabled contains flag + name, disabled empty, empty title falls back to task-). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/remote_control_test.go | 66 ++++++++++++++++++++++++++++ internal/executor/claude_executor.go | 15 +++++++ internal/executor/executor.go | 18 +------- internal/executor/rc_flag_test.go | 35 +++++++++++++++ 4 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 internal/db/remote_control_test.go create mode 100644 internal/executor/rc_flag_test.go diff --git a/internal/db/remote_control_test.go b/internal/db/remote_control_test.go new file mode 100644 index 00000000..bceffd21 --- /dev/null +++ b/internal/db/remote_control_test.go @@ -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) + } +} diff --git a/internal/executor/claude_executor.go b/internal/executor/claude_executor.go index 63daca0c..5d2171f2 100644 --- a/internal/executor/claude_executor.go +++ b/internal/executor/claude_executor.go @@ -46,6 +46,21 @@ func effortFlag(level string) string { return fmt.Sprintf("--effort %s ", level) } +// rcFlag returns the `--remote-control %q ` 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-` when the title is +// empty. +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 %q ", rcName) +} + // NewClaudeExecutor creates a new Claude executor. func NewClaudeExecutor(e *Executor) *ClaudeExecutor { return &ClaudeExecutor{ diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 9b209b50..7025ed6c 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2293,14 +2293,7 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt // Permission flag: dangerous, auto (acceptEdits), or none, honoring the task's mode dangerousFlag := claudePermissionFlag(task) // Remote Control: launch claude as a remote-drivable session (claude.ai/code + phone) - rcFlag := "" - if task.RemoteControl { - rcName := task.Title - if rcName == "" { - rcName = fmt.Sprintf("task-%d", task.ID) - } - rcFlag = fmt.Sprintf("--remote-control %q ", rcName) - } + rcFlag := rcFlag(task) // Build per-task effort override flag (empty = use Claude's global default) effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean @@ -2483,14 +2476,7 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir, // Permission flag: dangerous, auto (acceptEdits), or none, honoring the task's mode dangerousFlag := claudePermissionFlag(task) // Remote Control: launch claude as a remote-drivable session (claude.ai/code + phone) - rcFlag := "" - if task.RemoteControl { - rcName := task.Title - if rcName == "" { - rcName = fmt.Sprintf("task-%d", task.ID) - } - rcFlag = fmt.Sprintf("--remote-control %q ", rcName) - } + rcFlag := rcFlag(task) // Build per-task effort override flag (empty = use Claude's global default) effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean diff --git a/internal/executor/rc_flag_test.go b/internal/executor/rc_flag_test.go new file mode 100644 index 00000000..fe6d78d8 --- /dev/null +++ b/internal/executor/rc_flag_test.go @@ -0,0 +1,35 @@ +package executor + +import ( + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +// TestRCFlag verifies the rcFlag helper produces the expected --remote-control +// flag, including the task-title fallback to task-. +func TestRCFlag(t *testing.T) { + // Remote Control disabled => empty string. + if got := rcFlag(&db.Task{RemoteControl: false}); got != "" { + t.Errorf("rcFlag(disabled) = %q, want %q", got, "") + } + + // Remote Control enabled with a title => flag contains --remote-control and the title. + got := rcFlag(&db.Task{RemoteControl: true, Title: "My Task"}) + if !strings.Contains(got, "--remote-control") { + t.Errorf("rcFlag(enabled) = %q, want it to contain --remote-control", got) + } + if !strings.Contains(got, "My Task") { + t.Errorf("rcFlag(enabled) = %q, want it to contain the task title", got) + } + + // Remote Control enabled with an empty title => falls back to task-. + got = rcFlag(&db.Task{RemoteControl: true, ID: 42, Title: ""}) + if !strings.Contains(got, "--remote-control") { + t.Errorf("rcFlag(empty title) = %q, want it to contain --remote-control", got) + } + if !strings.Contains(got, "task-42") { + t.Errorf("rcFlag(empty title) = %q, want it to contain the task- fallback", got) + } +} From dfe12c94d03b904a0a515330b7b4105ce57a6734 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 11:19:30 -0400 Subject: [PATCH 3/4] Drop RC system-prompt suppression (superseded by #559 which removes the injection) --- internal/executor/executor.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7025ed6c..e554d08d 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2298,9 +2298,6 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) - if task.RemoteControl { - systemPromptFlag = "" - } // Build trailing prompt arg - suppressed for Remote Control so claude starts with a blank session promptArg := fmt.Sprintf(`"$(cat %q)"`, promptFile.Name()) if task.RemoteControl { @@ -2481,9 +2478,6 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir, effort := effortFlag(task.EffortLevel) // Build system prompt flag - passes task guidance via system prompt to keep conversation clean systemPromptFlag := fmt.Sprintf(`--append-system-prompt "$(cat %q)" `, systemFile.Name()) - if task.RemoteControl { - systemPromptFlag = "" - } // Build trailing prompt arg - suppressed for Remote Control so claude starts with a blank session promptArg := fmt.Sprintf(`"$(cat %q)"`, feedbackFile.Name()) if task.RemoteControl { From a99e382d1bb46b83a41751b4544c033b64c2c110 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 7 Jun 2026 15:01:00 -0500 Subject: [PATCH 4/4] fix(executor): shell-quote --remote-control session name to prevent command injection rcFlag built the flag with fmt's %q (Go-string quoting), then the result is concatenated into a script run via `sh -c`. %q does not escape $, backticks, etc., so a task title like $(...) or `...` executed arbitrary commands. Titles are arbitrary user/MCP/daemon-supplied text, so this was a real injection vector. Quote the name with proper shell single-quoting instead. Adds a regression test that runs the flag through `sh` and asserts injected commands never fire. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/executor/claude_executor.go | 22 ++++++++++++++----- internal/executor/rc_flag_test.go | 32 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/internal/executor/claude_executor.go b/internal/executor/claude_executor.go index 5d2171f2..a7269e17 100644 --- a/internal/executor/claude_executor.go +++ b/internal/executor/claude_executor.go @@ -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 { @@ -46,10 +56,12 @@ func effortFlag(level string) string { return fmt.Sprintf("--effort %s ", level) } -// rcFlag returns the `--remote-control %q ` 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-` when the title is -// empty. +// rcFlag returns the `--remote-control ` 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-` +// 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 "" @@ -58,7 +70,7 @@ func rcFlag(task *db.Task) string { if rcName == "" { rcName = fmt.Sprintf("task-%d", task.ID) } - return fmt.Sprintf("--remote-control %q ", rcName) + return fmt.Sprintf("--remote-control %s ", shellSingleQuote(rcName)) } // NewClaudeExecutor creates a new Claude executor. diff --git a/internal/executor/rc_flag_test.go b/internal/executor/rc_flag_test.go index fe6d78d8..f97077d6 100644 --- a/internal/executor/rc_flag_test.go +++ b/internal/executor/rc_flag_test.go @@ -1,6 +1,9 @@ package executor import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -33,3 +36,32 @@ func TestRCFlag(t *testing.T) { t.Errorf("rcFlag(empty title) = %q, want it to contain the task- fallback", got) } } + +// TestRCFlagShellInjection verifies a task title containing shell metacharacters +// cannot inject commands. rcFlag's output is concatenated into a script that the +// executor runs via `sh -c`, and the title is arbitrary user/MCP/daemon-supplied +// text, so it must be shell-quoted (not Go-quoted with %q, which leaves $(...) +// and backticks live). We prove safety by running the flag through `sh` exactly +// the way the executor does and asserting the injected command never executes. +func TestRCFlagShellInjection(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "pwned") + + for _, title := range []string{ + "$(touch " + sentinel + ")", + "`touch " + sentinel + "`", + `"; touch ` + sentinel + `; echo "`, + "normal'title$(touch " + sentinel + ")", + } { + got := rcFlag(&db.Task{RemoteControl: true, Title: title}) + + // `echo ` mirrors how the flag lands inside the executor's + // `sh -c` script: if the title is unsafely quoted, the substitution + // fires and creates the sentinel file. + if out, err := exec.Command("sh", "-c", "echo "+got).CombinedOutput(); err != nil { + t.Fatalf("sh -c failed for title %q: %v (%s)", title, err, out) + } + if _, err := os.Stat(sentinel); !os.IsNotExist(err) { + t.Fatalf("title %q injected a command: sentinel %s exists", title, sentinel) + } + } +}