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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ CREATE TABLE settings (
);
```

Database location: `~/.local/share/task/tasks.db` (configurable via `TASK_DB_PATH`)
Database location: `~/.local/share/task/tasks.db` (configurable via `WORKTREE_DB_PATH`)

## Task Lifecycle

Expand Down Expand Up @@ -258,4 +258,4 @@ wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {

| Variable | Description | Default |
|----------|-------------|---------|
| `TASK_DB_PATH` | SQLite database path | `~/.local/share/task/tasks.db` |
| `WORKTREE_DB_PATH` | SQLite database path | `~/.local/share/task/tasks.db` |
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ backlog → queued → processing → done

| Variable | Description | Default |
|----------|-------------|---------|
| `TASK_DB_PATH` | SQLite database path | `~/.local/share/task/tasks.db` |
| `WORKTREE_DB_PATH` | SQLite database path | `~/.local/share/task/tasks.db` |

### Projects

Expand All @@ -108,31 +108,31 @@ Each task provides environment variables that applications can use to run in iso

| Variable | Description | Example |
|----------|-------------|---------|
| `TASK_ID` | Unique task identifier | `207` |
| `TASK_PORT` | Unique port (3100-4099) | `3100` |
| `TASK_WORKTREE_PATH` | Path to the worktree | `/path/to/project/.task-worktrees/207-my-task` |
| `WORKTREE_TASK_ID` | Unique task identifier | `207` |
| `WORKTREE_PORT` | Unique port (3100-4099) | `3100` |
| `WORKTREE_PATH` | Path to the worktree | `/path/to/project/.task-worktrees/207-my-task` |

#### Rails Example

Configure your Rails app to use these variables for port and database isolation:

**config/puma.rb:**
```ruby
port ENV.fetch("TASK_PORT", 3000)
port ENV.fetch("WORKTREE_PORT", 3000)
```

**config/database.yml:**
```yaml
development:
database: myapp_dev<%= ENV['TASK_ID'] ? "_task#{ENV['TASK_ID']}" : "" %>
database: myapp_dev<%= ENV['WORKTREE_TASK_ID'] ? "_task#{ENV['WORKTREE_TASK_ID']}" : "" %>
```

**Procfile.dev (with foreman/overmind):**
```
web: bin/rails server -p ${TASK_PORT:-3000}
web: bin/rails server -p ${WORKTREE_PORT:-3000}
```

Now Claude can run your app with `bin/dev` and interact with it via curl or browser at `http://localhost:$TASK_PORT`.
Now Claude can run your app with `bin/dev` and interact with it via curl or browser at `http://localhost:$WORKTREE_PORT`.

### Memories

Expand Down
22 changes: 11 additions & 11 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ var (
// Uses PID to ensure each task instance gets its own tmux sessions.
func getSessionID() string {
// Check if SESSION_ID is already set (for child processes)
if sid := os.Getenv("TASK_SESSION_ID"); sid != "" {
if sid := os.Getenv("WORKTREE_SESSION_ID"); sid != "" {
return sid
}
// Generate new session ID based on PID
Expand Down Expand Up @@ -146,7 +146,7 @@ func main() {
time.Sleep(100 * time.Millisecond)

// Start daemon (inherit dangerous mode from environment if set)
dangerousMode := os.Getenv("TASK_DANGEROUS_MODE") == "1"
dangerousMode := os.Getenv("WORKTREE_DANGEROUS_MODE") == "1"
if err := ensureDaemonRunning(dangerousMode); err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
Expand Down Expand Up @@ -1128,8 +1128,8 @@ func execInTmux() error {
args := append([]string{executable}, os.Args[1:]...)
cmdStr := strings.Join(args, " ")

// Set TASK_SESSION_ID env var so child processes use the same session ID
envCmd := fmt.Sprintf("TASK_SESSION_ID=%s %s", sessionID, cmdStr)
// Set WORKTREE_SESSION_ID env var so child processes use the same session ID
envCmd := fmt.Sprintf("WORKTREE_SESSION_ID=%s %s", sessionID, cmdStr)

// Check if session already exists
if osexec.Command("tmux", "has-session", "-t", sessionName).Run() == nil {
Expand Down Expand Up @@ -1228,7 +1228,7 @@ func runLocal(dangerousMode bool) error {
}

// ensureDaemonRunning starts the daemon if it's not already running.
// If dangerousMode is true, sets TASK_DANGEROUS_MODE=1 for the daemon.
// If dangerousMode is true, sets WORKTREE_DANGEROUS_MODE=1 for the daemon.
// If the daemon is running with a different mode, it will be restarted.
func ensureDaemonRunning(dangerousMode bool) error {
pidFile := getPidFilePath()
Expand Down Expand Up @@ -1268,10 +1268,10 @@ func ensureDaemonRunning(dangerousMode bool) error {
cmd.Stderr = nil
cmd.Stdin = nil
// Pass session ID to daemon so it uses the same tmux sessions
cmd.Env = append(os.Environ(), fmt.Sprintf("TASK_SESSION_ID=%s", getSessionID()))
cmd.Env = append(os.Environ(), fmt.Sprintf("WORKTREE_SESSION_ID=%s", getSessionID()))
// Pass dangerous mode flag if enabled
if dangerousMode {
cmd.Env = append(cmd.Env, "TASK_DANGEROUS_MODE=1")
cmd.Env = append(cmd.Env, "WORKTREE_DANGEROUS_MODE=1")
}
// Detach from parent process
cmd.SysProcAttr = &syscall.SysProcAttr{
Expand Down Expand Up @@ -1489,7 +1489,7 @@ func connectRemote(host, port string) error {

// Send current working directory for project detection
if cwd, err := os.Getwd(); err == nil {
session.Setenv("TASK_CWD", cwd)
session.Setenv("WORKTREE_CWD", cwd)
}

// Start shell (taskd serves TUI directly)
Expand Down Expand Up @@ -1560,13 +1560,13 @@ type ClaudeHookInput struct {
// It reads hook data from stdin and updates task status accordingly.
func handleClaudeHook(hookEvent string) error {
// Get task ID from environment (set by executor when launching Claude)
taskIDStr := os.Getenv("TASK_ID")
taskIDStr := os.Getenv("WORKTREE_TASK_ID")
if taskIDStr == "" {
return fmt.Errorf("TASK_ID not set")
return fmt.Errorf("WORKTREE_TASK_ID not set")
}
var taskID int64
if _, err := fmt.Sscanf(taskIDStr, "%d", &taskID); err != nil {
return fmt.Errorf("invalid TASK_ID: %s", taskIDStr)
return fmt.Errorf("invalid WORKTREE_TASK_ID: %s", taskIDStr)
}

// Read hook input from stdin
Expand Down
2 changes: 1 addition & 1 deletion internal/db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ Think deeply but be actionable. Summarize your conclusions clearly.`,
// DefaultPath returns the default database path.
func DefaultPath() string {
// Check for explicit path
if p := os.Getenv("TASK_DB_PATH"); p != "" {
if p := os.Getenv("WORKTREE_DB_PATH"); p != "" {
return p
}

Expand Down
52 changes: 26 additions & 26 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ const TmuxDaemonSession = "task-daemon"
// getDaemonSessionName returns the task-daemon session name for this instance.
func getDaemonSessionName() string {
// Check if SESSION_ID is set (for child processes)
if sid := os.Getenv("TASK_SESSION_ID"); sid != "" {
if sid := os.Getenv("WORKTREE_SESSION_ID"); sid != "" {
return fmt.Sprintf("task-daemon-%s", sid)
}
// Generate new session ID based on PID
Expand Down Expand Up @@ -945,7 +945,7 @@ func (e *Executor) setupClaudeHooks(workDir string, taskID int64) (cleanup func(
}

// Configure hooks to call our task binary
// The TASK_ID env var is set when launching Claude
// The WORKTREE_TASK_ID env var is set when launching Claude
// We use multiple hook types to ensure accurate task state tracking:
// - PreToolUse: Fires before tool execution - ensures task is "processing"
// - PostToolUse: Fires after tool completes - ensures task stays "processing"
Expand Down Expand Up @@ -1086,31 +1086,31 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt
promptFile.Close()
defer os.Remove(promptFile.Name())

// Script that runs claude interactively with task environment variables
// Script that runs claude interactively with worktree environment variables
// Note: tmux starts in workDir (-c flag), so claude inherits proper permissions and hooks config
// Run interactively (no -p) so user can attach and see/interact in real-time
// Environment variables passed:
// - TASK_ID: Task identifier for hooks
// - TASK_SESSION_ID: Consistent session naming across processes
// - TASK_PORT: Unique port for running the application
// - TASK_WORKTREE_PATH: Path to the task's git worktree
sessionID := os.Getenv("TASK_SESSION_ID")
// - WORKTREE_TASK_ID: Task identifier for hooks
// - WORKTREE_SESSION_ID: Consistent session naming across processes
// - WORKTREE_PORT: Unique port for running the application
// - WORKTREE_PATH: Path to the task's git worktree
sessionID := os.Getenv("WORKTREE_SESSION_ID")
if sessionID == "" {
sessionID = fmt.Sprintf("%d", os.Getpid())
}
// Only use --dangerously-skip-permissions if TASK_DANGEROUS_MODE is set
// Only use --dangerously-skip-permissions if WORKTREE_DANGEROUS_MODE is set
dangerousFlag := ""
if os.Getenv("TASK_DANGEROUS_MODE") == "1" {
if os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" {
dangerousFlag = "--dangerously-skip-permissions "
}
// Check for existing Claude session to resume instead of starting fresh
var script string
if existingSessionID := e.findClaudeSessionID(workDir); existingSessionID != "" {
e.logLine(task.ID, "system", fmt.Sprintf("Resuming existing session %s", existingSessionID))
script = fmt.Sprintf(`TASK_ID=%d TASK_SESSION_ID=%s TASK_PORT=%d TASK_WORKTREE_PATH=%q claude %s--chrome --resume %s "$(cat %q)"`,
script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s--chrome --resume %s "$(cat %q)"`,
task.ID, sessionID, task.Port, task.WorktreePath, dangerousFlag, existingSessionID, promptFile.Name())
} else {
script = fmt.Sprintf(`TASK_ID=%d TASK_SESSION_ID=%s TASK_PORT=%d TASK_WORKTREE_PATH=%q claude %s--chrome "$(cat %q)"`,
script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s--chrome "$(cat %q)"`,
task.ID, sessionID, task.Port, task.WorktreePath, dangerousFlag, promptFile.Name())
}

Expand Down Expand Up @@ -1194,20 +1194,20 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir,

// Script that resumes claude with session ID (interactive mode)
// Environment variables passed:
// - TASK_ID: Task identifier for hooks
// - TASK_SESSION_ID: Consistent session naming across processes
// - TASK_PORT: Unique port for running the application
// - TASK_WORKTREE_PATH: Path to the task's git worktree
taskSessionID := os.Getenv("TASK_SESSION_ID")
// - WORKTREE_TASK_ID: Task identifier for hooks
// - WORKTREE_SESSION_ID: Consistent session naming across processes
// - WORKTREE_PORT: Unique port for running the application
// - WORKTREE_PATH: Path to the task's git worktree
taskSessionID := os.Getenv("WORKTREE_SESSION_ID")
if taskSessionID == "" {
taskSessionID = fmt.Sprintf("%d", os.Getpid())
}
// Only use --dangerously-skip-permissions if TASK_DANGEROUS_MODE is set
// Only use --dangerously-skip-permissions if WORKTREE_DANGEROUS_MODE is set
dangerousFlag := ""
if os.Getenv("TASK_DANGEROUS_MODE") == "1" {
if os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" {
dangerousFlag = "--dangerously-skip-permissions "
}
script := fmt.Sprintf(`TASK_ID=%d TASK_SESSION_ID=%s TASK_PORT=%d TASK_WORKTREE_PATH=%q claude %s--chrome --resume %s "$(cat %q)"`,
script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q claude %s--chrome --resume %s "$(cat %q)"`,
task.ID, taskSessionID, task.Port, task.WorktreePath, dangerousFlag, claudeSessionID, feedbackFile.Name())

// Create new window in task-daemon session (with timeout for tmux overhead)
Expand Down Expand Up @@ -1465,19 +1465,19 @@ func (e *Executor) configureTmuxWindow(windowTarget string) {

// runClaudeDirect runs claude directly without tmux (fallback)
func (e *Executor) runClaudeDirect(ctx context.Context, task *db.Task, workDir, prompt string) execResult {
// Build command args - only include --dangerously-skip-permissions if TASK_DANGEROUS_MODE is set
// Build command args - only include --dangerously-skip-permissions if WORKTREE_DANGEROUS_MODE is set
args := []string{}
if os.Getenv("TASK_DANGEROUS_MODE") == "1" {
if os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" {
args = append(args, "--dangerously-skip-permissions")
}
args = append(args, "--chrome", "-p", prompt)
cmd := exec.CommandContext(ctx, "claude", args...)
cmd.Dir = workDir
// Pass task environment variables so hooks and applications know the task context
// Pass worktree environment variables so hooks and applications know the task context
cmd.Env = append(os.Environ(),
fmt.Sprintf("TASK_ID=%d", task.ID),
fmt.Sprintf("TASK_PORT=%d", task.Port),
fmt.Sprintf("TASK_WORKTREE_PATH=%s", task.WorktreePath),
fmt.Sprintf("WORKTREE_TASK_ID=%d", task.ID),
fmt.Sprintf("WORKTREE_PORT=%d", task.Port),
fmt.Sprintf("WORKTREE_PATH=%s", task.WorktreePath),
)

stdout, err := cmd.StdoutPipe()
Expand Down
2 changes: 1 addition & 1 deletion internal/server/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (s *Server) Shutdown(ctx context.Context) error {

// teaHandler returns the Bubble Tea program for each SSH session.
func (s *Server) teaHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) {
workingDir := GetEnvValue(sess.Environ(), "TASK_CWD")
workingDir := GetEnvValue(sess.Environ(), "WORKTREE_CWD")
model := ui.NewAppModel(s.db, s.executor, workingDir)

return model, []tea.ProgramOption{
Expand Down
22 changes: 11 additions & 11 deletions internal/server/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,44 @@ func TestGetEnvValue(t *testing.T) {
}{
{
name: "found",
environ: []string{"HOME=/home/user", "TASK_CWD=/Users/test/Projects/myproject", "SHELL=/bin/bash"},
key: "TASK_CWD",
environ: []string{"HOME=/home/user", "WORKTREE_CWD=/Users/test/Projects/myproject", "SHELL=/bin/bash"},
key: "WORKTREE_CWD",
want: "/Users/test/Projects/myproject",
},
{
name: "not found",
environ: []string{"HOME=/home/user", "SHELL=/bin/bash"},
key: "TASK_CWD",
key: "WORKTREE_CWD",
want: "",
},
{
name: "empty environ",
environ: []string{},
key: "TASK_CWD",
key: "WORKTREE_CWD",
want: "",
},
{
name: "nil environ",
environ: nil,
key: "TASK_CWD",
key: "WORKTREE_CWD",
want: "",
},
{
name: "empty value",
environ: []string{"TASK_CWD="},
key: "TASK_CWD",
environ: []string{"WORKTREE_CWD="},
key: "WORKTREE_CWD",
want: "",
},
{
name: "value with equals sign",
environ: []string{"TASK_CWD=/path/with=equals"},
key: "TASK_CWD",
environ: []string{"WORKTREE_CWD=/path/with=equals"},
key: "WORKTREE_CWD",
want: "/path/with=equals",
},
{
name: "partial key match should not match",
environ: []string{"TASK_CWD_EXTRA=/wrong"},
key: "TASK_CWD",
environ: []string{"WORKTREE_CWD_EXTRA=/wrong"},
key: "WORKTREE_CWD",
want: "",
},
}
Expand Down
8 changes: 4 additions & 4 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,15 @@ func (m *DetailModel) startResumableSession(sessionID string) {
// Build the claude command with --resume
// Check for dangerous mode
dangerousFlag := ""
if os.Getenv("TASK_DANGEROUS_MODE") == "1" {
if os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" {
dangerousFlag = "--dangerously-skip-permissions "
}

// Get the session ID for environment
taskSessionID := strings.TrimPrefix(daemonSession, "task-daemon-")
worktreeSessionID := strings.TrimPrefix(daemonSession, "task-daemon-")

script := fmt.Sprintf(`TASK_ID=%d TASK_SESSION_ID=%s claude %s--chrome --resume %s`,
m.task.ID, taskSessionID, dangerousFlag, sessionID)
script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s claude %s--chrome --resume %s`,
m.task.ID, worktreeSessionID, dangerousFlag, sessionID)

// Log the reconnection
m.database.AppendTaskLog(m.task.ID, "system", fmt.Sprintf("Reconnecting to session %s", sessionID))
Expand Down