diff --git a/agent-tracker/cmd/tracker-client/main.go b/agent-tracker/cmd/tracker-client/main.go index 61fbc8e..3e9c931 100644 --- a/agent-tracker/cmd/tracker-client/main.go +++ b/agent-tracker/cmd/tracker-client/main.go @@ -511,23 +511,33 @@ func runUI(args []string) error { 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 - } + case *tcell.EventKey: + if tev.Key() == tcell.KeyEnter { + if len(tasks) > 0 && selected < len(tasks) { + task := tasks[selected] + // Mark completed tasks as viewed (acknowledged) before focusing + if task.Status == statusCompleted && !task.Acknowledged { + if err := sendCommand("acknowledge", func(env *ipc.Envelope) { + env.SessionID = task.SessionID + env.WindowID = task.WindowID + env.Pane = task.Pane + }); err != nil { + return err + } + } + 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") } diff --git a/agent-tracker/cmd/tracker-mcp/main.go b/agent-tracker/cmd/tracker-mcp/main.go index fbb0137..24492be 100644 --- a/agent-tracker/cmd/tracker-mcp/main.go +++ b/agent-tracker/cmd/tracker-mcp/main.go @@ -1,19 +1,20 @@ package main import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "os" - "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 ( @@ -76,90 +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"` -} - -type finishInput 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 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; 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. 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; 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) - 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 - }) + 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) @@ -173,22 +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 + } + } + } + + // 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") } diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index 00b85f9..c5982ee 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -208,19 +208,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 { diff --git a/codex/AGENTS.md b/codex/AGENTS.md index aa29961..74920b5 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -14,3 +14,7 @@ When you need to call tools from the shell, **use this rubric**: - YAML/XML: `yq` - Use the `python3` command for python. There is no `python` command on this system. +TRACKER INTEGRATION +- Before starting substantive work, call the MCP tool `tracker_mark_start_working` exactly once with: + - `summary`: short description of planned work + - `tmux_id`: the provided TMUX_ID in the form `session_id::window_id::pane_id` diff --git a/codex/config.toml b/codex/config.toml index 4308665..ed3c9af 100644 --- a/codex/config.toml +++ b/codex/config.toml @@ -20,5 +20,6 @@ notify = ["/usr/bin/python3", "/Users/david/.config/codex/notify # cwd = "/Users/david/Github/project-wiki-mcp" # startup_timeout_ms = 20000 -# [mcp_servers.tracker] -# command = "/Users/david/.config/agent-tracker/bin/tracker-mcp" +[mcp_servers.tracker] +command = "/Users/david/.config/agent-tracker/bin/tracker-mcp" +startup_timeout_ms = 20000 diff --git a/codex/notify.py b/codex/notify.py new file mode 100755 index 0000000..a57bdd9 --- /dev/null +++ b/codex/notify.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import json +import os +import shlex +import shutil +import subprocess +import sys + + +def main() -> int: + if len(sys.argv) != 2: + print("Usage: notify.py ") + return 1 + + try: + notification = json.loads(sys.argv[1]) + except json.JSONDecodeError: + return 1 + + if notification.get("type") == "agent-turn-complete": + assistant_message = notification.get("last-assistant-message") + title = "Codex" + subtitle = assistant_message if assistant_message else "Turn Complete!" + input_messages = notification.get("input_messages", []) + # Build body and strip empty/whitespace-only lines for a tighter banner + raw_body = "\n".join(input_messages) + non_empty_lines = [ln for ln in raw_body.splitlines() if ln.strip()] + message = "\n".join(non_empty_lines).strip() + else: + print(f"not sending a push notification for: {notification}") + return 0 + + # Try to discover tmux target of the caller (session/window/pane) + # Prefer TMUX_PANE from env; fall back to parent process TTY. + tmux_path = shutil.which("tmux") or "/opt/homebrew/bin/tmux" + tmux_ids = None + if os.path.exists(tmux_path): + pane = os.environ.get("TMUX_PANE", "").strip() + try: + if pane: + out = subprocess.check_output( + [ + tmux_path, + "display-message", + "-p", + "-t", + pane, + "#{session_id}:::#{window_id}:::#{pane_id}", + ], + text=True, + ).strip() + parts = out.split(":::") + if len(parts) == 3: + tmux_ids = tuple(parts) + except Exception: + tmux_ids = None + + if tmux_ids is None: + try: + ppid = os.getppid() + tty = ( + subprocess.check_output( + ["ps", "-o", "tty=", "-p", str(ppid)], text=True + ) + .strip() + .lstrip("?") + ) + if tty: + out = subprocess.check_output( + [ + tmux_path, + "display-message", + "-p", + "-c", + f"/dev/{tty}", + "#{session_id}:::#{window_id}:::#{pane_id}", + ], + text=True, + ).strip() + parts = out.split(":::") + if len(parts) == 3: + tmux_ids = tuple(parts) + except Exception: + tmux_ids = None + + args = [ + "terminal-notifier", + "-title", + title, + "-subtitle", + subtitle, + "-message", + message, + "-group", + "codex", + "-ignoreDnD", + "-activate", + "com.googlecode.iterm2", + ] + + # Before showing the banner: tell the tracker server we responded, + # attaching the assistant's response text as the completion note. + if tmux_ids is not None and assistant_message: + try: + tracker_bin = shutil.which("tracker-client") + if not tracker_bin: + tracker_bin = os.path.join(os.path.expanduser("~"), ".config", "agent-tracker", "bin", "tracker-client") + if os.path.exists(tracker_bin): + sid, wid, pid = [s.strip() for s in tmux_ids] + subprocess.check_output( + [ + tracker_bin, + "command", + "-session-id", + sid, + "-window-id", + wid, + "-pane", + pid, + "-summary", + assistant_message, + "finish_task", + ], + text=True, + ) + except Exception: + pass + + # On click: focus iTerm2 and switch tmux to the originating pane + if tmux_ids is not None: + sid, wid, pid = [s.strip() for s in tmux_ids] + switch_cmd = ( + f"{shlex.quote(tmux_path)} switch-client -t {shlex.quote(sid)}" + f" && {shlex.quote(tmux_path)} select-window -t {shlex.quote(wid)}" + f" && {shlex.quote(tmux_path)} select-pane -t {shlex.quote(pid)}" + ) + args += ["-execute", "sh -lc " + shlex.quote(switch_cmd)] + + subprocess.check_output(args) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())