mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
feat: notes mode in agent tracker
This commit is contained in:
parent
fe41728347
commit
4d8b75f932
6 changed files with 1479 additions and 307 deletions
|
|
@ -52,6 +52,7 @@ set-hook -ag client-attached 'run -b "tmux refresh-client -S"'
|
||||||
set-hook -ag pane-focus-in 'run -b "~/.config/agent-tracker/bin/tracker-client command acknowledge --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
set-hook -ag pane-focus-in 'run -b "~/.config/agent-tracker/bin/tracker-client command acknowledge --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
||||||
set-hook -ag pane-focus-in 'run -b "tmux refresh-client -S"'
|
set-hook -ag pane-focus-in 'run -b "tmux refresh-client -S"'
|
||||||
set-hook -ag pane-died 'run -b "~/.config/agent-tracker/bin/tracker-client command delete_task --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
set-hook -ag pane-died 'run -b "~/.config/agent-tracker/bin/tracker-client command delete_task --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
||||||
|
set-hook -ag pane-died 'run -b "~/.config/agent-tracker/bin/tracker-client command note_archive_pane --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
||||||
|
|
||||||
# -- Project-specific window activation hooks
|
# -- Project-specific window activation hooks
|
||||||
# Checks for ./on-tmux-window-activate.sh or ../on-tmux-window-activate.sh and runs it
|
# Checks for ./on-tmux-window-activate.sh or ../on-tmux-window-activate.sh and runs it
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +1,20 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/david/agent-tracker/internal/ipc"
|
"github.com/david/agent-tracker/internal/ipc"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -77,50 +77,50 @@ func (c *trackerClient) sendCommand(ctx context.Context, env ipc.Envelope) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type startInput struct {
|
type startInput struct {
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
TmuxID string `json:"tmux_id"`
|
TmuxID string `json:"tmux_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
client := newTrackerClient()
|
client := newTrackerClient()
|
||||||
|
|
||||||
server := mcp.NewServer(&mcp.Implementation{Name: implementationName, Version: implementationVersion}, nil)
|
server := mcp.NewServer(&mcp.Implementation{Name: implementationName, Version: implementationVersion}, nil)
|
||||||
|
|
||||||
mcp.AddTool(server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "tracker_mark_start_working",
|
Name: "tracker_mark_start_working",
|
||||||
Description: "Record that work has started for the specified tmux session/window/pane.",
|
Description: "Record that work has started for the specified tmux session/window/pane.",
|
||||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, input startInput) (*mcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, _ *mcp.CallToolRequest, input startInput) (*mcp.CallToolResult, any, error) {
|
||||||
tmuxID := strings.TrimSpace(input.TmuxID)
|
tmuxID := strings.TrimSpace(input.TmuxID)
|
||||||
if tmuxID == "" {
|
if tmuxID == "" {
|
||||||
return nil, nil, fmt.Errorf("tmux_id is required; pass session_id::window_id::pane_id (for example, $3::@12::%30)")
|
return nil, nil, fmt.Errorf("tmux_id is required; pass session_id::window_id::pane_id (for example, $3::@12::%%30)")
|
||||||
}
|
}
|
||||||
target, err := determineContext(tmuxID)
|
target, err := determineContext(tmuxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
summary := strings.TrimSpace(input.Summary)
|
summary := strings.TrimSpace(input.Summary)
|
||||||
if summary == "" {
|
if summary == "" {
|
||||||
return nil, nil, fmt.Errorf("summary is required")
|
return nil, nil, fmt.Errorf("summary is required")
|
||||||
}
|
}
|
||||||
env := ipc.Envelope{
|
env := ipc.Envelope{
|
||||||
Command: "start_task",
|
Command: "start_task",
|
||||||
Session: target.SessionID,
|
Session: target.SessionID,
|
||||||
SessionID: target.SessionID,
|
SessionID: target.SessionID,
|
||||||
Window: target.WindowID,
|
Window: target.WindowID,
|
||||||
WindowID: target.WindowID,
|
WindowID: target.WindowID,
|
||||||
Pane: target.PaneID,
|
Pane: target.PaneID,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
}
|
}
|
||||||
if err := client.sendCommand(ctx, env); err != nil {
|
if err := client.sendCommand(ctx, env); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return &mcp.CallToolResult{
|
return &mcp.CallToolResult{
|
||||||
Content: []mcp.Content{
|
Content: []mcp.Content{
|
||||||
&mcp.TextContent{Text: "Status recorded."},
|
&mcp.TextContent{Text: "Status recorded."},
|
||||||
},
|
},
|
||||||
}, nil, nil
|
}, nil, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -134,55 +134,55 @@ type tmuxContext struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func determineContext(tmuxID string) (tmuxContext, error) {
|
func determineContext(tmuxID string) (tmuxContext, error) {
|
||||||
parts := strings.Split(strings.TrimSpace(tmuxID), "::")
|
parts := strings.Split(strings.TrimSpace(tmuxID), "::")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return tmuxContext{}, fmt.Errorf("tmux_id must be session_id::window_id::pane_id")
|
return tmuxContext{}, fmt.Errorf("tmux_id must be session_id::window_id::pane_id")
|
||||||
}
|
}
|
||||||
sessionID := strings.TrimSpace(parts[0])
|
sessionID := strings.TrimSpace(parts[0])
|
||||||
windowID := strings.TrimSpace(parts[1])
|
windowID := strings.TrimSpace(parts[1])
|
||||||
paneID := strings.TrimSpace(parts[2])
|
paneID := strings.TrimSpace(parts[2])
|
||||||
if sessionID == "" || windowID == "" || paneID == "" {
|
if sessionID == "" || windowID == "" || paneID == "" {
|
||||||
return tmuxContext{}, fmt.Errorf("tmux_id must include non-empty session, window, and pane identifiers")
|
return tmuxContext{}, fmt.Errorf("tmux_id must include non-empty session, window, and pane identifiers")
|
||||||
}
|
}
|
||||||
return tmuxContext{SessionID: sessionID, WindowID: windowID, PaneID: paneID}, nil
|
return tmuxContext{SessionID: sessionID, WindowID: windowID, PaneID: paneID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func socketPath() string {
|
func socketPath() string {
|
||||||
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
||||||
return filepath.Join(dir, "agent-tracker.sock")
|
return filepath.Join(dir, "agent-tracker.sock")
|
||||||
}
|
}
|
||||||
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
// autodetectContext tries to resolve the current tmux session/window/pane.
|
// autodetectContext tries to resolve the current tmux session/window/pane.
|
||||||
// It first uses TMUX_PANE if set, then falls back to the parent process TTY.
|
// It first uses TMUX_PANE if set, then falls back to the parent process TTY.
|
||||||
func autodetectContext() (tmuxContext, error) {
|
func autodetectContext() (tmuxContext, error) {
|
||||||
pane := strings.TrimSpace(os.Getenv("TMUX_PANE"))
|
pane := strings.TrimSpace(os.Getenv("TMUX_PANE"))
|
||||||
if pane != "" {
|
if pane != "" {
|
||||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", pane, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
out, err := exec.Command("tmux", "display-message", "-p", "-t", pane, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use parent process TTY
|
// Fallback: use parent process TTY
|
||||||
ppid := os.Getppid()
|
ppid := os.Getppid()
|
||||||
ttyOut, err := exec.Command("ps", "-o", "tty=", "-p", fmt.Sprint(ppid)).CombinedOutput()
|
ttyOut, err := exec.Command("ps", "-o", "tty=", "-p", fmt.Sprint(ppid)).CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tty := strings.TrimSpace(string(ttyOut))
|
tty := strings.TrimSpace(string(ttyOut))
|
||||||
if tty != "" && tty != "?" {
|
if tty != "" && tty != "?" {
|
||||||
out, err := exec.Command("tmux", "display-message", "-p", "-c", "/dev/"+tty, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
out, err := exec.Command("tmux", "display-message", "-p", "-c", "/dev/"+tty, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmuxContext{}, fmt.Errorf("unable to determine tmux context from environment")
|
return tmuxContext{}, fmt.Errorf("unable to determine tmux context from environment")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -36,6 +37,13 @@ const (
|
||||||
statusCompleted = "completed"
|
statusCompleted = "completed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scopePane = "pane"
|
||||||
|
scopeWindow = "window"
|
||||||
|
scopeSession = "session"
|
||||||
|
scopeAll = "all"
|
||||||
|
)
|
||||||
|
|
||||||
type taskRecord struct {
|
type taskRecord struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
WindowID string
|
WindowID string
|
||||||
|
|
@ -48,6 +56,21 @@ type taskRecord struct {
|
||||||
Acknowledged bool
|
Acknowledged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type noteRecord struct {
|
||||||
|
ID string
|
||||||
|
SessionID string
|
||||||
|
Session string
|
||||||
|
WindowID string
|
||||||
|
Window string
|
||||||
|
PaneID string
|
||||||
|
Summary string
|
||||||
|
Completed bool
|
||||||
|
Archived bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
ArchivedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type tmuxTarget struct {
|
type tmuxTarget struct {
|
||||||
SessionName string
|
SessionName string
|
||||||
SessionID string
|
SessionID string
|
||||||
|
|
@ -68,7 +91,10 @@ type server struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
tasks map[string]*taskRecord
|
tasks map[string]*taskRecord
|
||||||
|
notes map[string]*noteRecord
|
||||||
subscribers map[*uiSubscriber]struct{}
|
subscribers map[*uiSubscriber]struct{}
|
||||||
|
notesPath string
|
||||||
|
noteCounter uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer() *server {
|
func newServer() *server {
|
||||||
|
|
@ -78,7 +104,9 @@ func newServer() *server {
|
||||||
width: 84,
|
width: 84,
|
||||||
height: 24,
|
height: 24,
|
||||||
tasks: make(map[string]*taskRecord),
|
tasks: make(map[string]*taskRecord),
|
||||||
|
notes: make(map[string]*noteRecord),
|
||||||
subscribers: make(map[*uiSubscriber]struct{}),
|
subscribers: make(map[*uiSubscriber]struct{}),
|
||||||
|
notesPath: notesStorePath(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +118,9 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) run() error {
|
func (s *server) run() error {
|
||||||
|
if err := s.loadNotes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -208,19 +239,19 @@ func (s *server) handleCommand(env ipc.Envelope) error {
|
||||||
s.broadcastStateAsync()
|
s.broadcastStateAsync()
|
||||||
s.statusRefreshAsync()
|
s.statusRefreshAsync()
|
||||||
return nil
|
return nil
|
||||||
case "finish_task":
|
case "finish_task":
|
||||||
target, err := requireSessionWindow(env)
|
target, err := requireSessionWindow(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
note := firstNonEmpty(env.Summary, env.Message)
|
note := firstNonEmpty(env.Summary, env.Message)
|
||||||
if err := s.finishTask(target, note); err != nil {
|
if err := s.finishTask(target, note); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// s.notifyResponded(target)
|
// s.notifyResponded(target)
|
||||||
s.broadcastStateAsync()
|
s.broadcastStateAsync()
|
||||||
s.statusRefreshAsync()
|
s.statusRefreshAsync()
|
||||||
return nil
|
return nil
|
||||||
case "acknowledge":
|
case "acknowledge":
|
||||||
target, err := requireSessionWindow(env)
|
target, err := requireSessionWindow(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -252,6 +283,69 @@ func (s *server) handleCommand(env ipc.Envelope) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case "note_add":
|
||||||
|
target := 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),
|
||||||
|
}
|
||||||
|
if err := s.addNote(target, env.Scope, env.Summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_edit":
|
||||||
|
if err := s.editNote(strings.TrimSpace(env.NoteID), env.Summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_toggle_complete":
|
||||||
|
if err := s.toggleNoteCompletion(strings.TrimSpace(env.NoteID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_delete":
|
||||||
|
if err := s.deleteNote(strings.TrimSpace(env.NoteID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_archive":
|
||||||
|
if err := s.archiveNote(strings.TrimSpace(env.NoteID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_archive_pane":
|
||||||
|
if err := s.archiveNotesForPane(strings.TrimSpace(env.SessionID), strings.TrimSpace(env.WindowID), strings.TrimSpace(env.Pane)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
|
case "note_attach":
|
||||||
|
target := 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),
|
||||||
|
}
|
||||||
|
if err := s.attachArchivedNote(strings.TrimSpace(env.NoteID), target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.broadcastStateAsync()
|
||||||
|
s.statusRefreshAsync()
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown command %q", env.Command)
|
return fmt.Errorf("unknown command %q", env.Command)
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +405,302 @@ func (s *server) deleteTask(sessionID, windowID, paneID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeScope(scope string) string {
|
||||||
|
scope = strings.TrimSpace(strings.ToLower(scope))
|
||||||
|
switch scope {
|
||||||
|
case scopePane, scopeWindow, scopeSession, scopeAll:
|
||||||
|
return scope
|
||||||
|
default:
|
||||||
|
return scopeWindow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) addNote(target tmuxTarget, scope, summary string) error {
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if summary == "" {
|
||||||
|
return fmt.Errorf("note summary required")
|
||||||
|
}
|
||||||
|
scope = normalizeScope(scope)
|
||||||
|
target = normalizeNoteTargetNames(target)
|
||||||
|
switch scope {
|
||||||
|
case scopePane:
|
||||||
|
if target.SessionID == "" || target.WindowID == "" || target.PaneID == "" {
|
||||||
|
return fmt.Errorf("pane notes require session, window, and pane identifiers")
|
||||||
|
}
|
||||||
|
case scopeWindow:
|
||||||
|
if target.SessionID == "" || target.WindowID == "" {
|
||||||
|
return fmt.Errorf("window notes require session and window identifiers")
|
||||||
|
}
|
||||||
|
case scopeSession, scopeAll:
|
||||||
|
if target.SessionID == "" {
|
||||||
|
return fmt.Errorf("session notes require a session identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n := ¬eRecord{
|
||||||
|
ID: s.newNoteIDLocked(now),
|
||||||
|
SessionID: target.SessionID,
|
||||||
|
Session: target.SessionName,
|
||||||
|
WindowID: target.WindowID,
|
||||||
|
Window: target.WindowName,
|
||||||
|
PaneID: target.PaneID,
|
||||||
|
Summary: summary,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
s.notes[n.ID] = n
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) editNote(id, summary string) error {
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if summary == "" {
|
||||||
|
return fmt.Errorf("note summary required")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.notes[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
n.Summary = summary
|
||||||
|
now := time.Now()
|
||||||
|
n.UpdatedAt = now
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) toggleNoteCompletion(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.notes[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
n.Completed = !n.Completed
|
||||||
|
n.UpdatedAt = time.Now()
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) deleteNote(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.notes[id]; !ok {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
delete(s.notes, id)
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) archiveNote(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.notes[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
if n.Archived {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n.Archived = true
|
||||||
|
n.ArchivedAt = &now
|
||||||
|
n.UpdatedAt = now
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) archiveNotesForPane(sessionID, windowID, paneID string) error {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
windowID = strings.TrimSpace(windowID)
|
||||||
|
paneID = strings.TrimSpace(paneID)
|
||||||
|
if sessionID == "" || windowID == "" || paneID == "" {
|
||||||
|
return fmt.Errorf("pane archive requires session, window, and pane identifiers")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
changed := false
|
||||||
|
for _, n := range s.notes {
|
||||||
|
if n.Archived {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.SessionID == sessionID && n.WindowID == windowID && n.PaneID == paneID {
|
||||||
|
n.Archived = true
|
||||||
|
n.ArchivedAt = &now
|
||||||
|
n.UpdatedAt = now
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) attachArchivedNote(id string, target tmuxTarget) error {
|
||||||
|
target = normalizeNoteTargetNames(target)
|
||||||
|
if target.SessionID == "" || target.WindowID == "" || target.PaneID == "" {
|
||||||
|
return fmt.Errorf("attach requires session, window, and pane identifiers")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.notes[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
if !n.Archived {
|
||||||
|
return fmt.Errorf("note is not archived")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n.SessionID = target.SessionID
|
||||||
|
n.Session = target.SessionName
|
||||||
|
n.WindowID = target.WindowID
|
||||||
|
n.Window = target.WindowName
|
||||||
|
n.PaneID = target.PaneID
|
||||||
|
n.Archived = false
|
||||||
|
n.ArchivedAt = nil
|
||||||
|
n.UpdatedAt = now
|
||||||
|
return s.saveNotesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) saveNotesLocked() error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.notesPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type storedNote struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Session string `json:"session"`
|
||||||
|
WindowID string `json:"window_id"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
PaneID string `json:"pane_id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]storedNote, 0, len(s.notes))
|
||||||
|
for _, n := range s.notes {
|
||||||
|
records = append(records, storedNote(*n))
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(records, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.notesPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.notesPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) loadNotes() error {
|
||||||
|
if s.notes == nil {
|
||||||
|
s.notes = make(map[string]*noteRecord)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(s.notesPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type storedNote struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Session string `json:"session"`
|
||||||
|
WindowID string `json:"window_id"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
PaneID string `json:"pane_id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
}
|
||||||
|
var records []storedNote
|
||||||
|
if err := json.Unmarshal(data, &records); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rec := range records {
|
||||||
|
n := rec
|
||||||
|
s.notes[n.ID] = ¬eRecord{
|
||||||
|
ID: n.ID,
|
||||||
|
SessionID: n.SessionID,
|
||||||
|
Session: n.Session,
|
||||||
|
WindowID: n.WindowID,
|
||||||
|
Window: n.Window,
|
||||||
|
PaneID: n.PaneID,
|
||||||
|
Summary: n.Summary,
|
||||||
|
Completed: n.Completed,
|
||||||
|
Archived: n.Archived,
|
||||||
|
CreatedAt: n.CreatedAt,
|
||||||
|
UpdatedAt: n.UpdatedAt,
|
||||||
|
ArchivedAt: n.ArchivedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) newNoteIDLocked(now time.Time) string {
|
||||||
|
counter := atomic.AddUint64(&s.noteCounter, 1)
|
||||||
|
return fmt.Sprintf("%x-%x", now.UnixNano(), counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNoteTargetNames(target tmuxTarget) tmuxTarget {
|
||||||
|
if strings.TrimSpace(target.SessionName) == "" {
|
||||||
|
target.SessionName = target.SessionID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.WindowName) == "" {
|
||||||
|
target.WindowName = target.WindowID
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) notesForState() ([]ipc.Note, []ipc.Note) {
|
||||||
|
s.mu.Lock()
|
||||||
|
records := make([]*noteRecord, 0, len(s.notes))
|
||||||
|
for _, n := range s.notes {
|
||||||
|
records = append(records, n)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
active := make([]ipc.Note, 0, len(records))
|
||||||
|
archived := make([]ipc.Note, 0, len(records))
|
||||||
|
|
||||||
|
for _, n := range records {
|
||||||
|
copy := ipc.Note{
|
||||||
|
ID: n.ID,
|
||||||
|
SessionID: n.SessionID,
|
||||||
|
Session: n.Session,
|
||||||
|
WindowID: n.WindowID,
|
||||||
|
Window: n.Window,
|
||||||
|
Pane: n.PaneID,
|
||||||
|
Summary: n.Summary,
|
||||||
|
Completed: n.Completed,
|
||||||
|
Archived: n.Archived,
|
||||||
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if n.ArchivedAt != nil {
|
||||||
|
copy.ArchivedAt = n.ArchivedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if n.Archived {
|
||||||
|
archived = append(archived, copy)
|
||||||
|
} else {
|
||||||
|
active = append(active, copy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return active, archived
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) notifyResponded(target tmuxTarget) {
|
func (s *server) notifyResponded(target tmuxTarget) {
|
||||||
summary := strings.TrimSpace(s.summaryForTask(target.SessionID, target.WindowID, target.PaneID))
|
summary := strings.TrimSpace(s.summaryForTask(target.SessionID, target.WindowID, target.PaneID))
|
||||||
if summary == "" {
|
if summary == "" {
|
||||||
|
|
@ -548,6 +938,10 @@ func (s *server) closeOnClients() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) openPopup(client string) error {
|
func (s *server) openPopup(client string) error {
|
||||||
|
origin := ""
|
||||||
|
if ctx, err := tmuxDisplay(client, "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}"); err == nil {
|
||||||
|
origin = strings.TrimSpace(ctx)
|
||||||
|
}
|
||||||
width, height := s.popupSize()
|
width, height := s.popupSize()
|
||||||
x, y, err := s.popupPosition(client, width, height)
|
x, y, err := s.popupPosition(client, width, height)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -567,6 +961,18 @@ func (s *server) openPopup(client string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
args = append(args, bin, "ui", "--client", client)
|
args = append(args, bin, "ui", "--client", client)
|
||||||
|
if origin != "" {
|
||||||
|
parts := strings.Split(origin, ":::")
|
||||||
|
if len(parts) == 5 {
|
||||||
|
args = append(args,
|
||||||
|
"--origin-session", parts[0],
|
||||||
|
"--origin-session-id", parts[1],
|
||||||
|
"--origin-window", parts[2],
|
||||||
|
"--origin-window-id", parts[3],
|
||||||
|
"--origin-pane", parts[4],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return runTmux(args...)
|
return runTmux(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,8 +1109,17 @@ func (s *server) buildStateEnvelope() *ipc.Envelope {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := stateSummary(tasks)
|
activeNotes, archived := s.notesForState()
|
||||||
return &ipc.Envelope{Kind: "state", Visible: &visible, Position: string(pos), Message: msg, Tasks: tasks}
|
msg := stateSummary(tasks, activeNotes, archived)
|
||||||
|
return &ipc.Envelope{
|
||||||
|
Kind: "state",
|
||||||
|
Visible: &visible,
|
||||||
|
Position: string(pos),
|
||||||
|
Message: msg,
|
||||||
|
Tasks: tasks,
|
||||||
|
Notes: activeNotes,
|
||||||
|
Archived: archived,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) addSubscriber(sub *uiSubscriber) {
|
func (s *server) addSubscriber(sub *uiSubscriber) {
|
||||||
|
|
@ -872,6 +1287,11 @@ func socketPath() string {
|
||||||
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notesStorePath() string {
|
||||||
|
base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run")
|
||||||
|
return filepath.Join(base, "notes.json")
|
||||||
|
}
|
||||||
|
|
||||||
func taskKey(sessionID, windowID, paneID string) string {
|
func taskKey(sessionID, windowID, paneID string) string {
|
||||||
return strings.Join([]string{sessionID, windowID, paneID}, "|")
|
return strings.Join([]string{sessionID, windowID, paneID}, "|")
|
||||||
}
|
}
|
||||||
|
|
@ -1011,7 +1431,7 @@ func firstNonEmpty(values ...string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateSummary(tasks []ipc.Task) string {
|
func stateSummary(tasks []ipc.Task, notes []ipc.Note, archived []ipc.Note) string {
|
||||||
inProgress := 0
|
inProgress := 0
|
||||||
waiting := 0
|
waiting := 0
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
|
|
@ -1024,5 +1444,11 @@ func stateSummary(tasks []ipc.Task) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Active %d · Waiting %d · %s", inProgress, waiting, time.Now().Format(time.Kitchen))
|
noteCount := len(notes)
|
||||||
|
archivedCount := len(archived)
|
||||||
|
notePart := fmt.Sprintf("Notes %d", noteCount)
|
||||||
|
if archivedCount > 0 {
|
||||||
|
notePart = fmt.Sprintf("%s (+%d archived)", notePart, archivedCount)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Active %d · Waiting %d · %s · %s", inProgress, waiting, notePart, time.Now().Format(time.Kitchen))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
agent-tracker/deploy
Executable file
10
agent-tracker/deploy
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
go build -o bin/tracker-server ./cmd/tracker-server
|
||||||
|
go build -o bin/tracker-client ./cmd/tracker-client
|
||||||
|
|
||||||
|
./scripts/install_brew_service.sh
|
||||||
|
brew services restart agent-tracker-server
|
||||||
|
|
@ -9,11 +9,15 @@ type Envelope struct {
|
||||||
Window string `json:"window,omitempty"`
|
Window string `json:"window,omitempty"`
|
||||||
WindowID string `json:"window_id,omitempty"`
|
WindowID string `json:"window_id,omitempty"`
|
||||||
Pane string `json:"pane,omitempty"`
|
Pane string `json:"pane,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
NoteID string `json:"note_id,omitempty"`
|
||||||
Position string `json:"position,omitempty"`
|
Position string `json:"position,omitempty"`
|
||||||
Visible *bool `json:"visible,omitempty"`
|
Visible *bool `json:"visible,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
Tasks []Task `json:"tasks,omitempty"`
|
Tasks []Task `json:"tasks,omitempty"`
|
||||||
|
Notes []Note `json:"notes,omitempty"`
|
||||||
|
Archived []Note `json:"archived,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
|
|
@ -30,3 +34,18 @@ type Task struct {
|
||||||
DurationSeconds float64 `json:"duration_seconds"`
|
DurationSeconds float64 `json:"duration_seconds"`
|
||||||
Acknowledged bool `json:"acknowledged"`
|
Acknowledged bool `json:"acknowledged"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Session string `json:"session"`
|
||||||
|
WindowID string `json:"window_id"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
Pane string `json:"pane,omitempty"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
ArchivedAt string `json:"archived_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue