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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.PHONY: build install clean lint test
.PHONY: build install clean lint test hooks

hooks:
git config core.hooksPath .githooks
@chmod +x .githooks/pre-commit

build:
go build -o bin/td .
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,16 @@ Dispatch profiles:
| MCP (`notion`, `linear`, …) | `generic_workspace` | MCP-focused prompt; deliverable in the external tool |
| `generic` | `generic_workspace` | Files, research, bootstraps — no required MCP |

The confirmation panel shows the target and path. Press `Enter` to launch headless `claude` (stream-json log under `~/.config/td/trackers/`). The task tag updates to `@dispatched`. Press `r` to resume in iTerm2.
The confirmation panel shows the target and path. Press `Enter` to launch headless `claude` (stream-json log on disk; path below). The task tag updates to `@dispatched`.

While a session is running:

- **`w`** — watch the live stream-json log in the TUI (does not interrupt the agent)
- **`r`** — resume in iTerm2 (kills the headless process; requires **two presses** while status is `running`)

Open the sessions sidebar with **`h`** for the same shortcuts plus **`L`** (open raw log in your editor).

Tracker logs live under `$(go env USERCONFIGDIR)/td/trackers/` on macOS that is `~/Library/Application Support/td/trackers/`.

The dispatch flow requires:
- At least one `[[repos]]`, `[[mcp]]`, or use of `generic` routing
Expand Down
2 changes: 1 addition & 1 deletion internal/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func (c *Client) Complete(system, prompt string) (string, error) {
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
Expand Down
6 changes: 1 addition & 5 deletions internal/ai/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,7 @@ func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP)

var route RouteResult
if err := json.Unmarshal([]byte(result), &route); err != nil {
// Fallback: treat raw text as a repo name (backward compat)
if strings.EqualFold(result, "generic") {
return &RouteResult{Generic: true, Actionable: true}, nil
}
return &RouteResult{Repo: result, Actionable: true}, nil
return &RouteResult{Actionable: false, Missing: "routing response was not valid JSON"}, nil
}

// Normalise: a literal "generic" repo name is the generic workspace.
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,10 @@ func AppendRepos(repos []Repo) error {
if err != nil {
return err
}
defer f.Close()
defer func() { _ = f.Close() }()

for _, r := range repos {
fmt.Fprintf(f, "\n[[repos]]\nname = %q\npath = %q\ndescription = %q\n", r.Name, r.Path, r.Description)
_, _ = fmt.Fprintf(f, "\n[[repos]]\nname = %q\npath = %q\ndescription = %q\n", r.Name, r.Path, r.Description)
}
return nil
}
Expand Down
16 changes: 9 additions & 7 deletions internal/scan/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func FindRepos(dir string) ([]string, error) {
}

var repos []string
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
Expand All @@ -42,7 +42,9 @@ func FindRepos(dir string) ([]string, error) {
repos = append(repos, path)
}
return nil
})
}); err != nil {
return nil, err
}

return repos, nil
}
Expand All @@ -59,7 +61,7 @@ Output format:

func DescribeRepos(paths []string, apiKey, model string) ([]Suggestion, error) {
var prompt strings.Builder
prompt.WriteString(fmt.Sprintf("Analyze these %d git repositories:\n\n", len(paths)))
fmt.Fprintf(&prompt, "Analyze these %d git repositories:\n\n", len(paths))

for i, p := range paths {
ctx := collectContext(p)
Expand Down Expand Up @@ -125,7 +127,7 @@ func SaveSelected(suggestions []Suggestion) error {

func collectContext(repoPath string) string {
var ctx strings.Builder
ctx.WriteString(fmt.Sprintf("Path: %s\n", repoPath))
fmt.Fprintf(&ctx, "Path: %s\n", repoPath)

entries, err := os.ReadDir(repoPath)
if err == nil {
Expand All @@ -135,7 +137,7 @@ func collectContext(repoPath string) string {
names = append(names, e.Name())
}
}
ctx.WriteString(fmt.Sprintf("Files: %s\n", strings.Join(names, ", ")))
fmt.Fprintf(&ctx, "Files: %s\n", strings.Join(names, ", "))
}

for _, readme := range []string{"README.md", "readme.md", "README"} {
Expand All @@ -145,7 +147,7 @@ func collectContext(repoPath string) string {
if len(content) > 500 {
content = content[:500] + "..."
}
ctx.WriteString(fmt.Sprintf("README:\n%s\n", content))
fmt.Fprintf(&ctx, "README:\n%s\n", content)
break
}
}
Expand All @@ -157,7 +159,7 @@ func collectContext(repoPath string) string {
if len(content) > 300 {
content = content[:300] + "..."
}
ctx.WriteString(fmt.Sprintf("%s:\n%s\n", cfgFile, content))
fmt.Fprintf(&ctx, "%s:\n%s\n", cfgFile, content)
break
}
}
Expand Down
8 changes: 5 additions & 3 deletions internal/taskfile/carry.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,17 @@ func findCarriedTasks(tasksDir, todayPath string) []Task {
func findPreviousFile(tasksDir, todayPath string) string {
var files []string

filepath.WalkDir(tasksDir, func(path string, d fs.DirEntry, err error) error {
if err := filepath.WalkDir(tasksDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".md") && path != todayPath {
files = append(files, path)
}
return nil
})
}); err != nil {
return ""
}

if len(files) == 0 {
return ""
Expand All @@ -87,7 +89,7 @@ func findPreviousFile(tasksDir, todayPath string) string {
func generateTemplate(date time.Time, carried []Task) string {
var b strings.Builder

b.WriteString(fmt.Sprintf("# %s\n", date.Format("Monday, January 2 2006")))
fmt.Fprintf(&b, "# %s\n", date.Format("Monday, January 2 2006"))

if len(carried) > 0 {
b.WriteString("\n## Carried over\n")
Expand Down
10 changes: 4 additions & 6 deletions internal/tracker/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (s *Store) Poll(t *Task) {
}
return
}
defer f.Close()
defer func() { _ = f.Close() }()

scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 512*1024), 512*1024)
Expand Down Expand Up @@ -53,12 +53,10 @@ func (s *Store) Poll(t *Task) {
t.DurationMs = ev.DurationMs
t.Turns = ev.NumTurns

if ev.Subtype == "success" && ev.StopReason == "end_turn" && len(ev.PermissionDenials) == 0 {
t.Status = StatusDone
} else if len(ev.PermissionDenials) > 0 {
if len(ev.PermissionDenials) > 0 {
t.Status = StatusNeedsInput
t.Error = "permission denied: " + ev.PermissionDenials[0]
} else if ev.Subtype != "success" {
} else if ev.Subtype != "success" && ev.Subtype != "" {
t.Status = StatusFailed
t.Error = ev.Subtype
} else {
Expand Down Expand Up @@ -86,7 +84,7 @@ func parseToolUse(ev *event, t *Task) {
}

func extractDetail(tool string, input json.RawMessage) string {
var m map[string]interface{}
var m map[string]any
if json.Unmarshal(input, &m) != nil {
return ""
}
Expand Down
16 changes: 15 additions & 1 deletion internal/tracker/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"os"
"path/filepath"
"sort"
"sync"
"syscall"
)

type Store struct {
mu sync.Mutex
dir string
}

Expand Down Expand Up @@ -65,6 +67,8 @@ func (s *Store) save(st *state) error {
}

func (s *Store) Register(t *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.load()
st.Tasks[t.ID] = t
return s.save(st)
Expand All @@ -75,11 +79,15 @@ func (s *Store) Update(t *Task) error {
}

func (s *Store) Get(id string) *Task {
s.mu.Lock()
defer s.mu.Unlock()
st := s.load()
return st.Tasks[id]
}

func (s *Store) ActiveTasks() []*Task {
s.mu.Lock()
defer s.mu.Unlock()
st := s.load()
var active []*Task
for _, t := range st.Tasks {
Expand All @@ -91,12 +99,16 @@ func (s *Store) ActiveTasks() []*Task {
}

func (s *Store) AllTasks() map[string]*Task {
s.mu.Lock()
defer s.mu.Unlock()
return s.load().Tasks
}

// ListRecent returns tasks sorted by StartedAt (newest first), capped at limit.
// limit <= 0 means no cap.
func (s *Store) ListRecent(limit int) []*Task {
s.mu.Lock()
defer s.mu.Unlock()
st := s.load()
all := make([]*Task, 0, len(st.Tasks))
for _, t := range st.Tasks {
Expand All @@ -113,9 +125,11 @@ func (s *Store) ListRecent(limit int) []*Task {

// Remove deletes a tracker and its log file.
func (s *Store) Remove(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.load()
delete(st.Tasks, id)
os.Remove(s.LogPath(id))
_ = os.Remove(s.LogPath(id))
return s.save(st)
}

Expand Down
Loading
Loading