feat: report agent status to remote server from hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+10
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user