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
+192 -20
View File
@@ -8,6 +8,17 @@ import (
"github.com/longbin/agent-notify/internal/tmux"
)
type DeliveryMethod string
const (
MethodDirectStdout DeliveryMethod = "direct-stdout"
MethodPassthroughStdout DeliveryMethod = "passthrough-stdout"
MethodClientTTYRaw DeliveryMethod = "client-tty-raw"
MethodClientTTYPassthrough DeliveryMethod = "client-tty-passthrough"
MethodControllingTTYRaw DeliveryMethod = "controlling-tty-raw"
MethodControllingTTYPassthrough DeliveryMethod = "controlling-tty-passthrough"
)
type SendOptions struct {
Protocol string
Title string
@@ -16,44 +27,185 @@ type SendOptions struct {
InTmux bool
Layers int
ClientTTY string
Method DeliveryMethod
ForHook bool
}
type SendResult struct {
Method DeliveryMethod
}
func autoMethods(inTmux, ssh bool) []DeliveryMethod {
if !inTmux {
return []DeliveryMethod{MethodDirectStdout}
}
if ssh {
return []DeliveryMethod{
MethodPassthroughStdout,
MethodClientTTYPassthrough,
MethodClientTTYRaw,
}
}
return []DeliveryMethod{
MethodClientTTYRaw,
MethodPassthroughStdout,
MethodClientTTYPassthrough,
}
}
// hookMethods avoids stdout when Cursor captures hook output (pipe, not a TTY).
func hookMethods(inTmux, ssh bool, clientTTY string) []DeliveryMethod {
var methods []DeliveryMethod
methods = append(methods, MethodControllingTTYRaw, MethodControllingTTYPassthrough)
if inTmux && clientTTY != "" {
if ssh {
methods = append(methods, MethodClientTTYRaw, MethodClientTTYPassthrough)
} else {
methods = append(methods, MethodClientTTYRaw, MethodClientTTYPassthrough)
}
}
if stdoutIsTerminal() {
if !inTmux {
methods = append(methods, MethodDirectStdout)
} else {
methods = append(methods, MethodPassthroughStdout)
}
}
return methods
}
func stdoutIsTerminal() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func Send(opts SendOptions) error {
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
w := opts.Writer
if w == nil {
w = os.Stdout
}
_, err := SendWithResult(opts)
return err
}
if opts.InTmux && opts.ClientTTY != "" {
f, err := os.OpenFile(opts.ClientTTY, os.O_WRONLY, 0)
if err == nil {
defer f.Close()
_, err = io.WriteString(f, seq)
return err
func SendWithResult(opts SendOptions) (SendResult, error) {
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
methods := []DeliveryMethod{opts.Method}
if opts.Method == "" {
if opts.ForHook {
methods = hookMethods(opts.InTmux, tmux.IsSSHSession(), opts.ClientTTY)
} else {
methods = autoMethods(opts.InTmux, tmux.IsSSHSession())
}
}
out := seq
if opts.InTmux {
var lastErr error
for _, method := range methods {
if err := deliver(seq, opts, method); err != nil {
lastErr = err
continue
}
return SendResult{Method: method}, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no delivery method available")
}
return SendResult{}, lastErr
}
func deliver(seq string, opts SendOptions, method DeliveryMethod) error {
switch method {
case MethodDirectStdout:
return writeOut(seq, opts, seq)
case MethodPassthroughStdout:
layers := opts.Layers
if layers <= 0 {
layers = 1
}
out = tmux.WrapPassthroughLayers(seq, layers)
return writeOut(seq, opts, tmux.WrapPassthroughLayers(seq, layers))
case MethodClientTTYRaw:
return writeClientTTY(opts.ClientTTY, seq)
case MethodClientTTYPassthrough:
layers := opts.Layers
if layers <= 0 {
layers = 1
}
return writeClientTTY(opts.ClientTTY, tmux.WrapPassthroughLayers(seq, layers))
case MethodControllingTTYRaw:
return writeControllingTTY(seq)
case MethodControllingTTYPassthrough:
layers := opts.Layers
if layers <= 0 {
layers = 1
}
return writeControllingTTY(tmux.WrapPassthroughLayers(seq, layers))
default:
return fmt.Errorf("unknown delivery method %q", method)
}
}
func writeOut(_ string, opts SendOptions, out string) error {
w := opts.Writer
if w == nil {
w = os.Stdout
}
_, err := io.WriteString(w, out)
return err
}
func SendAuto(protocol, title, body string) error {
func writeClientTTY(clientTTY, out string) error {
if clientTTY == "" {
return fmt.Errorf("client tty unavailable")
}
f, err := os.OpenFile(clientTTY, os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
_, err = io.WriteString(f, out)
return err
}
func writeControllingTTY(out string) error {
f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
_, err = io.WriteString(f, out)
return err
}
func SendForHookWithResult(protocol, title, body string) (SendResult, error) {
inTmux := tmux.InTmux()
clientTTY, _ := tmux.ClientTTY()
layers := 0
if inTmux {
layers = 1
}
return Send(SendOptions{
return SendWithResult(SendOptions{
Protocol: protocol,
Title: title,
Body: body,
InTmux: inTmux,
Layers: layers,
ClientTTY: clientTTY,
ForHook: true,
})
}
func SendAuto(protocol, title, body string) error {
_, err := SendAutoWithResult(protocol, title, body)
return err
}
func SendAutoWithResult(protocol, title, body string) (SendResult, error) {
inTmux := tmux.InTmux()
clientTTY, _ := tmux.ClientTTY()
layers := 0
if inTmux {
layers = 1
}
return SendWithResult(SendOptions{
Protocol: protocol,
Title: title,
Body: body,
@@ -63,9 +215,29 @@ func SendAuto(protocol, title, body string) error {
})
}
func TestNotification(title, body string) error {
if err := SendAuto("osc777", title, body); err != nil {
return fmt.Errorf("send test notification: %w", err)
func EmitSequence(seq string) (SendResult, error) {
inTmux := tmux.InTmux()
clientTTY, _ := tmux.ClientTTY()
layers := 0
if inTmux {
layers = 1
}
return nil
opts := SendOptions{
InTmux: inTmux,
Layers: layers,
ClientTTY: clientTTY,
}
methods := autoMethods(inTmux, tmux.IsSSHSession())
var lastErr error
for _, method := range methods {
if err := deliver(seq, opts, method); err != nil {
lastErr = err
continue
}
return SendResult{Method: method}, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no delivery method available")
}
return SendResult{}, lastErr
}
+34
View File
@@ -0,0 +1,34 @@
package notify
import "testing"
func TestHookMethodsSkipStdoutWhenNotTTY(t *testing.T) {
methods := hookMethods(true, true, "/dev/pts/1")
if len(methods) < 2 {
t.Fatalf("expected hook methods, got %v", methods)
}
if methods[0] != MethodControllingTTYRaw {
t.Fatalf("expected controlling tty first, got %v", methods)
}
for _, m := range methods {
if m == MethodPassthroughStdout || m == MethodDirectStdout {
t.Fatalf("stdout methods should not appear without terminal stdout, got %v", methods)
}
}
}
func TestHookMethodsIncludeStdoutWhenTTY(t *testing.T) {
if !stdoutIsTerminal() {
t.Skip("stdout is not a terminal in test runner")
}
methods := hookMethods(true, false, "/dev/pts/1")
found := false
for _, m := range methods {
if m == MethodPassthroughStdout {
found = true
}
}
if !found {
t.Fatalf("expected passthrough stdout in manual mode, got %v", methods)
}
}
+1
View File
@@ -32,6 +32,7 @@ func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) {
InTmux: true,
Layers: 1,
ClientTTY: "",
Method: MethodPassthroughStdout,
})
if err != nil {
t.Fatal(err)
+111
View File
@@ -0,0 +1,111 @@
package notify
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/longbin/agent-notify/internal/tmux"
)
const defaultTestTitle = "agent-notify"
const defaultTestBody = "测试通知 — 如果你看到这条,说明配置正确"
type claudeTestResponse struct {
TerminalSequence string `json:"terminalSequence"`
}
func TestCursor(title, body string, tryAll bool) (SendResult, error) {
if title == "" {
title = defaultTestTitle
}
if body == "" {
body = defaultTestBody + " [cursor]"
}
if tryAll {
return testCursorAll(title, body)
}
result, err := SendAutoWithResult("osc777", title, body)
printTestStatus("cursor", result, err)
return result, err
}
func testCursorAll(title, body string) (SendResult, error) {
inTmux := tmux.InTmux()
methods := []DeliveryMethod{MethodDirectStdout, MethodPassthroughStdout, MethodClientTTYRaw, MethodClientTTYPassthrough}
if !inTmux {
methods = []DeliveryMethod{MethodDirectStdout}
}
clientTTY, _ := tmux.ClientTTY()
var lastResult SendResult
var lastErr error
for i, method := range methods {
result, err := SendWithResult(SendOptions{
Protocol: "osc777",
Title: title,
Body: fmt.Sprintf("%s [%s]", body, method),
InTmux: inTmux,
Layers: 1,
ClientTTY: clientTTY,
Method: method,
})
if err != nil {
fmt.Fprintf(os.Stderr, "try %d/%d %s: FAIL %v\n", i+1, len(methods), method, err)
lastErr = err
continue
}
fmt.Fprintf(os.Stderr, "try %d/%d %s: OK (check Ghostty notification)\n", i+1, len(methods), method)
lastResult = result
}
if lastResult.Method == "" && lastErr != nil {
return lastResult, lastErr
}
fmt.Fprintf(os.Stderr, "mode=cursor try-all done ssh=%v tmux=%v\n", tmux.IsSSHSession(), inTmux)
return lastResult, nil
}
func printTestStatus(mode string, result SendResult, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "agent-notify: %s test FAILED: %v\n", mode, err)
return
}
fmt.Fprintf(os.Stderr,
"agent-notify: %s test sent via %s (ssh=%v tmux=%v) — check Ghostty desktop notification\n",
mode, result.Method, tmux.IsSSHSession(), tmux.InTmux(),
)
}
func TestClaude(title, body string, apply bool, out io.Writer) (SendResult, error) {
if title == "" {
title = defaultTestTitle
}
if body == "" {
body = defaultTestBody + " [claude]"
}
seq := BuildSequence("osc777", title, body)
if out == nil {
out = os.Stdout
}
if !apply {
resp := claudeTestResponse{TerminalSequence: seq}
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
if err := enc.Encode(resp); err != nil {
return SendResult{}, err
}
fmt.Fprintln(os.Stderr, "agent-notify: claude test JSON printed (use --apply to emit)")
return SendResult{Method: "terminal-sequence-json"}, nil
}
result, err := EmitSequence(seq)
printTestStatus("claude", result, err)
return result, err
}
func TestNotification(title, body string) error {
_, err := TestCursor(title, body, false)
return err
}
+49
View File
@@ -0,0 +1,49 @@
package notify
import (
"bytes"
"strings"
"testing"
"github.com/longbin/agent-notify/internal/tmux"
)
func TestAutoMethodsSSHPrefersPassthrough(t *testing.T) {
methods := autoMethods(true, true)
if methods[0] != MethodPassthroughStdout {
t.Fatalf("expected passthrough first over ssh, got %v", methods)
}
}
func TestAutoMethodsLocalPrefersClientTTY(t *testing.T) {
methods := autoMethods(true, false)
if methods[0] != MethodClientTTYRaw {
t.Fatalf("expected client tty first locally, got %v", methods)
}
}
func TestTestClaudeJSON(t *testing.T) {
var out bytes.Buffer
_, err := TestClaude("t", "b", false, &out)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "terminalSequence") {
t.Fatalf("expected json output, got %q", out.String())
}
}
func TestEmitSequenceDirect(t *testing.T) {
t.Setenv("TMUX", "")
t.Setenv("SSH_CONNECTION", "")
_ = tmux.InTmux()
seq := BuildSequence("osc777", "t", "b")
var buf bytes.Buffer
opts := SendOptions{Writer: &buf, InTmux: false}
if err := deliver(seq, opts, MethodDirectStdout); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "777;notify") {
t.Fatalf("unexpected output %q", buf.String())
}
}