feat: report agent status to remote server from hooks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:35:30 +08:00
parent 6802a432d7
commit 8c43a96817
4 changed files with 163 additions and 5 deletions
+5 -1
View File
@@ -53,6 +53,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
return err return err
} }
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title) 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 return nil
} }
+10 -4
View File
@@ -12,9 +12,11 @@ import (
) )
type cursorPayload struct { type cursorPayload struct {
WorkspaceRoots []string `json:"workspace_roots"` WorkspaceRoots []string `json:"workspace_roots"`
HookEventName string `json:"hook_event_name"` HookEventName string `json:"hook_event_name"`
Status string `json:"status"` Status string `json:"status"`
ConversationID string `json:"conversation_id"`
TranscriptPath string `json:"transcript_path"`
} }
var ( var (
@@ -64,7 +66,11 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
return err return err
} }
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title) 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 return nil
} }
+69
View File
@@ -4,11 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/longbin/agent-notify/internal/config" "github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox" "github.com/longbin/agent-notify/internal/inbox"
"github.com/longbin/agent-notify/internal/remote"
) )
func TestCursorStopHookDisabled(t *testing.T) { func TestCursorStopHookDisabled(t *testing.T) {
@@ -153,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app
appendInboxRecord = prevAppend 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)
}
}
+79
View File
@@ -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"
}
}