codex agent tracker

This commit is contained in:
David Chen 2025-09-15 19:35:50 -07:00
parent 20eddd1600
commit b08d63e4a5
96 changed files with 3057 additions and 3055 deletions

View file

@ -0,0 +1,19 @@
class AgentTrackerServer < Formula
desc "Tmux-aware agent tracker server"
homepage "https://github.com/david/agent-tracker"
url "file:///var/folders/11/dhzcjp416tl1dkf16kxns3z00000gn/T/tmp.mJenIMNxo7/tracker-server.tar.gz"
sha256 "cb24ca397f60e5209b79667e32dc0f98fd22a9ac85627d5eb79d0e9e8e75be55"
version "local-20250917103405"
def install
bin.install "tracker-server"
end
service do
run [opt_bin/"tracker-server"]
keep_alive true
working_dir var/"agent-tracker"
log_path var/"log/agent-tracker-server.log"
error_log_path var/"log/agent-tracker-server.log"
end
end

1
agent-tracker/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.build/

View file

@ -0,0 +1,762 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
"github.com/david/agent-tracker/internal/ipc"
"github.com/gdamore/tcell/v2"
)
const (
statusInProgress = "in_progress"
statusCompleted = "completed"
)
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
const spinnerInterval = 120 * time.Millisecond
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
if err := runUI(os.Args[1:]); err != nil {
log.Fatal(err)
}
return
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "ui":
if err := runUI(args); err != nil {
log.Fatal(err)
}
case "command":
if err := runCommand(args); err != nil {
log.Fatal(err)
}
case "state":
if err := runState(args); err != nil {
log.Fatal(err)
}
default:
if err := runUI(os.Args[1:]); err != nil {
log.Fatal(err)
}
}
}
func runCommand(args []string) error {
fs := flag.NewFlagSet("tracker-client command", flag.ExitOnError)
var client, session, sessionID, window, windowID, pane, summary string
fs.StringVar(&client, "client", "", "tmux client tty")
fs.StringVar(&session, "session", "", "tmux session name")
fs.StringVar(&sessionID, "session-id", "", "tmux session id")
fs.StringVar(&window, "window", "", "tmux window name")
fs.StringVar(&windowID, "window-id", "", "tmux window id")
fs.StringVar(&pane, "pane", "", "tmux pane id")
fs.StringVar(&summary, "summary", "", "summary or note payload")
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) == 0 {
return fmt.Errorf("command name required")
}
if len(rest) > 1 {
summary = strings.Join(rest[1:], " ")
}
env := ipc.Envelope{
Kind: "command",
Command: rest[0],
Client: client,
Session: strings.TrimSpace(session),
SessionID: strings.TrimSpace(sessionID),
Window: strings.TrimSpace(window),
WindowID: strings.TrimSpace(windowID),
Pane: strings.TrimSpace(pane),
Summary: strings.TrimSpace(summary),
}
if env.Summary != "" {
env.Message = env.Summary
}
switch env.Command {
case "start_task", "finish_task", "acknowledge":
ctx, err := resolveContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
if err != nil {
return err
}
env.Session = ctx.SessionName
env.SessionID = ctx.SessionID
env.Window = ctx.WindowName
env.WindowID = ctx.WindowID
env.Pane = ctx.PaneID
}
conn, err := net.Dial("unix", socketPath())
if err != nil {
return err
}
defer conn.Close()
enc := json.NewEncoder(conn)
if err := enc.Encode(&env); err != nil {
return err
}
dec := json.NewDecoder(conn)
for {
var reply ipc.Envelope
if err := dec.Decode(&reply); err != nil {
return err
}
if reply.Kind == "ack" {
return nil
}
}
}
func runState(args []string) error {
fs := flag.NewFlagSet("tracker-client state", flag.ExitOnError)
var client string
fs.StringVar(&client, "client", "", "tmux client tty")
if err := fs.Parse(args); err != nil {
return err
}
conn, err := net.Dial("unix", socketPath())
if err != nil {
return err
}
defer conn.Close()
enc := json.NewEncoder(conn)
dec := json.NewDecoder(bufio.NewReader(conn))
if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil {
return err
}
for {
var env ipc.Envelope
if err := dec.Decode(&env); err != nil {
return err
}
if env.Kind == "state" {
out := json.NewEncoder(os.Stdout)
out.SetEscapeHTML(false)
if err := out.Encode(&env); err != nil {
return err
}
return nil
}
}
}
type tmuxContext struct {
SessionName string
SessionID string
WindowName string
WindowID string
PaneID string
}
func resolveContext(sessionName, sessionID, windowName, windowID, paneID string) (tmuxContext, error) {
ctx := tmuxContext{
SessionName: strings.TrimSpace(sessionName),
SessionID: strings.TrimSpace(sessionID),
WindowName: strings.TrimSpace(windowName),
WindowID: strings.TrimSpace(windowID),
PaneID: strings.TrimSpace(paneID),
}
fetchOrder := []string{}
if ctx.PaneID != "" {
fetchOrder = append(fetchOrder, ctx.PaneID)
}
fetchOrder = append(fetchOrder, "")
for _, target := range fetchOrder {
if ctx.complete() {
break
}
info, err := detectTmuxContext(target)
if err != nil {
if target == "" {
return tmuxContext{}, err
}
continue
}
ctx = ctx.merge(info)
}
if ctx.SessionID == "" || ctx.WindowID == "" {
return tmuxContext{}, fmt.Errorf("session and window identifiers required")
}
if ctx.SessionName == "" || ctx.WindowName == "" {
if info, err := detectTmuxContext(ctx.WindowID); err == nil {
ctx = ctx.merge(info)
}
}
if ctx.SessionName == "" {
ctx.SessionName = ctx.SessionID
}
if ctx.WindowName == "" {
ctx.WindowName = ctx.WindowID
}
return ctx, nil
}
func (c tmuxContext) complete() bool {
return c.SessionName != "" && c.SessionID != "" && c.WindowName != "" && c.WindowID != "" && c.PaneID != ""
}
func (c tmuxContext) merge(other tmuxContext) tmuxContext {
if c.SessionName == "" {
c.SessionName = other.SessionName
}
if c.SessionID == "" {
c.SessionID = other.SessionID
}
if c.WindowName == "" {
c.WindowName = other.WindowName
}
if c.WindowID == "" {
c.WindowID = other.WindowID
}
if c.PaneID == "" {
c.PaneID = other.PaneID
}
return c
}
func detectTmuxContext(target string) (tmuxContext, error) {
format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}"
args := []string{"display-message", "-p"}
if strings.TrimSpace(target) != "" {
args = append(args, "-t", strings.TrimSpace(target))
}
args = append(args, format)
cmd := exec.Command("tmux", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return tmuxContext{}, fmt.Errorf("tmux display-message: %w (%s)", err, strings.TrimSpace(string(output)))
}
parts := strings.Split(strings.TrimSpace(string(output)), ":::")
if len(parts) != 5 {
return tmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(string(output)))
}
return tmuxContext{
SessionName: strings.TrimSpace(parts[0]),
SessionID: strings.TrimSpace(parts[1]),
WindowName: strings.TrimSpace(parts[2]),
WindowID: strings.TrimSpace(parts[3]),
PaneID: strings.TrimSpace(parts[4]),
}, nil
}
func runUI(args []string) error {
fs := flag.NewFlagSet("tracker-client ui", flag.ExitOnError)
var client string
fs.StringVar(&client, "client", "", "tmux client tty")
if err := fs.Parse(args); err != nil {
return err
}
conn, err := net.Dial("unix", socketPath())
if err != nil {
return err
}
defer conn.Close()
screen, err := tcell.NewScreen()
if err != nil {
return err
}
if err := screen.Init(); err != nil {
return err
}
defer screen.Fini()
screen.Clear()
enc := json.NewEncoder(conn)
dec := json.NewDecoder(bufio.NewReader(conn))
if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil {
return err
}
type state struct {
message string
tasks []ipc.Task
}
st := state{message: "Connecting to tracker…"}
incoming := make(chan ipc.Envelope)
errCh := make(chan error, 1)
go func() {
for {
var env ipc.Envelope
if err := dec.Decode(&env); err != nil {
errCh <- err
close(incoming)
return
}
incoming <- env
}
}()
events := make(chan tcell.Event)
go func() {
for {
ev := screen.PollEvent()
if ev == nil {
close(events)
return
}
events <- ev
}
}()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
var encMu sync.Mutex
sendCommand := func(name string, opts ...func(*ipc.Envelope)) error {
encMu.Lock()
defer encMu.Unlock()
env := ipc.Envelope{Kind: "command", Command: name, Client: client}
for _, opt := range opts {
opt(&env)
}
return enc.Encode(&env)
}
selected := 0
offset := 0
var tasks []ipc.Task
draw := func(now time.Time) {
screen.Clear()
width, height := screen.Size()
headerStyle := tcell.StyleDefault.Foreground(tcell.ColorLightCyan).Bold(true)
subtleStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray)
infoStyle := tcell.StyleDefault.Foreground(tcell.ColorSilver)
writeStyledLine(screen, 0, 0, truncate("▌ Tracker", width), headerStyle)
writeStyledLine(screen, 0, 1, truncate(st.message, width), subtleStyle)
if width > 0 {
writeStyledLine(screen, 0, 2, strings.Repeat("─", width), infoStyle)
}
if len(tasks) == 0 {
offset = 0
if height > 3 {
writeStyledLine(screen, 0, 3, truncate("No active work. Enjoy the calm.", width), infoStyle)
}
screen.Show()
return
}
if selected >= len(tasks) {
selected = len(tasks) - 1
}
if selected < 0 {
selected = 0
}
visibleRows := height - 3
if visibleRows < 0 {
visibleRows = 0
}
capacity := visibleRows / 4
if capacity < 1 {
capacity = 1
}
maxOffset := len(tasks) - capacity
if maxOffset < 0 {
maxOffset = 0
}
if offset > maxOffset {
offset = maxOffset
}
if selected < offset {
offset = selected
}
if selected >= offset+capacity {
offset = selected - capacity + 1
}
if offset < 0 {
offset = 0
}
row := 3
for idx := offset; idx < len(tasks); idx++ {
t := tasks[idx]
if row >= height {
break
}
indicator := taskIndicator(t, now)
summary := t.Summary
if summary == "" {
summary = "(no summary)"
}
line := fmt.Sprintf("%s %s", indicator, summary)
mainStyle := tcell.StyleDefault
switch t.Status {
case statusInProgress:
mainStyle = mainStyle.Foreground(tcell.ColorLightGoldenrodYellow).Bold(true)
case statusCompleted:
if t.Acknowledged {
mainStyle = mainStyle.Foreground(tcell.ColorLightGreen).Bold(true)
} else {
mainStyle = mainStyle.Foreground(tcell.ColorFuchsia).Bold(true)
}
}
if idx == selected {
mainStyle = mainStyle.Background(tcell.ColorDarkSlateGray)
}
writeStyledLine(screen, 0, row, truncate(line, width), mainStyle)
row++
if row >= height {
break
}
metaStyle := subtleStyle
if idx == selected {
metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray)
}
meta := fmt.Sprintf(" %s · %s · %s", t.Session, t.Window, liveDuration(t, now))
if t.Status == statusCompleted {
if !t.Acknowledged {
meta += " • awaiting review"
} else if t.CompletedAt != "" {
if completed, err := time.Parse(time.RFC3339, t.CompletedAt); err == nil {
meta += fmt.Sprintf(" • finished %s", completed.Format("15:04"))
}
}
}
writeStyledLine(screen, 0, row, truncate(meta, width), metaStyle)
row++
if t.CompletionNote != "" && row < height {
noteStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSteelBlue)
if idx == selected {
noteStyle = noteStyle.Background(tcell.ColorDarkSlateGray)
}
note := fmt.Sprintf(" ↳ %s", t.CompletionNote)
writeStyledLine(screen, 0, row, truncate(note, width), noteStyle)
row++
}
if row < height {
row++
}
}
screen.Show()
}
draw(time.Now())
for {
select {
case env, ok := <-incoming:
if !ok {
return <-errCh
}
switch env.Kind {
case "state":
st.message = env.Message
tasks = make([]ipc.Task, len(env.Tasks))
copy(tasks, env.Tasks)
sortTasks(tasks)
if len(tasks) == 0 {
selected = 0
} else if selected >= len(tasks) {
selected = len(tasks) - 1
}
draw(time.Now())
case "ack":
default:
}
case ev, ok := <-events:
if !ok {
return nil
}
switch tev := ev.(type) {
case *tcell.EventKey:
if tev.Key() == tcell.KeyEnter {
if len(tasks) > 0 && selected < len(tasks) {
task := tasks[selected]
if err := sendCommand("focus_task", func(env *ipc.Envelope) {
env.SessionID = task.SessionID
env.WindowID = task.WindowID
env.Pane = task.Pane
}); err != nil {
return err
}
}
if err := sendCommand("hide"); err != nil {
return err
}
return nil
}
if tev.Key() == tcell.KeyCtrlC {
return sendCommand("hide")
}
if tev.Modifiers()&tcell.ModAlt != 0 {
r := unicode.ToLower(tev.Rune())
switch r {
case 't':
if err := sendCommand("hide"); err != nil {
return err
}
case 'n':
if err := sendCommand("move_left"); err != nil {
return err
}
case 'i':
if err := sendCommand("move_right"); err != nil {
return err
}
case 'u':
if selected > 0 {
selected--
draw(time.Now())
}
case 'e':
if selected < len(tasks)-1 {
selected++
draw(time.Now())
}
}
} else if tev.Key() == tcell.KeyRune {
r := tev.Rune()
if unicode.ToLower(r) == 'd' && (tev.Modifiers()&tcell.ModShift != 0 || unicode.IsUpper(r)) {
if len(tasks) > 0 && selected < len(tasks) {
task := tasks[selected]
if err := sendCommand("delete_task", func(env *ipc.Envelope) {
env.SessionID = task.SessionID
env.WindowID = task.WindowID
env.Pane = task.Pane
env.Session = task.Session
env.Window = task.Window
}); err != nil {
return err
}
}
continue
}
r = unicode.ToLower(r)
if r == 'u' {
if selected > 0 {
selected--
draw(time.Now())
}
} else if r == 'e' {
if selected < len(tasks)-1 {
selected++
draw(time.Now())
}
}
}
if tev.Key() == tcell.KeyEscape {
if err := sendCommand("hide"); err != nil {
return err
}
}
case *tcell.EventResize:
draw(time.Now())
}
case now := <-ticker.C:
draw(now)
case err := <-errCh:
if err == nil || errors.Is(err, io.EOF) || strings.Contains(err.Error(), "use of closed network connection") {
return nil
}
return err
}
}
}
func sortTasks(tasks []ipc.Task) {
sort.SliceStable(tasks, func(i, j int) bool {
ti, tj := tasks[i], tasks[j]
ranki := taskStatusRank(ti.Status)
rankj := taskStatusRank(tj.Status)
if ranki != rankj {
return ranki < rankj
}
switch ti.Status {
case statusInProgress:
return ti.StartedAt < tj.StartedAt
case statusCompleted:
if ti.Acknowledged != tj.Acknowledged {
return !ti.Acknowledged && tj.Acknowledged
}
if ci, hasCi := parseTimestamp(ti.CompletedAt); hasCi {
if cj, hasCj := parseTimestamp(tj.CompletedAt); hasCj {
if !ci.Equal(cj) {
return ci.After(cj)
}
} else {
return true
}
} else if _, hasCj := parseTimestamp(tj.CompletedAt); hasCj {
return false
}
si, hasSi := parseTimestamp(ti.StartedAt)
sj, hasSj := parseTimestamp(tj.StartedAt)
if hasSi && hasSj && !si.Equal(sj) {
return si.After(sj)
}
if hasSi != hasSj {
return hasSi
}
return ti.StartedAt > tj.StartedAt
default:
return ti.StartedAt < tj.StartedAt
}
})
}
func taskStatusRank(status string) int {
switch status {
case statusInProgress:
return 0
case statusCompleted:
return 1
default:
return 2
}
}
func parseTimestamp(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
ts, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, false
}
return ts, true
}
func taskIndicator(t ipc.Task, now time.Time) string {
switch t.Status {
case statusInProgress:
idx := int(now.UnixNano()/int64(spinnerInterval)) % len(spinnerFrames)
return string(spinnerFrames[idx])
case statusCompleted:
if t.Acknowledged {
return "✓"
}
return "⚑"
default:
return "?"
}
}
func liveDuration(t ipc.Task, now time.Time) string {
if t.StartedAt == "" {
return "0s"
}
start, err := time.Parse(time.RFC3339, t.StartedAt)
if err != nil {
return formatDuration(t.DurationSeconds)
}
if t.Status == statusCompleted {
if t.CompletedAt != "" {
if end, err := time.Parse(time.RFC3339, t.CompletedAt); err == nil {
return formatDuration(end.Sub(start).Seconds())
}
}
return formatDuration(t.DurationSeconds)
}
return formatDuration(time.Since(start).Seconds())
}
func formatDuration(seconds float64) string {
if seconds < 0 {
seconds = 0
}
d := time.Duration(seconds * float64(time.Second))
if d >= 99*time.Hour {
return ">=99h"
}
hours := d / time.Hour
minutes := (d % time.Hour) / time.Minute
secondsPart := (d % time.Minute) / time.Second
if hours > 0 {
return fmt.Sprintf("%02dh%02dm", hours, minutes)
}
if minutes > 0 {
return fmt.Sprintf("%02dm%02ds", minutes, secondsPart)
}
return fmt.Sprintf("%02ds", secondsPart)
}
func truncate(text string, width int) string {
if width <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= width {
return text
}
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}
func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) {
width, _ := s.Size()
if x >= width {
return
}
runes := []rune(text)
limit := width - x
for i := 0; i < limit; i++ {
r := rune(' ')
if i < len(runes) {
r = runes[i]
}
s.SetContent(x+i, y, r, nil, style)
}
}
func socketPath() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return filepath.Join(dir, "agent-tracker.sock")
}
return filepath.Join(os.TempDir(), "agent-tracker.sock")
}

View file

@ -0,0 +1,194 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/david/agent-tracker/internal/ipc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
implementationName = "tracker_mcp"
implementationVersion = "0.1.0"
commandTimeout = 5 * time.Second
)
type trackerClient struct {
socket string
}
func newTrackerClient() *trackerClient {
socket := os.Getenv("CODEX_TRACKER_SOCKET")
if strings.TrimSpace(socket) == "" {
socket = socketPath()
}
return &trackerClient{socket: socket}
}
func (c *trackerClient) sendCommand(ctx context.Context, env ipc.Envelope) error {
env.Kind = "command"
d := net.Dialer{}
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, commandTimeout)
defer cancel()
}
conn, err := d.DialContext(ctx, "unix", c.socket)
if err != nil {
return err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
return err
}
}
enc := json.NewEncoder(conn)
dec := json.NewDecoder(conn)
if err := enc.Encode(&env); err != nil {
return err
}
for {
var reply ipc.Envelope
if err := dec.Decode(&reply); err != nil {
if errors.Is(err, net.ErrClosed) {
return fmt.Errorf("tracker server disconnected")
}
return err
}
if reply.Kind == "ack" {
return nil
}
}
}
type startInput struct {
Summary string `json:"summary"`
TmuxID string `json:"tmux_id"`
}
type finishInput struct {
Summary string `json:"summary"`
TmuxID string `json:"tmux_id"`
}
func main() {
log.SetFlags(0)
client := newTrackerClient()
server := mcp.NewServer(&mcp.Implementation{Name: implementationName, Version: implementationVersion}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "tracker_mark_start_working",
Description: "Record that work has started for the currently focused tmux session/window.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input startInput) (*mcp.CallToolResult, any, error) {
tmuxID := strings.TrimSpace(input.TmuxID)
if tmuxID == "" {
return nil, nil, fmt.Errorf("tmux_id is required")
}
target, err := determineContext(tmuxID)
if err != nil {
return nil, nil, err
}
summary := strings.TrimSpace(input.Summary)
if summary == "" {
return nil, nil, fmt.Errorf("summary is required")
}
env := ipc.Envelope{
Command: "start_task",
Session: target.SessionID,
SessionID: target.SessionID,
Window: target.WindowID,
WindowID: target.WindowID,
Pane: target.PaneID,
Summary: summary,
}
if err := client.sendCommand(ctx, env); err != nil {
return nil, nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Status recorded. Do the work, then call `tracker_mark_respond_to_user` exactly once, right before you send the user their reply."},
},
}, nil, nil
})
mcp.AddTool(server, &mcp.Tool{
Name: "tracker_mark_respond_to_user",
Description: "Record that work has completed for the currently focused tmux session/window.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input finishInput) (*mcp.CallToolResult, any, error) {
tmuxID := strings.TrimSpace(input.TmuxID)
if tmuxID == "" {
return nil, nil, fmt.Errorf("tmux_id is required")
}
target, err := determineContext(tmuxID)
if err != nil {
return nil, nil, err
}
summary := strings.TrimSpace(input.Summary)
env := ipc.Envelope{
Command: "finish_task",
Session: target.SessionID,
SessionID: target.SessionID,
Window: target.WindowID,
WindowID: target.WindowID,
Pane: target.PaneID,
Summary: summary,
}
if summary != "" {
env.Message = summary
}
if err := client.sendCommand(ctx, env); err != nil {
return nil, nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Send your reply now. You MUST NOT not call `tracker_mark_start_working` from now on until the user sends you a message."},
},
}, nil, nil
})
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
type tmuxContext struct {
SessionID string
WindowID string
PaneID string
}
func determineContext(tmuxID string) (tmuxContext, error) {
parts := strings.Split(strings.TrimSpace(tmuxID), "::")
if len(parts) != 3 {
return tmuxContext{}, fmt.Errorf("tmux_id must be session_id::window_id::pane_id")
}
sessionID := strings.TrimSpace(parts[0])
windowID := strings.TrimSpace(parts[1])
paneID := strings.TrimSpace(parts[2])
if sessionID == "" || windowID == "" || paneID == "" {
return tmuxContext{}, fmt.Errorf("tmux_id must include non-empty session, window, and pane identifiers")
}
return tmuxContext{SessionID: sessionID, WindowID: windowID, PaneID: paneID}, nil
}
func socketPath() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return filepath.Join(dir, "agent-tracker.sock")
}
return filepath.Join(os.TempDir(), "agent-tracker.sock")
}

File diff suppressed because it is too large Load diff

20
agent-tracker/go.mod Normal file
View file

@ -0,0 +1,20 @@
module github.com/david/agent-tracker
go 1.25.1
require (
github.com/gdamore/tcell/v2 v2.9.0
github.com/modelcontextprotocol/go-sdk v0.5.0
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/google/jsonschema-go v0.2.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

58
agent-tracker/go.sum Normal file
View file

@ -0,0 +1,58 @@
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys=
github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modelcontextprotocol/go-sdk v0.5.0 h1:WXRHx/4l5LF5MZboeIJYn7PMFCrMNduGGVapYWFgrF8=
github.com/modelcontextprotocol/go-sdk v0.5.0/go.mod h1:degUj7OVKR6JcYbDF+O99Fag2lTSTbamZacbGTRTSGU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

13
agent-tracker/install.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
go build -o bin/tracker-client ./cmd/tracker-client
go build -o bin/tracker-server ./cmd/tracker-server
go build -o bin/tracker-mcp ./cmd/tracker-mcp
echo "Built tracker client, server, and MCP binaries into bin/"

View file

@ -0,0 +1,32 @@
package ipc
type Envelope struct {
Kind string `json:"kind"`
Command string `json:"command,omitempty"`
Client string `json:"client,omitempty"`
Session string `json:"session,omitempty"`
SessionID string `json:"session_id,omitempty"`
Window string `json:"window,omitempty"`
WindowID string `json:"window_id,omitempty"`
Pane string `json:"pane,omitempty"`
Position string `json:"position,omitempty"`
Visible *bool `json:"visible,omitempty"`
Message string `json:"message,omitempty"`
Summary string `json:"summary,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
}
type Task struct {
SessionID string `json:"session_id"`
Session string `json:"session"`
WindowID string `json:"window_id"`
Window string `json:"window"`
Pane string `json:"pane,omitempty"`
Status string `json:"status"`
Summary string `json:"summary"`
CompletionNote string `json:"completion_note,omitempty"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
DurationSeconds float64 `json:"duration_seconds"`
Acknowledged bool `json:"acknowledged"`
}

View file

@ -0,0 +1,29 @@
package tracker
import "time"
type Status string
const (
StatusIdle Status = "idle"
StatusInProgress Status = "in_progress"
StatusCompleted Status = "completed"
)
type Entry struct {
Session string `json:"session"`
Pane string `json:"pane"`
Status Status `json:"status"`
Description string `json:"description"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Acknowledged bool `json:"acknowledged"`
}
type UpdateManager interface {
StartWork(session, pane, description string, now time.Time) (*Entry, error)
CompleteWork(session, pane, summary string, now time.Time) (*Entry, error)
Acknowledge(session, pane string) (*Entry, error)
Get(session, pane string) (*Entry, bool)
List() []*Entry
}

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v brew >/dev/null 2>&1; then
echo "Error: Homebrew is required but not found in PATH" >&2
exit 1
fi
if ! brew services list >/dev/null 2>&1; then
echo "Error: brew services command is unavailable; install the homebrew/services tap" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SERVER_BIN="$ROOT_DIR/bin/tracker-server"
if [[ ! -x "$SERVER_BIN" ]]; then
echo "Error: tracker-server binary not found at $SERVER_BIN" >&2
echo "Build it with: (cd $ROOT_DIR && ./install.sh)" >&2
exit 1
fi
BREW_REPO="$(brew --repository)"
TAP_PATH="$BREW_REPO/Library/Taps/agenttracker/homebrew-agent-tracker"
FORMULA_DIR="$TAP_PATH/Formula"
FORMULA_PATH="$FORMULA_DIR/agent-tracker-server.rb"
mkdir -p "$FORMULA_DIR"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cp "$SERVER_BIN" "$TMP_DIR/tracker-server"
TARBALL="$TMP_DIR/tracker-server.tar.gz"
tar -czf "$TARBALL" -C "$TMP_DIR" tracker-server
SHA256="$(shasum -a 256 "$TARBALL" | awk '{print $1}')"
VERSION="local-$(date +%Y%m%d%H%M%S)"
cat >"$FORMULA_PATH" <<EOF
class AgentTrackerServer < Formula
desc "Tmux-aware agent tracker server"
homepage "https://github.com/david/agent-tracker"
url "file://$TARBALL"
sha256 "$SHA256"
version "$VERSION"
def install
bin.install "tracker-server"
end
service do
run [opt_bin/"tracker-server"]
keep_alive true
working_dir var/"agent-tracker"
log_path var/"log/agent-tracker-server.log"
error_log_path var/"log/agent-tracker-server.log"
end
end
EOF
if brew list --formula agent-tracker-server >/dev/null 2>&1; then
brew reinstall --formula "$FORMULA_PATH" >/dev/null
else
brew install --formula "$FORMULA_PATH" >/dev/null
fi
mkdir -p "$(brew --prefix)/var/agent-tracker"
if brew services list | awk '{print $1}' | grep -qx "agent-tracker-server"; then
brew services restart agent-tracker-server >/dev/null
else
brew services start agent-tracker-server >/dev/null
fi
SERVICE_STATE="$(brew services list | awk '$1=="agent-tracker-server" {print $2}')"
if [[ "$SERVICE_STATE" != "started" ]]; then
echo "Error: brew reports agent-tracker-server service in state '$SERVICE_STATE'" >&2
exit 1
fi
echo "Agent tracker server managed by brew services (state: $SERVICE_STATE)." >&2