From 021fc3a82897c5434c58a3d44dd96eaa9d91c948 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 26 May 2026 12:46:50 +0800 Subject: [PATCH] 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 --- README.md | 23 ++-- cmd/agent-notify/main.go | 83 +++++++++++- internal/config/config.go | 11 +- internal/context/meta.go | 26 +--- internal/context/meta_test.go | 14 +- internal/hook/claude.go | 22 +++- internal/hook/cursor.go | 49 ++++++- internal/hook/debounce.go | 40 ++++++ internal/hook/stdin.go | 29 ++++ internal/hook/stdin_test.go | 24 ++++ internal/install/install.go | 6 +- internal/install/install_test.go | 4 + internal/logx/log.go | 59 +++++++++ internal/logx/log_test.go | 29 ++++ internal/notify/send.go | 212 +++++++++++++++++++++++++++--- internal/notify/send_hook_test.go | 34 +++++ internal/notify/send_test.go | 1 + internal/notify/test.go | 111 ++++++++++++++++ internal/notify/test_test.go | 49 +++++++ internal/tmux/env.go | 7 + 20 files changed, 757 insertions(+), 76 deletions(-) create mode 100644 internal/hook/debounce.go create mode 100644 internal/hook/stdin.go create mode 100644 internal/hook/stdin_test.go create mode 100644 internal/logx/log.go create mode 100644 internal/logx/log_test.go create mode 100644 internal/notify/send_hook_test.go create mode 100644 internal/notify/test.go create mode 100644 internal/notify/test_test.go create mode 100644 internal/tmux/env.go diff --git a/README.md b/README.md index 54c030e..7dd9e3a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,11 @@ go install ./cmd/agent-notify ```bash agent-notify install --all agent-notify doctor -agent-notify test + +# 两种测试路径(与 hook 发送方式一致) +agent-notify test cursor # 始终会在 stderr 打印发送结果 +agent-notify test cursor -v # 同上(-v 保留兼容) +agent-notify test cursor --try-all # 逐个尝试所有投递方式(调试用) ``` 配置文件:`~/.config/agent-notify/config.toml` @@ -42,7 +46,7 @@ tool = false # shell/工具执行结束 [notify] protocol = "osc777" -title_template = "{agent} — {context}" +title_template = "{agent} — {context}" # context = 工作目录名 body_stop = "等待输入" ``` @@ -52,7 +56,8 @@ body_stop = "等待输入" agent-notify send --event stop agent-notify hook cursor stop # Cursor CLI stop hook agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON) -agent-notify test +agent-notify test cursor [-v] +agent-notify test claude [--apply] agent-notify doctor agent-notify install --all [--force] ``` @@ -68,17 +73,17 @@ agent-notify install --all [--force] ```bash # 1. 无 tmux(Ghostty 直接) -agent-notify test +agent-notify test cursor # 2. 本地 tmux -tmux new-session -d 'agent-notify test' +tmux new-session -d 'agent-notify test cursor -v' # 3. 远程 tmux(SSH 到远程后在 tmux 内) -agent-notify test +agent-notify test cursor -v +# 应看到 delivery=passthrough-stdout -# 4. 嵌套 tmux(本地 tmux → SSH → 远程 tmux) -# 确保两层 tmux 都设置了 allow-passthrough on -agent-notify test +# 4. Claude 路径 +agent-notify test claude --apply ``` ## 工作原理 diff --git a/cmd/agent-notify/main.go b/cmd/agent-notify/main.go index c01b5f8..53441b0 100644 --- a/cmd/agent-notify/main.go +++ b/cmd/agent-notify/main.go @@ -9,6 +9,7 @@ import ( "github.com/longbin/agent-notify/internal/context" "github.com/longbin/agent-notify/internal/hook" "github.com/longbin/agent-notify/internal/install" + "github.com/longbin/agent-notify/internal/logx" "github.com/longbin/agent-notify/internal/notify" "github.com/longbin/agent-notify/internal/tmux" ) @@ -33,9 +34,11 @@ func run(cmd string, args []string) error { case "install": return cmdInstall(args) case "test": - return cmdTest() + return cmdTest(args) case "doctor": return cmdDoctor() + case "logs": + return cmdLogs(args) case "help", "-h", "--help": printUsage() return nil @@ -80,7 +83,7 @@ func cmdHook(args []string) error { case "claude": return hook.RunClaude(os.Stdin, cfg, event, os.Stdout) default: - return fmt.Errorf("unknown agent %q", agent) + return fmt.Errorf("unknown agent %q (use cursor stop|response|tool or claude stop|idle)", agent) } } @@ -95,14 +98,79 @@ func cmdInstall(args []string) error { return fmt.Errorf("use --all") } -func cmdTest() error { - return notify.TestNotification("agent-notify", "测试通知 — 如果你看到这条,说明配置正确") +func cmdTest(args []string) error { + mode := "cursor" + apply := false + tryAll := false + + for _, arg := range args { + switch arg { + case "-v", "--verbose": + // accepted for compatibility; status always prints to stderr now + case "--apply": + apply = true + case "--try-all": + tryAll = true + case "cursor", "claude": + mode = arg + case "help", "-h", "--help": + fmt.Fprint(os.Stderr, `Usage: agent-notify test [cursor|claude] [flags] + +Flags: + --apply Claude only: emit terminalSequence to terminal + --try-all Cursor only: try every delivery method (debug) + +Examples: + agent-notify test cursor + agent-notify test cursor -v + agent-notify test cursor --try-all + agent-notify test claude --apply +`) + return nil + default: + return fmt.Errorf("unknown test argument %q (try: agent-notify test help)", arg) + } + } + + switch mode { + case "cursor": + _, err := notify.TestCursor("", "", tryAll) + return err + case "claude": + _, err := notify.TestClaude("", "", apply, os.Stdout) + return err + default: + return fmt.Errorf("usage: agent-notify test [cursor|claude] [--apply] [--try-all]") + } +} + +func cmdLogs(args []string) error { + fs := flag.NewFlagSet("logs", flag.ExitOnError) + tail := fs.Int("tail", 30, "number of recent lines") + _ = fs.Parse(args) + + lines, err := logx.Tail(*tail) + if err != nil { + return err + } + if len(lines) == 0 { + fmt.Printf("no log entries yet (log file: %s)\n", logx.Path()) + return nil + } + fmt.Printf("# %s\n", logx.Path()) + for _, line := range lines { + fmt.Println(line) + } + return nil } func cmdDoctor() error { fmt.Println("agent-notify doctor") if tmux.InTmux() { fmt.Println("✓ running inside tmux") + if tmux.IsSSHSession() { + fmt.Println("✓ SSH session detected (prefers passthrough delivery)") + } ok, val, err := tmux.AllowPassthroughEnabled() if err != nil { fmt.Printf("✗ allow-passthrough check failed: %v\n", err) @@ -118,6 +186,7 @@ func cmdDoctor() error { } cfgPath := config.DefaultPath() fmt.Printf(" config=%s\n", cfgPath) + fmt.Printf(" hook_log=%s\n", logx.Path()) return nil } @@ -125,10 +194,12 @@ func printUsage() { fmt.Fprint(os.Stderr, `Usage: agent-notify Commands: send [--title T] [--body B] [--event stop|idle|tool] - hook cursor stop|tool + hook cursor stop|response|tool hook claude stop|idle install [--all] [--force] - test + test cursor [--try-all] + test claude [--apply] + logs [--tail 30] doctor `) } diff --git a/internal/config/config.go b/internal/config/config.go index d860aed..0f87cdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,9 +14,10 @@ type Config struct { } type Events struct { - Stop bool `toml:"stop"` - Idle bool `toml:"idle"` - Tool bool `toml:"tool"` + Stop bool `toml:"stop"` + Response bool `toml:"response"` + Idle bool `toml:"idle"` + Tool bool `toml:"tool"` } type Notify struct { @@ -29,7 +30,7 @@ type Notify struct { func Default() Config { return Config{ - Events: Events{Stop: true, Idle: false, Tool: false}, + Events: Events{Stop: true, Response: true, Idle: false, Tool: false}, Notify: Notify{ Protocol: "osc777", TitleTemplate: "{agent} — {context}", @@ -66,6 +67,8 @@ func (c Config) EventEnabled(event string) bool { switch strings.ToLower(event) { case "stop": return c.Events.Stop + case "response": + return c.Events.Response case "idle": return c.Events.Idle case "tool": diff --git a/internal/context/meta.go b/internal/context/meta.go index 7ab1739..f507e4a 100644 --- a/internal/context/meta.go +++ b/internal/context/meta.go @@ -2,7 +2,6 @@ package context import ( "os" - "os/exec" "path/filepath" "strings" ) @@ -15,16 +14,13 @@ type Meta struct { } func Render(tmpl string, m Meta) string { - ctx := m.ResolveContext(m.Context) + ctx := m.ResolveContext() out := strings.ReplaceAll(tmpl, "{agent}", m.Agent) out = strings.ReplaceAll(out, "{context}", ctx) return out } -func (m Meta) ResolveContext(window string) string { - if window != "" { - return window - } +func (m Meta) ResolveContext() string { if m.Context != "" { return m.Context } @@ -38,17 +34,6 @@ func (m Meta) ResolveContext(window string) string { return filepath.Base(cwd) } -func TmuxWindowName() string { - if os.Getenv("TMUX") == "" { - return "" - } - out, err := exec.Command("tmux", "display-message", "-p", "#{window_name}").Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - func MetaFromEnv(agent, event string) Meta { cwd := os.Getenv("AGENT_NOTIFY_CWD") if cwd == "" { @@ -61,9 +46,8 @@ func MetaFromEnv(agent, event string) Meta { event = e } return Meta{ - Agent: agent, - CWD: cwd, - Context: TmuxWindowName(), - Event: event, + Agent: agent, + CWD: cwd, + Event: event, } } diff --git a/internal/context/meta_test.go b/internal/context/meta_test.go index 42cb2a8..ba2b427 100644 --- a/internal/context/meta_test.go +++ b/internal/context/meta_test.go @@ -3,7 +3,7 @@ package context import "testing" func TestRenderTitle(t *testing.T) { - meta := Meta{Agent: "Cursor", Context: "myapp"} + meta := Meta{Agent: "Cursor", CWD: "/home/user/code/myapp"} got := Render("{agent} — {context}", meta) want := "Cursor — myapp" if got != want { @@ -13,14 +13,14 @@ func TestRenderTitle(t *testing.T) { func TestContextFromCWD(t *testing.T) { meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"} - if meta.ResolveContext("") != "myapp" { - t.Fatalf("got %q", meta.ResolveContext("")) + if meta.ResolveContext() != "myapp" { + t.Fatalf("got %q", meta.ResolveContext()) } } -func TestContextPrefersWindow(t *testing.T) { - meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"} - if meta.ResolveContext("tmux-win") != "tmux-win" { - t.Fatalf("expected window name") +func TestContextExplicitOverride(t *testing.T) { + meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp", Context: "custom"} + if meta.ResolveContext() != "custom" { + t.Fatalf("expected explicit context override") } } diff --git a/internal/hook/claude.go b/internal/hook/claude.go index 501094d..5d11d68 100644 --- a/internal/hook/claude.go +++ b/internal/hook/claude.go @@ -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 } diff --git a/internal/hook/cursor.go b/internal/hook/cursor.go index dfc0e79..effcfce 100644 --- a/internal/hook/cursor.go +++ b/internal/hook/cursor.go @@ -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 } diff --git a/internal/hook/debounce.go b/internal/hook/debounce.go new file mode 100644 index 0000000..db46027 --- /dev/null +++ b/internal/hook/debounce.go @@ -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 +} diff --git a/internal/hook/stdin.go b/internal/hook/stdin.go new file mode 100644 index 0000000..f617590 --- /dev/null +++ b/internal/hook/stdin.go @@ -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 +} diff --git a/internal/hook/stdin_test.go b/internal/hook/stdin_test.go new file mode 100644 index 0000000..0254629 --- /dev/null +++ b/internal/hook/stdin_test.go @@ -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) + } +} diff --git a/internal/install/install.go b/internal/install/install.go index 48daa44..c435167 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -8,7 +8,8 @@ import ( "github.com/longbin/agent-notify/internal/config" ) -const cursorHookCmd = "agent-notify hook cursor stop" +const cursorStopCmd = "agent-notify hook cursor stop" +const cursorResponseCmd = "agent-notify hook cursor response" const cursorToolCmd = "agent-notify hook cursor tool" const claudeStopCmd = "agent-notify hook claude stop" const claudeIdleCmd = "agent-notify hook claude idle" @@ -31,7 +32,8 @@ func MergeCursorHooks(path string, force bool) error { hooks = map[string]any{} doc["hooks"] = hooks } - addHook(hooks, "stop", cursorHookCmd, force) + addHook(hooks, "stop", cursorStopCmd, force) + addHook(hooks, "afterAgentResponse", cursorResponseCmd, force) cfg, _ := config.LoadDefault() if cfg.Events.Tool { addHook(hooks, "afterShellExecution", cursorToolCmd, force) diff --git a/internal/install/install_test.go b/internal/install/install_test.go index 2cd2cf6..a0d60a2 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -24,6 +24,10 @@ func TestMergeCursorHooks(t *testing.T) { if len(stop) != 1 { t.Fatalf("expected stop hook added") } + resp := hooks["afterAgentResponse"].([]any) + if len(resp) != 1 { + t.Fatalf("expected afterAgentResponse hook added") + } } func TestMergeCursorHooksNoOverwrite(t *testing.T) { diff --git a/internal/logx/log.go b/internal/logx/log.go new file mode 100644 index 0000000..4d60bea --- /dev/null +++ b/internal/logx/log.go @@ -0,0 +1,59 @@ +package logx + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +func Path() string { + return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "hook.log") +} + +func Append(format string, args ...any) { + path := Path() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(f, "[%s] %s\n", time.Now().Format(time.RFC3339), msg) +} + +func Tail(n int) ([]string, error) { + data, err := os.ReadFile(Path()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + lines := splitLines(string(data)) + if n <= 0 || n >= len(lines) { + return lines, nil + } + return lines[len(lines)-n:], nil +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + line := s[start:i] + if line != "" { + lines = append(lines, line) + } + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/internal/logx/log_test.go b/internal/logx/log_test.go new file mode 100644 index 0000000..5bfc7fd --- /dev/null +++ b/internal/logx/log_test.go @@ -0,0 +1,29 @@ +package logx + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAppendAndTail(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + Append("hello %s", "world") + lines, err := Tail(10) + if err != nil { + t.Fatal(err) + } + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + if lines[0] == "" { + t.Fatal("empty line") + } + + path := filepath.Join(dir, ".local", "state", "agent-notify", "hook.log") + if _, err := os.Stat(path); err != nil { + t.Fatalf("log file not created: %v", err) + } +} diff --git a/internal/notify/send.go b/internal/notify/send.go index 78b19e1..a0785db 100644 --- a/internal/notify/send.go +++ b/internal/notify/send.go @@ -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 } diff --git a/internal/notify/send_hook_test.go b/internal/notify/send_hook_test.go new file mode 100644 index 0000000..49e03af --- /dev/null +++ b/internal/notify/send_hook_test.go @@ -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) + } +} diff --git a/internal/notify/send_test.go b/internal/notify/send_test.go index 561ce75..ec26903 100644 --- a/internal/notify/send_test.go +++ b/internal/notify/send_test.go @@ -32,6 +32,7 @@ func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) { InTmux: true, Layers: 1, ClientTTY: "", + Method: MethodPassthroughStdout, }) if err != nil { t.Fatal(err) diff --git a/internal/notify/test.go b/internal/notify/test.go new file mode 100644 index 0000000..8a3f69e --- /dev/null +++ b/internal/notify/test.go @@ -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 +} diff --git a/internal/notify/test_test.go b/internal/notify/test_test.go new file mode 100644 index 0000000..fa1a411 --- /dev/null +++ b/internal/notify/test_test.go @@ -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()) + } +} diff --git a/internal/tmux/env.go b/internal/tmux/env.go new file mode 100644 index 0000000..3565549 --- /dev/null +++ b/internal/tmux/env.go @@ -0,0 +1,7 @@ +package tmux + +import "os" + +func IsSSHSession() bool { + return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" +}