mirror of
https://github.com/theniceboy/.config.git
synced 2026-04-24 14:27:58 +08:00
1048 lines
25 KiB
Go
1048 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/david/agent-tracker/internal/ipc"
|
|
)
|
|
|
|
const (
|
|
statusInProgress = "in_progress"
|
|
statusCompleted = "completed"
|
|
)
|
|
|
|
type taskRecord struct {
|
|
SessionID string
|
|
SessionName string
|
|
WindowID string
|
|
WindowName string
|
|
Pane string
|
|
Summary string
|
|
CompletionNote string
|
|
StartedAt time.Time
|
|
CompletedAt *time.Time
|
|
Status string
|
|
Acknowledged bool
|
|
}
|
|
|
|
type storedSettings struct {
|
|
NotificationsEnabled *bool `json:"notifications_enabled,omitempty"`
|
|
}
|
|
|
|
type tmuxTarget struct {
|
|
SessionName string
|
|
SessionID string
|
|
WindowName string
|
|
WindowID string
|
|
PaneID string
|
|
WindowIndex string
|
|
PaneIndex string
|
|
}
|
|
|
|
type uiSubscriber struct {
|
|
enc *json.Encoder
|
|
}
|
|
|
|
type server struct {
|
|
mu sync.Mutex
|
|
socketPath string
|
|
notificationsEnabled bool
|
|
tasks map[string]*taskRecord
|
|
subscribers map[*uiSubscriber]struct{}
|
|
settingsPath string
|
|
}
|
|
|
|
func newServer() *server {
|
|
return &server{
|
|
socketPath: socketPath(),
|
|
notificationsEnabled: true,
|
|
tasks: make(map[string]*taskRecord),
|
|
subscribers: make(map[*uiSubscriber]struct{}),
|
|
settingsPath: settingsStorePath(),
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
srv := newServer()
|
|
if err := srv.run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (s *server) run() error {
|
|
if err := s.loadSettings(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.RemoveAll(s.socketPath); err != nil {
|
|
return err
|
|
}
|
|
ln, err := net.Listen("unix", s.socketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(s.socketPath, 0o600); err != nil {
|
|
return err
|
|
}
|
|
defer ln.Close()
|
|
defer os.Remove(s.socketPath)
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
if errors.Is(err, net.ErrClosed) {
|
|
return
|
|
}
|
|
errCh <- err
|
|
return
|
|
}
|
|
go s.handleConn(conn)
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case sig := <-sigCh:
|
|
return fmt.Errorf("tracker-server stopped: %s", sig)
|
|
}
|
|
}
|
|
|
|
func (s *server) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
|
|
dec := json.NewDecoder(bufio.NewReader(conn))
|
|
enc := json.NewEncoder(conn)
|
|
|
|
var sub *uiSubscriber
|
|
defer func() {
|
|
if sub != nil {
|
|
s.removeSubscriber(sub)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
var env ipc.Envelope
|
|
if err := dec.Decode(&env); err != nil {
|
|
return
|
|
}
|
|
switch env.Kind {
|
|
case "command":
|
|
if err := s.handleCommand(env); err != nil {
|
|
log.Printf("command error: %v", err)
|
|
}
|
|
reply := ipc.Envelope{Kind: "ack"}
|
|
if err := enc.Encode(&reply); err != nil {
|
|
return
|
|
}
|
|
case "ui-register":
|
|
if sub == nil {
|
|
sub = &uiSubscriber{enc: enc}
|
|
s.addSubscriber(sub)
|
|
}
|
|
if err := s.sendStateTo(sub); err != nil {
|
|
return
|
|
}
|
|
default:
|
|
log.Printf("unknown message: %+v", env)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *server) handleCommand(env ipc.Envelope) error {
|
|
switch env.Command {
|
|
case "start_task":
|
|
target, err := requireSessionWindow(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
summary := firstNonEmpty(env.Summary, env.Message)
|
|
if summary == "" {
|
|
return fmt.Errorf("start_task requires summary")
|
|
}
|
|
if err := s.startTask(target, summary); err != nil {
|
|
return err
|
|
}
|
|
s.broadcastStateAsync()
|
|
s.statusRefreshAsync()
|
|
return nil
|
|
case "finish_task":
|
|
target, err := requireSessionWindow(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
note := firstNonEmpty(env.Summary, env.Message)
|
|
notify, err := s.finishTask(target, note)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if notify && s.notificationsAreEnabled() {
|
|
go s.notifyResponded(target)
|
|
}
|
|
s.broadcastStateAsync()
|
|
s.statusRefreshAsync()
|
|
return nil
|
|
case "update_task":
|
|
target, err := requireSessionWindow(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
summary := firstNonEmpty(env.Summary, env.Message)
|
|
if summary == "" {
|
|
return fmt.Errorf("update_task requires summary")
|
|
}
|
|
if err := s.updateTaskSummary(target, summary); err != nil {
|
|
return err
|
|
}
|
|
s.broadcastStateAsync()
|
|
s.statusRefreshAsync()
|
|
return nil
|
|
case "notifications_toggle":
|
|
enabled, err := s.toggleNotifications()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if client := strings.TrimSpace(env.Client); client != "" {
|
|
status := "OFF"
|
|
if enabled {
|
|
status = "ON"
|
|
}
|
|
if err := runTmux("display-message", "-c", client, "push notifications: "+status); err != nil {
|
|
log.Printf("notification toggle message error: %v", err)
|
|
}
|
|
}
|
|
s.broadcastStateAsync()
|
|
return nil
|
|
case "acknowledge":
|
|
target, err := requireSessionWindow(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.acknowledgeTask(target.SessionID, target.WindowID, target.PaneID); err != nil {
|
|
return err
|
|
}
|
|
s.broadcastStateAsync()
|
|
s.statusRefreshAsync()
|
|
return nil
|
|
case "delete_task":
|
|
target, err := requireSessionWindow(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.deleteTask(target.SessionID, target.WindowID, target.PaneID); err != nil {
|
|
return err
|
|
}
|
|
s.broadcastStateAsync()
|
|
s.statusRefreshAsync()
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown command %q", env.Command)
|
|
}
|
|
}
|
|
|
|
func (s *server) startTask(target tmuxTarget, summary string) error {
|
|
if target.SessionID == "" || target.WindowID == "" {
|
|
return fmt.Errorf("cannot create task: missing session or window ID")
|
|
}
|
|
target = normalizeTargetNames(target)
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
|
|
t, ok := s.tasks[key]
|
|
if !ok {
|
|
s.tasks[key] = &taskRecord{
|
|
SessionID: target.SessionID,
|
|
SessionName: strings.TrimSpace(target.SessionName),
|
|
WindowID: target.WindowID,
|
|
WindowName: strings.TrimSpace(target.WindowName),
|
|
Pane: target.PaneID,
|
|
Summary: summary,
|
|
StartedAt: now,
|
|
Status: statusInProgress,
|
|
Acknowledged: true,
|
|
}
|
|
return nil
|
|
}
|
|
mergeTaskNamesFromTarget(t, target)
|
|
if !(t.Status == statusInProgress && strings.TrimSpace(t.Summary) != "") {
|
|
t.Summary = summary
|
|
}
|
|
t.StartedAt = now
|
|
t.Status = statusInProgress
|
|
t.CompletedAt = nil
|
|
t.CompletionNote = ""
|
|
t.Acknowledged = true
|
|
return nil
|
|
}
|
|
|
|
func (s *server) updateTaskSummary(target tmuxTarget, summary string) error {
|
|
if target.SessionID == "" || target.WindowID == "" {
|
|
return fmt.Errorf("cannot update task: missing session or window ID")
|
|
}
|
|
target = normalizeTargetNames(target)
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
|
|
t, ok := s.tasks[key]
|
|
if !ok {
|
|
t = &taskRecord{
|
|
SessionID: target.SessionID,
|
|
SessionName: strings.TrimSpace(target.SessionName),
|
|
WindowID: target.WindowID,
|
|
WindowName: strings.TrimSpace(target.WindowName),
|
|
Pane: target.PaneID,
|
|
StartedAt: now,
|
|
Status: statusInProgress,
|
|
Acknowledged: true,
|
|
}
|
|
s.tasks[key] = t
|
|
}
|
|
mergeTaskNamesFromTarget(t, target)
|
|
t.Summary = summary
|
|
if t.Status == "" {
|
|
t.Status = statusInProgress
|
|
}
|
|
if t.StartedAt.IsZero() {
|
|
t.StartedAt = now
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
|
|
if target.SessionID == "" || target.WindowID == "" {
|
|
return false, nil // silently ignore - pane likely died
|
|
}
|
|
target = normalizeTargetNames(target)
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
|
|
t, ok := s.tasks[key]
|
|
wasCompleted := false
|
|
if !ok {
|
|
t = &taskRecord{
|
|
SessionID: target.SessionID,
|
|
SessionName: strings.TrimSpace(target.SessionName),
|
|
WindowID: target.WindowID,
|
|
WindowName: strings.TrimSpace(target.WindowName),
|
|
Pane: target.PaneID,
|
|
StartedAt: now,
|
|
}
|
|
s.tasks[key] = t
|
|
} else {
|
|
wasCompleted = t.Status == statusCompleted
|
|
}
|
|
if t.Summary == "" {
|
|
t.Summary = note
|
|
}
|
|
mergeTaskNamesFromTarget(t, target)
|
|
t.Status = statusCompleted
|
|
t.CompletedAt = &now
|
|
if note != "" {
|
|
t.CompletionNote = note
|
|
}
|
|
// Auto-acknowledge if user is currently in this pane
|
|
t.Acknowledged = isActivePane(target.PaneID)
|
|
return !wasCompleted, nil
|
|
}
|
|
|
|
func (s *server) acknowledgeTask(sessionID, windowID, paneID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if t, ok := s.tasks[taskKey(sessionID, windowID, paneID)]; ok {
|
|
t.Acknowledged = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) deleteTask(sessionID, windowID, paneID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.tasks, taskKey(sessionID, windowID, paneID))
|
|
return nil
|
|
}
|
|
|
|
func normalizeTargetNames(target tmuxTarget) tmuxTarget {
|
|
if strings.TrimSpace(target.SessionName) == strings.TrimSpace(target.SessionID) {
|
|
target.SessionName = ""
|
|
}
|
|
if strings.TrimSpace(target.WindowName) == strings.TrimSpace(target.WindowID) {
|
|
target.WindowName = ""
|
|
}
|
|
return target
|
|
}
|
|
|
|
func mergeTaskNamesFromTarget(task *taskRecord, target tmuxTarget) {
|
|
if task == nil {
|
|
return
|
|
}
|
|
if sessionName := strings.TrimSpace(target.SessionName); sessionName != "" {
|
|
task.SessionName = sessionName
|
|
}
|
|
if windowName := strings.TrimSpace(target.WindowName); windowName != "" {
|
|
task.WindowName = windowName
|
|
}
|
|
}
|
|
|
|
func (s *server) loadSettings() error {
|
|
data, err := os.ReadFile(s.settingsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
var stored storedSettings
|
|
if err := json.Unmarshal(data, &stored); err != nil {
|
|
return err
|
|
}
|
|
if stored.NotificationsEnabled != nil {
|
|
s.mu.Lock()
|
|
s.notificationsEnabled = *stored.NotificationsEnabled
|
|
s.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) saveSettingsLocked() error {
|
|
if err := os.MkdirAll(filepath.Dir(s.settingsPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
enabled := s.notificationsEnabled
|
|
data, err := json.MarshalIndent(storedSettings{NotificationsEnabled: &enabled}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.settingsPath + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.settingsPath)
|
|
}
|
|
|
|
func (s *server) notificationsAreEnabled() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.notificationsEnabled
|
|
}
|
|
|
|
func (s *server) toggleNotifications() (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.notificationsEnabled = !s.notificationsEnabled
|
|
if err := s.saveSettingsLocked(); err != nil {
|
|
return false, err
|
|
}
|
|
return s.notificationsEnabled, nil
|
|
}
|
|
|
|
func (s *server) notifyResponded(target tmuxTarget) {
|
|
target = s.fillTargetNamesFromTask(target)
|
|
summary := strings.TrimSpace(s.summaryForTask(target.SessionID, target.WindowID, target.PaneID))
|
|
if summary == "" {
|
|
summary = "Task marked complete"
|
|
}
|
|
title := notificationTitleForTarget(target)
|
|
action := notificationActionForTarget(target)
|
|
if err := sendSystemNotification(title, summary, action); err != nil {
|
|
log.Printf("notification error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) fillTargetNamesFromTask(target tmuxTarget) tmuxTarget {
|
|
target = normalizeTargetNames(target)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if task, ok := s.tasks[taskKey(target.SessionID, target.WindowID, target.PaneID)]; ok {
|
|
if strings.TrimSpace(target.SessionName) == "" {
|
|
target.SessionName = strings.TrimSpace(task.SessionName)
|
|
}
|
|
if strings.TrimSpace(target.WindowName) == "" {
|
|
target.WindowName = strings.TrimSpace(task.WindowName)
|
|
}
|
|
}
|
|
return target
|
|
}
|
|
|
|
func notificationTitleForTarget(target tmuxTarget) string {
|
|
target = normalizeTargetNames(target)
|
|
session := strings.TrimSpace(target.SessionName)
|
|
if session != "" {
|
|
session = stripSessionIndexPrefix(session)
|
|
}
|
|
if session == "" {
|
|
session = strings.TrimSpace(target.SessionID)
|
|
}
|
|
window := strings.TrimSpace(target.WindowName)
|
|
if window == "" {
|
|
window = strings.TrimSpace(target.WindowID)
|
|
}
|
|
|
|
if session != "" && window != "" {
|
|
return session + " - " + window
|
|
}
|
|
if session != "" {
|
|
return session
|
|
}
|
|
if window != "" {
|
|
return window
|
|
}
|
|
return "Tracker"
|
|
}
|
|
|
|
func stripSessionIndexPrefix(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
|
|
i := 0
|
|
for i < len(name) && name[i] >= '0' && name[i] <= '9' {
|
|
i++
|
|
}
|
|
if i == 0 {
|
|
return name
|
|
}
|
|
|
|
j := i
|
|
for j < len(name) && name[j] == ' ' {
|
|
j++
|
|
}
|
|
if j >= len(name) || name[j] != '-' {
|
|
return name
|
|
}
|
|
|
|
j++
|
|
for j < len(name) && name[j] == ' ' {
|
|
j++
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(name[j:])
|
|
if trimmed == "" {
|
|
return name
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func (s *server) summaryForTask(sessionID, windowID, paneID string) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if t, ok := s.tasks[taskKey(sessionID, windowID, paneID)]; ok {
|
|
note := strings.TrimSpace(t.CompletionNote)
|
|
summary := strings.TrimSpace(t.Summary)
|
|
if note != "" && !isGenericCompletionNote(note) {
|
|
return note
|
|
}
|
|
if summary != "" {
|
|
return summary
|
|
}
|
|
if note != "" {
|
|
return note
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isGenericCompletionNote(note string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(note))
|
|
normalized = strings.Trim(normalized, ".!?,;:-_()[]{}\"'` ")
|
|
if normalized == "" {
|
|
return true
|
|
}
|
|
switch normalized {
|
|
case "done", "complete", "completed", "finished", "fixed", "resolved", "ok", "okay", "success", "successful", "all set", "all good", "implemented", "updated", "shipped":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *server) broadcastStateAsync() {
|
|
go s.broadcastState()
|
|
}
|
|
|
|
func (s *server) broadcastState() {
|
|
env := s.buildStateEnvelope()
|
|
if env == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
subs := make([]*uiSubscriber, 0, len(s.subscribers))
|
|
for sub := range s.subscribers {
|
|
subs = append(subs, sub)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
for _, sub := range subs {
|
|
if err := sub.enc.Encode(env); err != nil {
|
|
s.removeSubscriber(sub)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *server) statusRefreshAsync() {
|
|
go func() {
|
|
if err := runTmux("refresh-client", "-S"); err != nil {
|
|
log.Printf("status refresh error: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *server) sendState(enc *json.Encoder) {
|
|
env := s.buildStateEnvelope()
|
|
if env == nil {
|
|
return
|
|
}
|
|
if err := enc.Encode(env); err != nil {
|
|
log.Printf("state send error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) sendStateTo(sub *uiSubscriber) error {
|
|
env := s.buildStateEnvelope()
|
|
if env == nil {
|
|
return nil
|
|
}
|
|
if err := sub.enc.Encode(env); err != nil {
|
|
s.removeSubscriber(sub)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) buildStateEnvelope() *ipc.Envelope {
|
|
s.mu.Lock()
|
|
copies := make([]*taskRecord, 0, len(s.tasks))
|
|
for _, task := range s.tasks {
|
|
copy := *task
|
|
copies = append(copies, ©)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
tasks := make([]ipc.Task, 0, len(copies))
|
|
nameCache := make(map[string][2]string)
|
|
for _, t := range copies {
|
|
started := ""
|
|
if !t.StartedAt.IsZero() {
|
|
started = t.StartedAt.Format(time.RFC3339)
|
|
}
|
|
completed := ""
|
|
var duration time.Duration
|
|
if t.CompletedAt != nil {
|
|
completed = t.CompletedAt.Format(time.RFC3339)
|
|
duration = t.CompletedAt.Sub(t.StartedAt)
|
|
} else {
|
|
duration = now.Sub(t.StartedAt)
|
|
}
|
|
if duration < 0 {
|
|
duration = 0
|
|
}
|
|
sessionName := strings.TrimSpace(t.SessionName)
|
|
windowName := strings.TrimSpace(t.WindowName)
|
|
if sessionName == strings.TrimSpace(t.SessionID) {
|
|
sessionName = ""
|
|
}
|
|
if windowName == strings.TrimSpace(t.WindowID) {
|
|
windowName = ""
|
|
}
|
|
if sessionName == "" || windowName == "" {
|
|
if cached, ok := nameCache[t.WindowID]; ok {
|
|
if sessionName == "" {
|
|
sessionName = cached[0]
|
|
}
|
|
if windowName == "" {
|
|
windowName = cached[1]
|
|
}
|
|
} else {
|
|
sessName, winName, err := tmuxNamesForWindow(t.WindowID)
|
|
if err == nil {
|
|
nameCache[t.WindowID] = [2]string{sessName, winName}
|
|
if sessionName == "" {
|
|
sessionName = sessName
|
|
}
|
|
if windowName == "" {
|
|
windowName = winName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if sessionName == "" {
|
|
sessionName = t.SessionID
|
|
}
|
|
if windowName == "" {
|
|
windowName = t.WindowID
|
|
}
|
|
|
|
tasks = append(tasks, ipc.Task{
|
|
SessionID: t.SessionID,
|
|
Session: sessionName,
|
|
WindowID: t.WindowID,
|
|
Window: windowName,
|
|
Pane: t.Pane,
|
|
Status: t.Status,
|
|
Summary: t.Summary,
|
|
CompletionNote: t.CompletionNote,
|
|
StartedAt: started,
|
|
CompletedAt: completed,
|
|
DurationSeconds: duration.Seconds(),
|
|
Acknowledged: t.Acknowledged,
|
|
})
|
|
}
|
|
|
|
msg := stateSummary(tasks)
|
|
return &ipc.Envelope{
|
|
Kind: "state",
|
|
Message: msg,
|
|
Tasks: tasks,
|
|
}
|
|
}
|
|
|
|
func (s *server) addSubscriber(sub *uiSubscriber) {
|
|
s.mu.Lock()
|
|
s.subscribers[sub] = struct{}{}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *server) removeSubscriber(sub *uiSubscriber) {
|
|
s.mu.Lock()
|
|
delete(s.subscribers, sub)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
type notificationAction struct {
|
|
Command string
|
|
ActivateApp string
|
|
}
|
|
|
|
func notificationActionForTarget(target tmuxTarget) *notificationAction {
|
|
session := strings.TrimSpace(target.SessionID)
|
|
window := strings.TrimSpace(target.WindowID)
|
|
pane := strings.TrimSpace(target.PaneID)
|
|
if session == "" || window == "" || pane == "" {
|
|
return nil
|
|
}
|
|
cmd := fmt.Sprintf("tmux switch-client -t %s && tmux select-window -t %s && tmux select-pane -t %s",
|
|
shellQuote(session), shellQuote(window), shellQuote(pane))
|
|
return ¬ificationAction{
|
|
Command: "sh -lc " + strconv.Quote(cmd),
|
|
ActivateApp: "com.googlecode.iterm2",
|
|
}
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
if value == "" {
|
|
return "''"
|
|
}
|
|
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
|
}
|
|
|
|
func sendSystemNotification(title, message string, action *notificationAction) error {
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
title = "Tracker"
|
|
}
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
message = title
|
|
}
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
if bin, err := exec.LookPath("terminal-notifier"); err == nil {
|
|
args := []string{"-title", title, "-message", message, "-group", "agent-tracker"}
|
|
if action != nil {
|
|
if strings.TrimSpace(action.Command) != "" {
|
|
args = append(args, "-execute", action.Command)
|
|
}
|
|
if strings.TrimSpace(action.ActivateApp) != "" {
|
|
args = append(args, "-activate", action.ActivateApp)
|
|
}
|
|
}
|
|
cmd := exec.Command(bin, args...)
|
|
if err := cmd.Run(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
scriptLines := []string{fmt.Sprintf("display notification %s with title %s", strconv.Quote(message), strconv.Quote(title))}
|
|
cmd := exec.Command("osascript", "-e", strings.Join(scriptLines, "\n"))
|
|
if err := cmd.Run(); err != nil {
|
|
return err
|
|
}
|
|
case "linux":
|
|
if _, err := exec.LookPath("notify-send"); err != nil {
|
|
return err
|
|
}
|
|
cmd := exec.Command("notify-send", title, message)
|
|
if err := cmd.Run(); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runTmux(args ...string) error {
|
|
cmd := exec.Command("tmux", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
trimmed := strings.TrimSpace(string(output))
|
|
if trimmed != "" {
|
|
return fmt.Errorf("tmux %s: %v: %s", strings.Join(args, " "), err, trimmed)
|
|
}
|
|
return fmt.Errorf("tmux %s: %w", strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isActivePane(paneID string) bool {
|
|
clients, err := listClients()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, client := range clients {
|
|
output, err := tmuxDisplay(client, "#{pane_id}")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(output) == paneID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tmuxOutput(args ...string) (string, error) {
|
|
cmd := exec.Command("tmux", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("tmux %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func tmuxDisplay(client, format string) (string, error) {
|
|
cmd := exec.Command("tmux", "display-message", "-p", "-c", client, format)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("display-message %s: %w (%s)", format, err, strings.TrimSpace(string(output)))
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func listClients() ([]string, error) {
|
|
cmd := exec.Command("tmux", "list-clients", "-F", "#{client_tty}")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
var clients []string
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed != "" {
|
|
clients = append(clients, trimmed)
|
|
}
|
|
}
|
|
return clients, 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")
|
|
}
|
|
|
|
func settingsStorePath() string {
|
|
base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run")
|
|
return filepath.Join(base, "settings.json")
|
|
}
|
|
|
|
func taskKey(sessionID, windowID, paneID string) string {
|
|
return strings.Join([]string{sessionID, windowID, paneID}, "|")
|
|
}
|
|
|
|
func requireSessionWindow(env ipc.Envelope) (tmuxTarget, error) {
|
|
ctx := normalizeTargetNames(tmuxTarget{
|
|
SessionName: strings.TrimSpace(env.Session),
|
|
SessionID: strings.TrimSpace(env.SessionID),
|
|
WindowName: strings.TrimSpace(env.Window),
|
|
WindowID: strings.TrimSpace(env.WindowID),
|
|
PaneID: strings.TrimSpace(env.Pane),
|
|
})
|
|
|
|
fetchOrder := []string{}
|
|
if ctx.PaneID != "" {
|
|
fetchOrder = append(fetchOrder, ctx.PaneID)
|
|
}
|
|
if ctx.WindowID != "" {
|
|
fetchOrder = append(fetchOrder, ctx.WindowID)
|
|
}
|
|
fetchOrder = append(fetchOrder, "")
|
|
|
|
for _, target := range fetchOrder {
|
|
if ctx.complete() {
|
|
break
|
|
}
|
|
info, err := detectTmuxTarget(target)
|
|
if err != nil {
|
|
if target == "" {
|
|
return tmuxTarget{}, err
|
|
}
|
|
continue
|
|
}
|
|
ctx = ctx.merge(info)
|
|
}
|
|
|
|
if ctx.SessionID == "" || ctx.WindowID == "" {
|
|
return tmuxTarget{}, fmt.Errorf("session and window required")
|
|
}
|
|
|
|
if ctx.SessionName == "" || ctx.WindowName == "" {
|
|
if info, err := detectTmuxTarget(ctx.WindowID); err == nil {
|
|
ctx = ctx.merge(normalizeTargetNames(info))
|
|
}
|
|
}
|
|
|
|
if ctx.SessionName == "" {
|
|
ctx.SessionName = ctx.SessionID
|
|
}
|
|
if ctx.WindowName == "" {
|
|
ctx.WindowName = ctx.WindowID
|
|
}
|
|
if strings.TrimSpace(ctx.PaneID) == "" {
|
|
return tmuxTarget{}, fmt.Errorf("pane identifier required")
|
|
}
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
func (t tmuxTarget) complete() bool {
|
|
return t.SessionName != "" && t.SessionID != "" && t.WindowName != "" && t.WindowID != "" && t.PaneID != ""
|
|
}
|
|
|
|
func (t tmuxTarget) merge(other tmuxTarget) tmuxTarget {
|
|
if t.SessionName == "" {
|
|
t.SessionName = other.SessionName
|
|
}
|
|
if t.SessionID == "" {
|
|
t.SessionID = other.SessionID
|
|
}
|
|
if t.WindowName == "" {
|
|
t.WindowName = other.WindowName
|
|
}
|
|
if t.WindowID == "" {
|
|
t.WindowID = other.WindowID
|
|
}
|
|
if t.PaneID == "" {
|
|
t.PaneID = other.PaneID
|
|
}
|
|
if t.WindowIndex == "" {
|
|
t.WindowIndex = other.WindowIndex
|
|
}
|
|
if t.PaneIndex == "" {
|
|
t.PaneIndex = other.PaneIndex
|
|
}
|
|
return t
|
|
}
|
|
|
|
func detectTmuxTarget(target string) (tmuxTarget, error) {
|
|
format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}:::#{window_index}:::#{pane_index}"
|
|
output, err := tmuxQuery(strings.TrimSpace(target), format)
|
|
if err != nil {
|
|
return tmuxTarget{}, err
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(output), ":::")
|
|
if len(parts) != 7 {
|
|
return tmuxTarget{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(output))
|
|
}
|
|
return tmuxTarget{
|
|
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]),
|
|
WindowIndex: strings.TrimSpace(parts[5]),
|
|
PaneIndex: strings.TrimSpace(parts[6]),
|
|
}, nil
|
|
}
|
|
|
|
func tmuxNamesForWindow(windowID string) (string, string, error) {
|
|
if strings.TrimSpace(windowID) == "" {
|
|
return "", "", fmt.Errorf("window id required")
|
|
}
|
|
output, err := tmuxQuery(strings.TrimSpace(windowID), "#{session_name}:::#{window_name}")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(output), ":::")
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(output))
|
|
}
|
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
|
|
}
|
|
|
|
func tmuxQuery(target, format string) (string, error) {
|
|
args := []string{"display-message", "-p"}
|
|
if target != "" {
|
|
args = append(args, "-t", target)
|
|
}
|
|
args = append(args, format)
|
|
cmd := exec.Command("tmux", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("tmux %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
|
|
}
|
|
return strings.TrimSpace(string(output)), nil
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stateSummary(tasks []ipc.Task) string {
|
|
inProgress := 0
|
|
waiting := 0
|
|
for _, t := range tasks {
|
|
switch t.Status {
|
|
case statusInProgress:
|
|
inProgress++
|
|
case statusCompleted:
|
|
if !t.Acknowledged {
|
|
waiting++
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("Active %d · Waiting %d · %s", inProgress, waiting, time.Now().Format(time.Kitchen))
|
|
}
|