From 8c43a9681732d1184ca30d801292afa0cfa5e52f Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 2 Jun 2026 14:35:30 +0800 Subject: [PATCH] feat: report agent status to remote server from hooks Co-authored-by: Cursor --- internal/hook/claude.go | 6 ++- internal/hook/cursor.go | 14 +++++-- internal/hook/hook_test.go | 69 +++++++++++++++++++++++++++++++++ internal/hook/remote.go | 79 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 internal/hook/remote.go diff --git a/internal/hook/claude.go b/internal/hook/claude.go index 054a990..2822408 100644 --- a/internal/hook/claude.go +++ b/internal/hook/claude.go @@ -53,6 +53,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error return err } logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title) - recordInbox(cfg, "Claude", event, meta.CWD, title, body) + if cfg.Remote.Enabled { + reportRemoteHook(cfg, "Claude", event, meta.CWD, "", "") + } else if cfg.Inbox.Enabled { + recordInbox(cfg, "Claude", event, meta.CWD, title, body) + } return nil } diff --git a/internal/hook/cursor.go b/internal/hook/cursor.go index 44bf635..7a3dbd8 100644 --- a/internal/hook/cursor.go +++ b/internal/hook/cursor.go @@ -12,9 +12,11 @@ import ( ) type cursorPayload struct { - WorkspaceRoots []string `json:"workspace_roots"` - HookEventName string `json:"hook_event_name"` - Status string `json:"status"` + WorkspaceRoots []string `json:"workspace_roots"` + HookEventName string `json:"hook_event_name"` + Status string `json:"status"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` } var ( @@ -64,7 +66,11 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error return err } logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title) - recordInbox(cfg, "Cursor", event, meta.CWD, title, body) + if cfg.Remote.Enabled { + reportRemoteHook(cfg, "Cursor", event, meta.CWD, payload.TranscriptPath, payload.ConversationID) + } else if cfg.Inbox.Enabled { + recordInbox(cfg, "Cursor", event, meta.CWD, title, body) + } return nil } diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index 70315f6..31b71f1 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -4,11 +4,14 @@ import ( "bytes" "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" "testing" "github.com/longbin/agent-notify/internal/config" "github.com/longbin/agent-notify/internal/inbox" + "github.com/longbin/agent-notify/internal/remote" ) func TestCursorStopHookDisabled(t *testing.T) { @@ -153,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app appendInboxRecord = prevAppend }) } + +func stubReportRemote(t *testing.T, fn func(config.Config, string, string, string, string, string)) { + t.Helper() + prev := reportRemoteHook + reportRemoteHook = fn + t.Cleanup(func() { reportRemoteHook = prev }) +} + +func TestCursorHookReportsRemoteOnStop(t *testing.T) { + stubCursorSend(t) + var got remote.StatusReport + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/status" { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + http.Error(w, "decode", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := config.Default() + cfg.Remote.Enabled = true + cfg.Remote.URL = srv.URL + err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"],"conversation_id":"conv-1","transcript_path":"/tmp/t.jsonl"}`)), cfg, "stop", &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if got.Agent != "Cursor" || got.Event != "stop" || got.Status != "waiting" || got.CWD != "/tmp/proj" { + t.Fatalf("unexpected report: %+v", got) + } + if got.ConversationID != "conv-1" { + t.Fatalf("conversation_id: got %q", got.ConversationID) + } +} + +func TestCursorHookSkipsRemoteWhenDisabled(t *testing.T) { + stubCursorSend(t) + stubReportRemote(t, func(config.Config, string, string, string, string, string) { + t.Fatal("reportRemote should not run when remote disabled") + }) + + cfg := config.Default() + cfg.Remote.Enabled = false + if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil { + t.Fatal(err) + } +} + +func TestCursorHookRemoteReportFailureStillOK(t *testing.T) { + stubCursorSend(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "fail", http.StatusInternalServerError) + })) + defer srv.Close() + + cfg := config.Default() + cfg.Remote.Enabled = true + cfg.Remote.URL = srv.URL + if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil { + t.Fatalf("hook should return nil on remote failure, got %v", err) + } +} diff --git a/internal/hook/remote.go b/internal/hook/remote.go new file mode 100644 index 0000000..422abc4 --- /dev/null +++ b/internal/hook/remote.go @@ -0,0 +1,79 @@ +package hook + +import ( + "context" + "strings" + "time" + + "github.com/longbin/agent-notify/internal/config" + "github.com/longbin/agent-notify/internal/hostmeta" + "github.com/longbin/agent-notify/internal/logx" + "github.com/longbin/agent-notify/internal/remote" + "github.com/longbin/agent-notify/internal/transcript" +) + +var reportRemoteHook = reportRemote + +func reportRemote(cfg config.Config, agent, event, cwd string, transcriptPath, conversationID string) { + if !cfg.Remote.Enabled || cfg.Remote.URL == "" { + return + } + + hostname, err := hostmeta.Hostname() + if err != nil { + logx.Append("remote report hostname: %v", err) + hostname = "" + } + ips := hostmeta.IPs() + + var lastUser, lastAgent string + if transcriptPath != "" { + lastUser, lastAgent, err = transcript.LastMessages(transcriptPath, 2000) + if err != nil { + logx.Append("remote report transcript: %v", err) + } + } + + timeout := time.Duration(cfg.Remote.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 2 * time.Second + } + report := remote.StatusReport{ + Hostname: hostname, + IPs: ips, + Agent: agent, + CWD: cwd, + Status: eventToStatus(event), + Event: event, + ConversationID: conversationID, + LastUser: lastUser, + LastAgent: lastAgent, + UpdatedAt: time.Now().UTC(), + } + + client := remote.NewClient(remote.ClientConfig{ + URL: cfg.Remote.URL, + Token: cfg.Remote.Token, + Timeout: timeout, + }) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := client.Report(ctx, report); err != nil { + logx.Append("remote report failed: %v", err) + } +} + +func eventToStatus(event string) string { + switch strings.ToLower(event) { + case "stop": + return "waiting" + case "response": + return "running" + case "tool": + return "tool" + case "idle": + return "idle" + default: + return "waiting" + } +}