mirror of
https://github.com/theniceboy/.config.git
synced 2025-12-26 14:44:57 +08:00
codex agent tracker
This commit is contained in:
parent
20eddd1600
commit
b08d63e4a5
96 changed files with 3057 additions and 3055 deletions
19
agent-tracker/.brew/agent-tracker-server.rb
Normal file
19
agent-tracker/.brew/agent-tracker-server.rb
Normal 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
1
agent-tracker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.build/
|
||||
762
agent-tracker/cmd/tracker-client/main.go
Normal file
762
agent-tracker/cmd/tracker-client/main.go
Normal 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")
|
||||
}
|
||||
194
agent-tracker/cmd/tracker-mcp/main.go
Normal file
194
agent-tracker/cmd/tracker-mcp/main.go
Normal 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")
|
||||
}
|
||||
1028
agent-tracker/cmd/tracker-server/main.go
Normal file
1028
agent-tracker/cmd/tracker-server/main.go
Normal file
File diff suppressed because it is too large
Load diff
20
agent-tracker/go.mod
Normal file
20
agent-tracker/go.mod
Normal 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
58
agent-tracker/go.sum
Normal 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
13
agent-tracker/install.sh
Executable 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/"
|
||||
32
agent-tracker/internal/ipc/envelope.go
Normal file
32
agent-tracker/internal/ipc/envelope.go
Normal 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"`
|
||||
}
|
||||
29
agent-tracker/internal/tracker/tracker.go
Normal file
29
agent-tracker/internal/tracker/tracker.go
Normal 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
|
||||
}
|
||||
83
agent-tracker/scripts/install_brew_service.sh
Executable file
83
agent-tracker/scripts/install_brew_service.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue