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
+14 -9
View File
@@ -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. 无 tmuxGhostty 直接)
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. 远程 tmuxSSH 到远程后在 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
```
## 工作原理
+77 -6
View File
@@ -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 <command>
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
`)
}
+7 -4
View File
@@ -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":
+5 -21
View File
@@ -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,
}
}
+7 -7
View File
@@ -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")
}
}
+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)
}
}
+4 -2
View File
@@ -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)
+4
View File
@@ -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) {
+59
View File
@@ -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
}
+29
View File
@@ -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)
}
}
+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())
}
}
+7
View File
@@ -0,0 +1,7 @@
package tmux
import "os"
func IsSSHSession() bool {
return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != ""
}