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
|
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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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