mirror of
https://github.com/theniceboy/.config.git
synced 2025-12-26 14:44:57 +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 "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 note_archive_pane --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id}"'
|
||||
|
||||
# -- Project-specific window activation hooks
|
||||
# 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/david/agent-tracker/internal/ipc"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/david/agent-tracker/internal/ipc"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -77,50 +77,50 @@ func (c *trackerClient) sendCommand(ctx context.Context, env ipc.Envelope) error
|
|||
}
|
||||
|
||||
type startInput struct {
|
||||
Summary string `json:"summary"`
|
||||
TmuxID string `json:"tmux_id"`
|
||||
Summary string `json:"summary"`
|
||||
TmuxID string `json:"tmux_id"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
client := newTrackerClient()
|
||||
log.SetFlags(0)
|
||||
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{
|
||||
Name: "tracker_mark_start_working",
|
||||
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) {
|
||||
tmuxID := strings.TrimSpace(input.TmuxID)
|
||||
if tmuxID == "" {
|
||||
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)
|
||||
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."},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "tracker_mark_start_working",
|
||||
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) {
|
||||
tmuxID := strings.TrimSpace(input.TmuxID)
|
||||
if tmuxID == "" {
|
||||
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)
|
||||
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."},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
|
||||
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
@ -134,55 +134,55 @@ type tmuxContext struct {
|
|||
}
|
||||
|
||||
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
|
||||
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")
|
||||
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
||||
return filepath.Join(dir, "agent-tracker.sock")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
||||
}
|
||||
|
||||
// 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.
|
||||
func autodetectContext() (tmuxContext, error) {
|
||||
pane := strings.TrimSpace(os.Getenv("TMUX_PANE"))
|
||||
if pane != "" {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", pane, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||
if err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||
if len(parts) == 3 {
|
||||
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
pane := strings.TrimSpace(os.Getenv("TMUX_PANE"))
|
||||
if pane != "" {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", pane, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||
if err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||
if len(parts) == 3 {
|
||||
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use parent process TTY
|
||||
ppid := os.Getppid()
|
||||
ttyOut, err := exec.Command("ps", "-o", "tty=", "-p", fmt.Sprint(ppid)).CombinedOutput()
|
||||
if err == nil {
|
||||
tty := strings.TrimSpace(string(ttyOut))
|
||||
if tty != "" && tty != "?" {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-c", "/dev/"+tty, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||
if err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||
if len(parts) == 3 {
|
||||
return tmuxContext{SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2])}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: use parent process TTY
|
||||
ppid := os.Getppid()
|
||||
ttyOut, err := exec.Command("ps", "-o", "tty=", "-p", fmt.Sprint(ppid)).CombinedOutput()
|
||||
if err == nil {
|
||||
tty := strings.TrimSpace(string(ttyOut))
|
||||
if tty != "" && tty != "?" {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-c", "/dev/"+tty, "#{session_id}:::#{window_id}:::#{pane_id}").CombinedOutput()
|
||||
if err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ":::")
|
||||
if len(parts) == 3 {
|
||||
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"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -36,6 +37,13 @@ const (
|
|||
statusCompleted = "completed"
|
||||
)
|
||||
|
||||
const (
|
||||
scopePane = "pane"
|
||||
scopeWindow = "window"
|
||||
scopeSession = "session"
|
||||
scopeAll = "all"
|
||||
)
|
||||
|
||||
type taskRecord struct {
|
||||
SessionID string
|
||||
WindowID string
|
||||
|
|
@ -48,6 +56,21 @@ type taskRecord struct {
|
|||
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 {
|
||||
SessionName string
|
||||
SessionID string
|
||||
|
|
@ -68,7 +91,10 @@ type server struct {
|
|||
width int
|
||||
height int
|
||||
tasks map[string]*taskRecord
|
||||
notes map[string]*noteRecord
|
||||
subscribers map[*uiSubscriber]struct{}
|
||||
notesPath string
|
||||
noteCounter uint64
|
||||
}
|
||||
|
||||
func newServer() *server {
|
||||
|
|
@ -78,7 +104,9 @@ func newServer() *server {
|
|||
width: 84,
|
||||
height: 24,
|
||||
tasks: make(map[string]*taskRecord),
|
||||
notes: make(map[string]*noteRecord),
|
||||
subscribers: make(map[*uiSubscriber]struct{}),
|
||||
notesPath: notesStorePath(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +118,9 @@ func main() {
|
|||
}
|
||||
|
||||
func (s *server) run() error {
|
||||
if err := s.loadNotes(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -208,19 +239,19 @@ func (s *server) handleCommand(env ipc.Envelope) error {
|
|||
s.broadcastStateAsync()
|
||||
s.statusRefreshAsync()
|
||||
return nil
|
||||
case "finish_task":
|
||||
target, err := requireSessionWindow(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
note := firstNonEmpty(env.Summary, env.Message)
|
||||
if err := s.finishTask(target, note); err != nil {
|
||||
return err
|
||||
}
|
||||
// s.notifyResponded(target)
|
||||
s.broadcastStateAsync()
|
||||
s.statusRefreshAsync()
|
||||
return nil
|
||||
case "finish_task":
|
||||
target, err := requireSessionWindow(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
note := firstNonEmpty(env.Summary, env.Message)
|
||||
if err := s.finishTask(target, note); err != nil {
|
||||
return err
|
||||
}
|
||||
// s.notifyResponded(target)
|
||||
s.broadcastStateAsync()
|
||||
s.statusRefreshAsync()
|
||||
return nil
|
||||
case "acknowledge":
|
||||
target, err := requireSessionWindow(env)
|
||||
if err != nil {
|
||||
|
|
@ -252,6 +283,69 @@ func (s *server) handleCommand(env ipc.Envelope) error {
|
|||
return err
|
||||
}
|
||||
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:
|
||||
return fmt.Errorf("unknown command %q", env.Command)
|
||||
}
|
||||
|
|
@ -311,6 +405,302 @@ func (s *server) deleteTask(sessionID, windowID, paneID string) error {
|
|||
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) {
|
||||
summary := strings.TrimSpace(s.summaryForTask(target.SessionID, target.WindowID, target.PaneID))
|
||||
if summary == "" {
|
||||
|
|
@ -548,6 +938,10 @@ func (s *server) closeOnClients() 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()
|
||||
x, y, err := s.popupPosition(client, width, height)
|
||||
if err != nil {
|
||||
|
|
@ -567,6 +961,18 @@ func (s *server) openPopup(client string) error {
|
|||
return err
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
|
|
@ -703,8 +1109,17 @@ func (s *server) buildStateEnvelope() *ipc.Envelope {
|
|||
})
|
||||
}
|
||||
|
||||
msg := stateSummary(tasks)
|
||||
return &ipc.Envelope{Kind: "state", Visible: &visible, Position: string(pos), Message: msg, Tasks: tasks}
|
||||
activeNotes, archived := s.notesForState()
|
||||
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) {
|
||||
|
|
@ -872,6 +1287,11 @@ func socketPath() string {
|
|||
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 {
|
||||
return strings.Join([]string{sessionID, windowID, paneID}, "|")
|
||||
}
|
||||
|
|
@ -1011,7 +1431,7 @@ func firstNonEmpty(values ...string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func stateSummary(tasks []ipc.Task) string {
|
||||
func stateSummary(tasks []ipc.Task, notes []ipc.Note, archived []ipc.Note) string {
|
||||
inProgress := 0
|
||||
waiting := 0
|
||||
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"`
|
||||
WindowID string `json:"window_id,omitempty"`
|
||||
Pane string `json:"pane,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
NoteID string `json:"note_id,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"`
|
||||
Notes []Note `json:"notes,omitempty"`
|
||||
Archived []Note `json:"archived,omitempty"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
|
|
@ -30,3 +34,18 @@ type Task struct {
|
|||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
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