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:
@@ -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
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package tmux
|
||||
|
||||
import "os"
|
||||
|
||||
func IsSSHSession() bool {
|
||||
return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != ""
|
||||
}
|
||||
Reference in New Issue
Block a user