Skip to content
Merged
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
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 0 additions & 8 deletions cmd/task/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
129 changes: 0 additions & 129 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 1 addition & 21 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1676,9 +1676,7 @@ type taskRetriedMsg struct {
err error
}

type taskKilledMsg struct {
err error
}
type taskKilledMsg struct{}

type taskEventMsg struct {
event executor.TaskEvent
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
86 changes: 0 additions & 86 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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")
}
}
Loading