feat: fix Cursor hook delivery and improve notify workflow

Route hook notifications through /dev/tty and client_tty so OSC reaches
Ghostty when Cursor captures stdout. Add afterAgentResponse hook, hook
logging, debounce, split test modes, and use directory name for titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 12:46:50 +08:00
parent aa9c2b8ea5
commit 021fc3a828
20 changed files with 757 additions and 76 deletions
+19 -3
View File
@@ -6,12 +6,14 @@ import (
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/context"
"github.com/longbin/agent-notify/internal/logx"
"github.com/longbin/agent-notify/internal/notify"
)
type claudePayload struct {
StopHookActive bool `json:"stop_hook_active"`
Message string `json:"message"`
HookEventName string `json:"hook_event_name"`
}
type claudeResponse struct {
@@ -19,13 +21,22 @@ type claudeResponse struct {
}
func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error {
var payload claudePayload
decodeJSON(r, &payload)
hookName := payload.HookEventName
if hookName == "" {
hookName = event
}
logx.Append("hook claude event=%s hook_event_name=%s enabled=%v stop_hook_active=%v",
event, hookName, cfg.EventEnabled(event), payload.StopHookActive)
if !cfg.EventEnabled(event) {
logx.Append("hook claude event=%s skipped (disabled in config)", event)
_, err := io.WriteString(w, "{}\n")
return err
}
var payload claudePayload
_ = json.NewDecoder(r).Decode(&payload)
if event == "stop" && payload.StopHookActive {
logx.Append("hook claude event=stop skipped (stop_hook_active)")
_, err := io.WriteString(w, "{}\n")
return err
}
@@ -37,5 +48,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
resp := claudeResponse{TerminalSequence: seq}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(resp)
if err := enc.Encode(resp); err != nil {
logx.Append("hook claude event=%s encode FAILED: %v", event, err)
return err
}
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title)
return nil
}
+45 -4
View File
@@ -1,24 +1,39 @@
package hook
import (
"encoding/json"
"io"
"os"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/context"
"github.com/longbin/agent-notify/internal/logx"
"github.com/longbin/agent-notify/internal/notify"
"github.com/longbin/agent-notify/internal/tmux"
)
type cursorPayload struct {
WorkspaceRoots []string `json:"workspace_roots"`
HookEventName string `json:"hook_event_name"`
Status string `json:"status"`
}
func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error {
var payload cursorPayload
fromHook := !isInteractiveStdin(r)
decodeJSON(r, &payload)
hookName := payload.HookEventName
if hookName == "" {
hookName = event
}
clientTTY, _ := tmux.ClientTTY()
logx.Append("hook cursor event=%s hook_event_name=%s status=%s enabled=%v from_hook=%v stdout_tty=%v client_tty=%q",
event, hookName, payload.Status, cfg.EventEnabled(event), fromHook, stdoutIsTerminal(), clientTTY)
if !cfg.EventEnabled(event) {
logx.Append("hook cursor event=%s skipped (disabled in config)", event)
return nil
}
var payload cursorPayload
_ = json.NewDecoder(r).Decode(&payload)
meta := context.MetaFromEnv("Cursor", event)
if len(payload.WorkspaceRoots) > 0 {
@@ -26,5 +41,31 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
}
title := context.Render(cfg.Notify.TitleTemplate, meta)
body := cfg.BodyForEvent(event)
return notify.SendAuto(cfg.Notify.Protocol, title, body)
if shouldDebounce(title) {
logx.Append("hook cursor event=%s skipped (debounced duplicate)", event)
return nil
}
var result notify.SendResult
var err error
if fromHook {
result, err = notify.SendForHookWithResult(cfg.Notify.Protocol, title, body)
} else {
result, err = notify.SendAutoWithResult(cfg.Notify.Protocol, title, body)
}
if err != nil {
logx.Append("hook cursor event=%s send FAILED: %v", event, err)
return err
}
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title)
return nil
}
func stdoutIsTerminal() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
+40
View File
@@ -0,0 +1,40 @@
package hook
import (
"os"
"path/filepath"
"strconv"
"time"
)
func debouncePath() string {
return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "debounce")
}
func shouldDebounce(title string) bool {
path := debouncePath()
data, err := os.ReadFile(path)
now := time.Now().UnixNano()
window := int64(2 * time.Second)
var lastTitle string
var lastAt int64
if err == nil {
// format: timestamp\ttitle
for i := 0; i < len(data); i++ {
if data[i] == '\t' {
lastAt, _ = strconv.ParseInt(string(data[:i]), 10, 64)
lastTitle = string(data[i+1:])
break
}
}
}
if lastTitle == title && now-lastAt < window {
return true
}
_ = os.MkdirAll(filepath.Dir(path), 0755)
_ = os.WriteFile(path, []byte(strconv.FormatInt(now, 10)+"\t"+title), 0644)
return false
}
+29
View File
@@ -0,0 +1,29 @@
package hook
import (
"encoding/json"
"io"
"os"
)
func decodeJSON(r io.Reader, v any) {
if isInteractiveStdin(r) {
return
}
_ = json.NewDecoder(r).Decode(v)
}
func isInteractiveStdin(r io.Reader) bool {
file, ok := r.(*os.File)
if !ok {
return false
}
if file != os.Stdin {
return false
}
st, err := file.Stat()
if err != nil {
return false
}
return st.Mode()&os.ModeCharDevice != 0
}
+24
View File
@@ -0,0 +1,24 @@
package hook
import (
"bytes"
"testing"
"github.com/longbin/agent-notify/internal/config"
)
func TestRunCursorEmptyStdinDoesNotBlock(t *testing.T) {
cfg := config.Default()
err := RunCursor(bytes.NewReader(nil), cfg, "stop", nil)
if err != nil {
t.Fatal(err)
}
}
func TestRunCursorWithPayload(t *testing.T) {
cfg := config.Default()
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", nil)
if err != nil {
t.Fatal(err)
}
}