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:
+192
-20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) {
|
||||
InTmux: true,
|
||||
Layers: 1,
|
||||
ClientTTY: "",
|
||||
Method: MethodPassthroughStdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user