mirror of
https://github.com/theniceboy/.config.git
synced 2026-03-21 12:55:15 +08:00
updated codex tracker
This commit is contained in:
parent
74c2fa6ced
commit
61ace2ef02
6 changed files with 293 additions and 138 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
146
codex/notify.py
Executable file
146
codex/notify.py
Executable file
|
|
@ -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 <NOTIFICATION_JSON>")
|
||||
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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue