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
|
```bash
|
||||||
agent-notify install --all
|
agent-notify install --all
|
||||||
agent-notify doctor
|
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`
|
配置文件:`~/.config/agent-notify/config.toml`
|
||||||
@@ -42,7 +46,7 @@ tool = false # shell/工具执行结束
|
|||||||
|
|
||||||
[notify]
|
[notify]
|
||||||
protocol = "osc777"
|
protocol = "osc777"
|
||||||
title_template = "{agent} — {context}"
|
title_template = "{agent} — {context}" # context = 工作目录名
|
||||||
body_stop = "等待输入"
|
body_stop = "等待输入"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,7 +56,8 @@ body_stop = "等待输入"
|
|||||||
agent-notify send --event stop
|
agent-notify send --event stop
|
||||||
agent-notify hook cursor stop # Cursor CLI stop hook
|
agent-notify hook cursor stop # Cursor CLI stop hook
|
||||||
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON)
|
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 doctor
|
||||||
agent-notify install --all [--force]
|
agent-notify install --all [--force]
|
||||||
```
|
```
|
||||||
@@ -68,17 +73,17 @@ agent-notify install --all [--force]
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 无 tmux(Ghostty 直接)
|
# 1. 无 tmux(Ghostty 直接)
|
||||||
agent-notify test
|
agent-notify test cursor
|
||||||
|
|
||||||
# 2. 本地 tmux
|
# 2. 本地 tmux
|
||||||
tmux new-session -d 'agent-notify test'
|
tmux new-session -d 'agent-notify test cursor -v'
|
||||||
|
|
||||||
# 3. 远程 tmux(SSH 到远程后在 tmux 内)
|
# 3. 远程 tmux(SSH 到远程后在 tmux 内)
|
||||||
agent-notify test
|
agent-notify test cursor -v
|
||||||
|
# 应看到 delivery=passthrough-stdout
|
||||||
|
|
||||||
# 4. 嵌套 tmux(本地 tmux → SSH → 远程 tmux)
|
# 4. Claude 路径
|
||||||
# 确保两层 tmux 都设置了 allow-passthrough on
|
agent-notify test claude --apply
|
||||||
agent-notify test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/longbin/agent-notify/internal/context"
|
"github.com/longbin/agent-notify/internal/context"
|
||||||
"github.com/longbin/agent-notify/internal/hook"
|
"github.com/longbin/agent-notify/internal/hook"
|
||||||
"github.com/longbin/agent-notify/internal/install"
|
"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/notify"
|
||||||
"github.com/longbin/agent-notify/internal/tmux"
|
"github.com/longbin/agent-notify/internal/tmux"
|
||||||
)
|
)
|
||||||
@@ -33,9 +34,11 @@ func run(cmd string, args []string) error {
|
|||||||
case "install":
|
case "install":
|
||||||
return cmdInstall(args)
|
return cmdInstall(args)
|
||||||
case "test":
|
case "test":
|
||||||
return cmdTest()
|
return cmdTest(args)
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return cmdDoctor()
|
return cmdDoctor()
|
||||||
|
case "logs":
|
||||||
|
return cmdLogs(args)
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
printUsage()
|
printUsage()
|
||||||
return nil
|
return nil
|
||||||
@@ -80,7 +83,7 @@ func cmdHook(args []string) error {
|
|||||||
case "claude":
|
case "claude":
|
||||||
return hook.RunClaude(os.Stdin, cfg, event, os.Stdout)
|
return hook.RunClaude(os.Stdin, cfg, event, os.Stdout)
|
||||||
default:
|
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")
|
return fmt.Errorf("use --all")
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdTest() error {
|
func cmdTest(args []string) error {
|
||||||
return notify.TestNotification("agent-notify", "测试通知 — 如果你看到这条,说明配置正确")
|
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 {
|
func cmdDoctor() error {
|
||||||
fmt.Println("agent-notify doctor")
|
fmt.Println("agent-notify doctor")
|
||||||
if tmux.InTmux() {
|
if tmux.InTmux() {
|
||||||
fmt.Println("✓ running inside tmux")
|
fmt.Println("✓ running inside tmux")
|
||||||
|
if tmux.IsSSHSession() {
|
||||||
|
fmt.Println("✓ SSH session detected (prefers passthrough delivery)")
|
||||||
|
}
|
||||||
ok, val, err := tmux.AllowPassthroughEnabled()
|
ok, val, err := tmux.AllowPassthroughEnabled()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ allow-passthrough check failed: %v\n", err)
|
fmt.Printf("✗ allow-passthrough check failed: %v\n", err)
|
||||||
@@ -118,6 +186,7 @@ func cmdDoctor() error {
|
|||||||
}
|
}
|
||||||
cfgPath := config.DefaultPath()
|
cfgPath := config.DefaultPath()
|
||||||
fmt.Printf(" config=%s\n", cfgPath)
|
fmt.Printf(" config=%s\n", cfgPath)
|
||||||
|
fmt.Printf(" hook_log=%s\n", logx.Path())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +194,12 @@ func printUsage() {
|
|||||||
fmt.Fprint(os.Stderr, `Usage: agent-notify <command>
|
fmt.Fprint(os.Stderr, `Usage: agent-notify <command>
|
||||||
Commands:
|
Commands:
|
||||||
send [--title T] [--body B] [--event stop|idle|tool]
|
send [--title T] [--body B] [--event stop|idle|tool]
|
||||||
hook cursor stop|tool
|
hook cursor stop|response|tool
|
||||||
hook claude stop|idle
|
hook claude stop|idle
|
||||||
install [--all] [--force]
|
install [--all] [--force]
|
||||||
test
|
test cursor [--try-all]
|
||||||
|
test claude [--apply]
|
||||||
|
logs [--tail 30]
|
||||||
doctor
|
doctor
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
|
|
||||||
type Events struct {
|
type Events struct {
|
||||||
Stop bool `toml:"stop"`
|
Stop bool `toml:"stop"`
|
||||||
|
Response bool `toml:"response"`
|
||||||
Idle bool `toml:"idle"`
|
Idle bool `toml:"idle"`
|
||||||
Tool bool `toml:"tool"`
|
Tool bool `toml:"tool"`
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,7 @@ type Notify struct {
|
|||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Events: Events{Stop: true, Idle: false, Tool: false},
|
Events: Events{Stop: true, Response: true, Idle: false, Tool: false},
|
||||||
Notify: Notify{
|
Notify: Notify{
|
||||||
Protocol: "osc777",
|
Protocol: "osc777",
|
||||||
TitleTemplate: "{agent} — {context}",
|
TitleTemplate: "{agent} — {context}",
|
||||||
@@ -66,6 +67,8 @@ func (c Config) EventEnabled(event string) bool {
|
|||||||
switch strings.ToLower(event) {
|
switch strings.ToLower(event) {
|
||||||
case "stop":
|
case "stop":
|
||||||
return c.Events.Stop
|
return c.Events.Stop
|
||||||
|
case "response":
|
||||||
|
return c.Events.Response
|
||||||
case "idle":
|
case "idle":
|
||||||
return c.Events.Idle
|
return c.Events.Idle
|
||||||
case "tool":
|
case "tool":
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package context
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -15,16 +14,13 @@ type Meta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Render(tmpl string, m Meta) string {
|
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(tmpl, "{agent}", m.Agent)
|
||||||
out = strings.ReplaceAll(out, "{context}", ctx)
|
out = strings.ReplaceAll(out, "{context}", ctx)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Meta) ResolveContext(window string) string {
|
func (m Meta) ResolveContext() string {
|
||||||
if window != "" {
|
|
||||||
return window
|
|
||||||
}
|
|
||||||
if m.Context != "" {
|
if m.Context != "" {
|
||||||
return m.Context
|
return m.Context
|
||||||
}
|
}
|
||||||
@@ -38,17 +34,6 @@ func (m Meta) ResolveContext(window string) string {
|
|||||||
return filepath.Base(cwd)
|
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 {
|
func MetaFromEnv(agent, event string) Meta {
|
||||||
cwd := os.Getenv("AGENT_NOTIFY_CWD")
|
cwd := os.Getenv("AGENT_NOTIFY_CWD")
|
||||||
if cwd == "" {
|
if cwd == "" {
|
||||||
@@ -63,7 +48,6 @@ func MetaFromEnv(agent, event string) Meta {
|
|||||||
return Meta{
|
return Meta{
|
||||||
Agent: agent,
|
Agent: agent,
|
||||||
CWD: cwd,
|
CWD: cwd,
|
||||||
Context: TmuxWindowName(),
|
|
||||||
Event: event,
|
Event: event,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package context
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestRenderTitle(t *testing.T) {
|
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)
|
got := Render("{agent} — {context}", meta)
|
||||||
want := "Cursor — myapp"
|
want := "Cursor — myapp"
|
||||||
if got != want {
|
if got != want {
|
||||||
@@ -13,14 +13,14 @@ func TestRenderTitle(t *testing.T) {
|
|||||||
|
|
||||||
func TestContextFromCWD(t *testing.T) {
|
func TestContextFromCWD(t *testing.T) {
|
||||||
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
|
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
|
||||||
if meta.ResolveContext("") != "myapp" {
|
if meta.ResolveContext() != "myapp" {
|
||||||
t.Fatalf("got %q", meta.ResolveContext(""))
|
t.Fatalf("got %q", meta.ResolveContext())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContextPrefersWindow(t *testing.T) {
|
func TestContextExplicitOverride(t *testing.T) {
|
||||||
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
|
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp", Context: "custom"}
|
||||||
if meta.ResolveContext("tmux-win") != "tmux-win" {
|
if meta.ResolveContext() != "custom" {
|
||||||
t.Fatalf("expected window name")
|
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/config"
|
||||||
"github.com/longbin/agent-notify/internal/context"
|
"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/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
type claudePayload struct {
|
type claudePayload struct {
|
||||||
StopHookActive bool `json:"stop_hook_active"`
|
StopHookActive bool `json:"stop_hook_active"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
HookEventName string `json:"hook_event_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type claudeResponse struct {
|
type claudeResponse struct {
|
||||||
@@ -19,13 +21,22 @@ type claudeResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error {
|
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) {
|
if !cfg.EventEnabled(event) {
|
||||||
|
logx.Append("hook claude event=%s skipped (disabled in config)", event)
|
||||||
_, err := io.WriteString(w, "{}\n")
|
_, err := io.WriteString(w, "{}\n")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var payload claudePayload
|
|
||||||
_ = json.NewDecoder(r).Decode(&payload)
|
|
||||||
if event == "stop" && payload.StopHookActive {
|
if event == "stop" && payload.StopHookActive {
|
||||||
|
logx.Append("hook claude event=stop skipped (stop_hook_active)")
|
||||||
_, err := io.WriteString(w, "{}\n")
|
_, err := io.WriteString(w, "{}\n")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -37,5 +48,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
|
|||||||
resp := claudeResponse{TerminalSequence: seq}
|
resp := claudeResponse{TerminalSequence: seq}
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
enc.SetEscapeHTML(false)
|
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
|
package hook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/longbin/agent-notify/internal/config"
|
"github.com/longbin/agent-notify/internal/config"
|
||||||
"github.com/longbin/agent-notify/internal/context"
|
"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/notify"
|
||||||
|
"github.com/longbin/agent-notify/internal/tmux"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cursorPayload struct {
|
type cursorPayload struct {
|
||||||
WorkspaceRoots []string `json:"workspace_roots"`
|
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 {
|
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) {
|
if !cfg.EventEnabled(event) {
|
||||||
|
logx.Append("hook cursor event=%s skipped (disabled in config)", event)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var payload cursorPayload
|
|
||||||
_ = json.NewDecoder(r).Decode(&payload)
|
|
||||||
|
|
||||||
meta := context.MetaFromEnv("Cursor", event)
|
meta := context.MetaFromEnv("Cursor", event)
|
||||||
if len(payload.WorkspaceRoots) > 0 {
|
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)
|
title := context.Render(cfg.Notify.TitleTemplate, meta)
|
||||||
body := cfg.BodyForEvent(event)
|
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"
|
"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 cursorToolCmd = "agent-notify hook cursor tool"
|
||||||
const claudeStopCmd = "agent-notify hook claude stop"
|
const claudeStopCmd = "agent-notify hook claude stop"
|
||||||
const claudeIdleCmd = "agent-notify hook claude idle"
|
const claudeIdleCmd = "agent-notify hook claude idle"
|
||||||
@@ -31,7 +32,8 @@ func MergeCursorHooks(path string, force bool) error {
|
|||||||
hooks = map[string]any{}
|
hooks = map[string]any{}
|
||||||
doc["hooks"] = hooks
|
doc["hooks"] = hooks
|
||||||
}
|
}
|
||||||
addHook(hooks, "stop", cursorHookCmd, force)
|
addHook(hooks, "stop", cursorStopCmd, force)
|
||||||
|
addHook(hooks, "afterAgentResponse", cursorResponseCmd, force)
|
||||||
cfg, _ := config.LoadDefault()
|
cfg, _ := config.LoadDefault()
|
||||||
if cfg.Events.Tool {
|
if cfg.Events.Tool {
|
||||||
addHook(hooks, "afterShellExecution", cursorToolCmd, force)
|
addHook(hooks, "afterShellExecution", cursorToolCmd, force)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ func TestMergeCursorHooks(t *testing.T) {
|
|||||||
if len(stop) != 1 {
|
if len(stop) != 1 {
|
||||||
t.Fatalf("expected stop hook added")
|
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) {
|
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"
|
"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 {
|
type SendOptions struct {
|
||||||
Protocol string
|
Protocol string
|
||||||
Title string
|
Title string
|
||||||
@@ -16,44 +27,185 @@ type SendOptions struct {
|
|||||||
InTmux bool
|
InTmux bool
|
||||||
Layers int
|
Layers int
|
||||||
ClientTTY string
|
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 {
|
func Send(opts SendOptions) error {
|
||||||
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
|
_, err := SendWithResult(opts)
|
||||||
w := opts.Writer
|
|
||||||
if w == nil {
|
|
||||||
w = os.Stdout
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
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
|
var lastErr error
|
||||||
if opts.InTmux {
|
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
|
layers := opts.Layers
|
||||||
if layers <= 0 {
|
if layers <= 0 {
|
||||||
layers = 1
|
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)
|
_, err := io.WriteString(w, out)
|
||||||
return err
|
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()
|
inTmux := tmux.InTmux()
|
||||||
clientTTY, _ := tmux.ClientTTY()
|
clientTTY, _ := tmux.ClientTTY()
|
||||||
layers := 0
|
layers := 0
|
||||||
if inTmux {
|
if inTmux {
|
||||||
layers = 1
|
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,
|
Protocol: protocol,
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
@@ -63,9 +215,29 @@ func SendAuto(protocol, title, body string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotification(title, body string) error {
|
func EmitSequence(seq string) (SendResult, error) {
|
||||||
if err := SendAuto("osc777", title, body); err != nil {
|
inTmux := tmux.InTmux()
|
||||||
return fmt.Errorf("send test notification: %w", err)
|
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,
|
InTmux: true,
|
||||||
Layers: 1,
|
Layers: 1,
|
||||||
ClientTTY: "",
|
ClientTTY: "",
|
||||||
|
Method: MethodPassthroughStdout,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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