diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6bdd3d93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run tests + run: go test -v -race ./... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build binaries + run: | + go build -o bin/task ./cmd/task + go build -o bin/taskd ./cmd/taskd diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..b9992269 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,21 @@ +linters: + disable: + # Disable errcheck - this codebase intentionally ignores many error returns + # for logging, cleanup operations, and fire-and-forget calls + - errcheck + +linters-settings: + staticcheck: + checks: + - all + - -SA9003 # Empty branch - sometimes used for clarity + + gosimple: + checks: + - all + - -S1017 # TrimPrefix suggestion - keep explicit for readability + +issues: + # Don't limit the number of issues per linter + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/cmd/task/cloud.go b/cmd/task/cloud.go index 37dbedb9..1e4c3155 100644 --- a/cmd/task/cloud.go +++ b/cmd/task/cloud.go @@ -58,14 +58,6 @@ func sshRunInteractive(server, command string) error { return cmd.Run() } -// scpFile copies a local file to the remote server. -func scpFile(local, remote string) error { - cmd := osexec.Command("scp", "-C", local, remote) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - // getCloudSettings retrieves all cloud settings from the database. func getCloudSettings(database *db.DB) (map[string]string, error) { settings := make(map[string]string) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 24948a17..e661664c 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1435,14 +1435,6 @@ func (e *Executor) pollTmuxSession(ctx context.Context, taskID int64, sessionNam } } -// killTmuxSession kills a tmux session if it exists. -func (e *Executor) killTmuxSession(sessionName string) { - // Kill the window (we use windows in task-daemon, not separate sessions) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - exec.CommandContext(ctx, "tmux", "kill-window", "-t", sessionName).Run() -} - // configureTmuxWindow sets up helpful UI elements for a task window. func (e *Executor) configureTmuxWindow(windowTarget string) { // Window-specific options are limited; most styling is session-wide @@ -1458,127 +1450,6 @@ func (e *Executor) configureTmuxWindow(windowTarget string) { exec.CommandContext(ctx, "tmux", "set-option", "-t", daemonSession, "status-right-length", "30").Run() } -// isClaudeIdle checks if claude appears to be idle (showing prompt with no activity). -// This detects when claude has finished but didn't output TASK_COMPLETE. -func (e *Executor) isClaudeIdle(output string) bool { - // Get the last few lines of output - lines := strings.Split(output, "\n") - var recentLines []string - start := len(lines) - 10 - if start < 0 { - start = 0 - } - for i := start; i < len(lines); i++ { - line := strings.TrimSpace(lines[i]) - if line != "" { - recentLines = append(recentLines, line) - } - } - - if len(recentLines) == 0 { - return false - } - - // Check for Claude Code prompt indicator (❯) in recent lines - // The prompt typically looks like: "❯" or contains the chevron - lastLine := recentLines[len(recentLines)-1] - - // Claude Code shows ❯ when waiting for input at its prompt - if strings.Contains(lastLine, "❯") { - return true - } - - // Also check for common shell prompts that might indicate claude exited - // and we're back at a shell prompt - if strings.HasSuffix(lastLine, "$") || strings.HasSuffix(lastLine, "%") { - return true - } - - return false -} - -// isWaitingForInput checks if claude appears to be waiting for user input. -func (e *Executor) isWaitingForInput(output string, lastOutputTime time.Time) bool { - // Only consider waiting if no new output for a while - if time.Since(lastOutputTime) < 3*time.Second { - return false - } - - // Get the last few lines of output (the prompt area) - lines := strings.Split(output, "\n") - var recentLines string - start := len(lines) - 15 - if start < 0 { - start = 0 - } - recentLines = strings.Join(lines[start:], "\n") - recentLower := strings.ToLower(recentLines) - - // Permission prompts - if strings.Contains(recentLower, "allow") && strings.Contains(recentLower, "?") { - if strings.Contains(recentLower, "[y/n") || strings.Contains(recentLower, "(y/n") || - strings.Contains(recentLower, "yes/no") { - return true - } - } - - // Claude Code permission prompts (numbered options with "do you want to proceed") - if strings.Contains(recentLower, "do you want to proceed") || - (strings.Contains(recentLower, "esc to cancel") && strings.Contains(recentLower, "1.")) { - return true - } - - // Yes/no prompts - if strings.Contains(recentLower, "[y/n]") || strings.Contains(recentLower, "(yes/no)") { - return true - } - - // Press enter prompts - if strings.Contains(recentLower, "press enter") || strings.Contains(recentLower, "press any key") { - return true - } - - // Common input prompts - if strings.Contains(recentLower, "enter your") || strings.Contains(recentLower, "type your") || - strings.Contains(recentLower, "please provide") || strings.Contains(recentLower, "please enter") { - return true - } - - return false -} - -// hasCompletionMarker checks if any line in the output is the completion marker. -// This avoids false positives from the prompt text which contains "TASK_COMPLETE" in instructions. -func hasCompletionMarker(lines []string) bool { - for _, line := range lines { - trimmed := strings.TrimSpace(line) - // Check for exact match or line starting with TASK_COMPLETE - // (Claude might add punctuation or emoji after it) - if trimmed == "TASK_COMPLETE" || strings.HasPrefix(trimmed, "TASK_COMPLETE") { - // Make sure it's not part of the instruction text (which has quotes around it) - if !strings.Contains(line, `"TASK_COMPLETE"`) && !strings.Contains(line, "output") { - return true - } - } - } - return false -} - -// parseOutputMarkers checks output for completion markers -func (e *Executor) parseOutputMarkers(output string) execResult { - if strings.Contains(output, "TASK_COMPLETE") { - return execResult{Success: true} - } - if idx := strings.Index(output, "NEEDS_INPUT:"); idx >= 0 { - rest := output[idx+len("NEEDS_INPUT:"):] - if newline := strings.Index(rest, "\n"); newline >= 0 { - rest = rest[:newline] - } - return execResult{NeedsInput: true, Message: strings.TrimSpace(rest)} - } - return execResult{Success: true} -} - // runClaudeDirect runs claude directly without tmux (fallback) func (e *Executor) runClaudeDirect(ctx context.Context, taskID int64, workDir, prompt string) execResult { // Build command args - only include --dangerously-skip-permissions if TASK_DANGEROUS_MODE is set diff --git a/internal/ui/app.go b/internal/ui/app.go index 13fd24a5..d92de1e0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1676,9 +1676,7 @@ type taskRetriedMsg struct { err error } -type taskKilledMsg struct { - err error -} +type taskKilledMsg struct{} type taskEventMsg struct { event executor.TaskEvent @@ -1718,17 +1716,6 @@ func (m *AppModel) loadTask(id int64) tea.Cmd { } } -func (m *AppModel) createTask(t *db.Task) tea.Cmd { - exec := m.executor - return func() tea.Msg { - err := m.db.CreateTask(t) - if err == nil { - exec.NotifyTaskChange("created", t) - } - return taskCreatedMsg{task: t, err: err} - } -} - func (m *AppModel) updateTask(t *db.Task) tea.Cmd { database := m.db exec := m.executor @@ -1817,13 +1804,6 @@ func (m *AppModel) deleteTask(id int64) tea.Cmd { } } -func (m *AppModel) retryTask(id int64, feedback string) tea.Cmd { - return func() tea.Msg { - err := m.db.RetryTask(id, feedback) - return taskRetriedMsg{err: err} - } -} - func (m *AppModel) retryTaskWithAttachments(id int64, feedback string, attachmentPaths []string) tea.Cmd { database := m.db exec := m.executor diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 1248bb1b..1e9fb5c1 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -671,59 +671,6 @@ func (m *DetailModel) breakTmuxPanes() { m.daemonSessionID = "" } -// breakTmuxPane is a compatibility wrapper for breakTmuxPanes. -func (m *DetailModel) breakTmuxPane() { - m.breakTmuxPanes() -} - -// killTmuxSession kills the Claude tmux window and workdir pane. -func (m *DetailModel) killTmuxSession() { - windowTarget := m.cachedWindowTarget - if windowTarget == "" { - return - } - - // Use timeout for all tmux operations to prevent blocking UI - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Save pane positions before killing (must save before panes are destroyed) - m.saveShellPaneWidth() - currentPaneCmd := exec.CommandContext(ctx, "tmux", "display-message", "-p", "#{pane_id}") - if currentPaneOut, err := currentPaneCmd.Output(); err == nil { - tuiPaneID := strings.TrimSpace(string(currentPaneOut)) - m.saveDetailPaneHeight(tuiPaneID) - } - - m.database.AppendTaskLog(m.task.ID, "user", "→ [Kill] Session terminated") - - // Reset pane styling first - exec.CommandContext(ctx, "tmux", "set-option", "-t", "task-ui", "status-right", " ").Run() - exec.CommandContext(ctx, "tmux", "set-option", "-t", "task-ui", "pane-border-lines", "single").Run() - exec.CommandContext(ctx, "tmux", "set-option", "-t", "task-ui", "pane-border-indicators", "off").Run() - exec.CommandContext(ctx, "tmux", "set-option", "-t", "task-ui", "pane-border-style", "fg=#374151").Run() - exec.CommandContext(ctx, "tmux", "set-option", "-t", "task-ui", "pane-active-border-style", "fg=#61AFEF").Run() - - // Reset pane title back to main view label - exec.CommandContext(ctx, "tmux", "select-pane", "-t", "task-ui:.0", "-T", "Tasks").Run() - - // Kill the workdir pane first (it's a separate pane we created) - if m.workdirPaneID != "" { - exec.CommandContext(ctx, "tmux", "kill-pane", "-t", m.workdirPaneID).Run() - m.workdirPaneID = "" - } - - // If we have a joined Claude pane, it will be killed with the window - m.claudePaneID = "" - - exec.CommandContext(ctx, "tmux", "kill-window", "-t", windowTarget).Run() - - // Clear cached window target since session is now killed - m.cachedWindowTarget = "" - - m.Refresh() -} - // focusNextPane cycles focus to the next pane: Details -> Claude -> Shell -> Details. func (m *DetailModel) focusNextPane() { if m.claudePaneID == "" && m.workdirPaneID == "" { @@ -1091,36 +1038,3 @@ func (m *DetailModel) getClaudeMemoryMB() int { return rssKB / 1024 // Convert to MB } -func humanizeTime(t time.Time) string { - if t.IsZero() { - return "" - } - - now := time.Now() - diff := now.Sub(t) - - switch { - case diff < time.Minute: - return "just now" - case diff < time.Hour: - mins := int(diff.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case diff < 24*time.Hour: - hours := int(diff.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case diff < 7*24*time.Hour: - days := int(diff.Hours() / 24) - if days == 1 { - return "yesterday" - } - return fmt.Sprintf("%d days ago", days) - default: - return t.Format("Jan 2, 2006") - } -} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 7ba0d226..2dd9ca75 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -604,7 +604,7 @@ func (m *SettingsModel) View() string { b.WriteString("\n") // Theme section - themeHeader := "Theme" + var themeHeader string if m.section == 0 { themeHeader = Bold.Foreground(ColorPrimary).Render("Theme") } else { @@ -622,7 +622,7 @@ func (m *SettingsModel) View() string { b.WriteString("\n\n") // Projects section - projectsHeader := "Projects" + var projectsHeader string if m.section == 1 { projectsHeader = Bold.Foreground(ColorPrimary).Render("Projects") } else { @@ -660,7 +660,7 @@ func (m *SettingsModel) View() string { b.WriteString("\n") // Task Types section - taskTypesHeader := "Task Types" + var taskTypesHeader string if m.section == 2 { taskTypesHeader = Bold.Foreground(ColorPrimary).Render("Task Types") } else {