diff --git a/README.md b/README.md new file mode 100644 index 0000000..54c030e --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# agent-notify + +在 tmux(含 SSH 远程)中运行 Cursor CLI 与 Claude Code 时,通过 Agent hook 触发 Ghostty 桌面通知(OSC 777)。 + +**支持:** Cursor CLI(`cursor-agent`)、Claude Code +**不支持:** Cursor IDE + +## 前置条件 + +- [Ghostty](https://ghostty.org/) 终端(支持 OSC 777) +- tmux 3.2+(若在 tmux 内使用) + +在每一层 tmux 的 `~/.tmux.conf` 中添加: + +```tmux +set -g allow-passthrough on +``` + +## 安装 + +```bash +make install +# 或 +go install ./cmd/agent-notify +``` + +## 配置 Hook + +```bash +agent-notify install --all +agent-notify doctor +agent-notify test +``` + +配置文件:`~/.config/agent-notify/config.toml` + +```toml +[events] +stop = true # Agent 完成回复,等待输入 +idle = false # Claude 空闲 60s+(Notification hook) +tool = false # shell/工具执行结束 + +[notify] +protocol = "osc777" +title_template = "{agent} — {context}" +body_stop = "等待输入" +``` + +## 命令 + +```bash +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 doctor +agent-notify install --all [--force] +``` + +## Hook 配置位置 + +| Agent | 配置文件 | Hook 事件 | +|-------|---------|-----------| +| Cursor CLI | `~/.cursor/hooks.json` | `stop`, `afterShellExecution`(tool 开启时) | +| Claude Code | `~/.claude/settings.json` | `Stop`, `Notification`(idle 开启时) | + +## 手动测试矩阵 + +```bash +# 1. 无 tmux(Ghostty 直接) +agent-notify test + +# 2. 本地 tmux +tmux new-session -d 'agent-notify test' + +# 3. 远程 tmux(SSH 到远程后在 tmux 内) +agent-notify test + +# 4. 嵌套 tmux(本地 tmux → SSH → 远程 tmux) +# 确保两层 tmux 都设置了 allow-passthrough on +agent-notify test +``` + +## 工作原理 + +1. Agent hook 调用 `agent-notify hook ...` +2. CLI 生成 OSC 777 序列:`\033]777;notify;标题;正文\007` +3. 在 tmux 内优先写入 `client_tty`,否则用 DCS passthrough 透传 +4. Claude Code 通过 hook JSON 的 `terminalSequence` 字段输出 OSC(hook 进程无 TTY) + +## 开发 + +```bash +make test +make build +``` diff --git a/bin/agent-notify b/bin/agent-notify new file mode 100755 index 0000000..991fd42 Binary files /dev/null and b/bin/agent-notify differ diff --git a/cmd/agent-notify/main.go b/cmd/agent-notify/main.go index 6168bde..c01b5f8 100644 --- a/cmd/agent-notify/main.go +++ b/cmd/agent-notify/main.go @@ -1,8 +1,16 @@ package main import ( + "flag" "fmt" "os" + + "github.com/longbin/agent-notify/internal/config" + "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/notify" + "github.com/longbin/agent-notify/internal/tmux" ) func main() { @@ -10,23 +18,117 @@ func main() { printUsage() os.Exit(1) } - switch os.Args[1] { - case "send", "hook", "install", "test", "doctor", "help", "-h", "--help": - fmt.Fprintf(os.Stderr, "agent-notify: %s not implemented yet\n", os.Args[1]) - os.Exit(1) - default: - printUsage() + if err := run(os.Args[1], os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "agent-notify:", err) os.Exit(1) } } +func run(cmd string, args []string) error { + switch cmd { + case "send": + return cmdSend(args) + case "hook": + return cmdHook(args) + case "install": + return cmdInstall(args) + case "test": + return cmdTest() + case "doctor": + return cmdDoctor() + case "help", "-h", "--help": + printUsage() + return nil + default: + printUsage() + return fmt.Errorf("unknown command %q", cmd) + } +} + +func cmdSend(args []string) error { + fs := flag.NewFlagSet("send", flag.ExitOnError) + title := fs.String("title", "", "notification title") + body := fs.String("body", "", "notification body") + event := fs.String("event", "stop", "event type") + _ = fs.Parse(args) + cfg, err := config.LoadDefault() + if err != nil { + return err + } + if *title == "" { + meta := context.MetaFromEnv("Agent", *event) + *title = context.Render(cfg.Notify.TitleTemplate, meta) + } + if *body == "" { + *body = cfg.BodyForEvent(*event) + } + return notify.SendAuto(cfg.Notify.Protocol, *title, *body) +} + +func cmdHook(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: agent-notify hook ") + } + agent, event := args[0], args[1] + cfg, err := config.LoadDefault() + if err != nil { + return err + } + switch agent { + case "cursor": + return hook.RunCursor(os.Stdin, cfg, event, os.Stdout) + case "claude": + return hook.RunClaude(os.Stdin, cfg, event, os.Stdout) + default: + return fmt.Errorf("unknown agent %q", agent) + } +} + +func cmdInstall(args []string) error { + fs := flag.NewFlagSet("install", flag.ExitOnError) + all := fs.Bool("all", false, "install all") + force := fs.Bool("force", false, "overwrite existing hooks") + _ = fs.Parse(args) + if *all || len(args) == 0 { + return install.InstallAll(*force) + } + return fmt.Errorf("use --all") +} + +func cmdTest() error { + return notify.TestNotification("agent-notify", "测试通知 — 如果你看到这条,说明配置正确") +} + +func cmdDoctor() error { + fmt.Println("agent-notify doctor") + if tmux.InTmux() { + fmt.Println("✓ running inside tmux") + ok, val, err := tmux.AllowPassthroughEnabled() + if err != nil { + fmt.Printf("✗ allow-passthrough check failed: %v\n", err) + } else if ok { + fmt.Printf("✓ allow-passthrough=%s\n", val) + } else { + fmt.Printf("✗ allow-passthrough=%q — add to ~/.tmux.conf: set -g allow-passthrough on\n", val) + } + tty, _ := tmux.ClientTTY() + fmt.Printf(" client_tty=%s\n", tty) + } else { + fmt.Println(" not in tmux (direct Ghostty mode)") + } + cfgPath := config.DefaultPath() + fmt.Printf(" config=%s\n", cfgPath) + return nil +} + func printUsage() { fmt.Fprint(os.Stderr, `Usage: agent-notify Commands: - send Send a notification - hook Agent hook entrypoint - install Install hook configs - test Send test notification - doctor Check environment + send [--title T] [--body B] [--event stop|idle|tool] + hook cursor stop|tool + hook claude stop|idle + install [--all] [--force] + test + doctor `) } diff --git a/docs/superpowers/plans/2026-05-26-agent-notify.md b/docs/superpowers/plans/2026-05-26-agent-notify.md new file mode 100644 index 0000000..151e911 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-agent-notify.md @@ -0,0 +1,1411 @@ +# agent-notify Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Go CLI that sends Ghostty desktop notifications via OSC 777 through tmux passthrough, with installable hooks for Cursor CLI and Claude Code. + +**Architecture:** Single Go binary with focused internal packages: `config` (TOML), `notify` (OSC encode/send), `tmux` (layer detection + passthrough), `context` (title/body templates), `hook` (Cursor/Claude stdin adapters), `install` (merge hook JSON). CLI subcommands wired in `cmd/agent-notify/main.go`. + +**Tech Stack:** Go 1.22+, `github.com/BurntSushi/toml`, standard library only otherwise. + +**Spec:** `docs/superpowers/specs/2026-05-26-agent-notify-design.md` + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `go.mod` | Module definition | +| `cmd/agent-notify/main.go` | CLI entry, subcommand dispatch | +| `internal/config/config.go` | Load/merge TOML defaults | +| `internal/config/config_test.go` | Config parsing tests | +| `internal/notify/osc.go` | Build OSC 777/9 byte sequences | +| `internal/notify/osc_test.go` | OSC sequence tests | +| `internal/notify/send.go` | Write OSC to stdout/client_tty/DCS | +| `internal/notify/send_test.go` | Send logic tests (mock writer) | +| `internal/tmux/tmux.go` | Detect tmux, client_tty, passthrough wrap | +| `internal/tmux/tmux_test.go` | Passthrough wrapping tests | +| `internal/context/meta.go` | Resolve agent/cwd/window, render templates | +| `internal/context/meta_test.go` | Template rendering tests | +| `internal/hook/cursor.go` | Parse Cursor hook stdin, call send | +| `internal/hook/claude.go` | Parse Claude hook stdin, emit terminalSequence JSON | +| `internal/hook/hook_test.go` | Hook handler tests | +| `internal/install/install.go` | Write config + merge hooks.json/settings.json | +| `internal/install/install_test.go` | Merge logic tests | +| `Makefile` | build, test, install targets | +| `README.md` | Usage, tmux setup, hook install | + +--- + +### Task 1: Project Bootstrap + +**Files:** +- Create: `go.mod` +- Create: `cmd/agent-notify/main.go` +- Create: `Makefile` + +- [ ] **Step 1: Initialize Go module** + +```bash +cd /home/longbin/code/agent-notify +go mod init github.com/longbin/agent-notify +``` + +- [ ] **Step 2: Create minimal main with subcommand stub** + +Create `cmd/agent-notify/main.go`: + +```go +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + switch os.Args[1] { + case "send", "hook", "install", "test", "doctor", "help", "-h", "--help": + fmt.Fprintf(os.Stderr, "agent-notify: %s not implemented yet\n", os.Args[1]) + os.Exit(1) + default: + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Fprint(os.Stderr, `Usage: agent-notify +Commands: + send Send a notification + hook Agent hook entrypoint + install Install hook configs + test Send test notification + doctor Check environment +`) +} +``` + +- [ ] **Step 3: Create Makefile** + +```makefile +.PHONY: build test install +BINARY := agent-notify + +build: + go build -o bin/$(BINARY) ./cmd/agent-notify + +test: + go test ./... + +install: build + install -m 755 bin/$(BINARY) $(HOME)/.local/bin/$(BINARY) +``` + +- [ ] **Step 4: Verify build** + +Run: `go build -o bin/agent-notify ./cmd/agent-notify` +Expected: succeeds, no output + +- [ ] **Step 5: Commit** + +```bash +git init +git add go.mod cmd/agent-notify/main.go Makefile +git commit -m "chore: bootstrap agent-notify Go project" +``` + +--- + +### Task 2: Config Package + +**Files:** +- Create: `internal/config/config.go` +- Create: `internal/config/config_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/config/config_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := Default() + if !cfg.Events.Stop { + t.Fatal("expected stop=true by default") + } + if cfg.Events.Idle { + t.Fatal("expected idle=false by default") + } + if cfg.Notify.Protocol != "osc777" { + t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol) + } +} + +func TestLoadFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := ` +[events] +stop = false +tool = true + +[notify] +body_stop = "custom stop" +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Events.Stop { + t.Fatal("expected stop=false") + } + if !cfg.Events.Tool { + t.Fatal("expected tool=true") + } + if cfg.Notify.BodyStop != "custom stop" { + t.Fatalf("got %q", cfg.Notify.BodyStop) + } +} + +func TestEventEnabled(t *testing.T) { + cfg := Default() + cfg.Events.Stop = false + if cfg.EventEnabled("stop") { + t.Fatal("expected stop disabled") + } + if !cfg.EventEnabled("STOP") { + t.Fatal("expected case-insensitive match") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/... -v` +Expected: FAIL — package/config not defined + +- [ ] **Step 3: Implement config** + +```bash +go get github.com/BurntSushi/toml +``` + +Create `internal/config/config.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Events Events `toml:"events"` + Notify Notify `toml:"notify"` +} + +type Events struct { + Stop bool `toml:"stop"` + Idle bool `toml:"idle"` + Tool bool `toml:"tool"` +} + +type Notify struct { + Protocol string `toml:"protocol"` + TitleTemplate string `toml:"title_template"` + BodyStop string `toml:"body_stop"` + BodyIdle string `toml:"body_idle"` + BodyTool string `toml:"body_tool"` +} + +func Default() Config { + return Config{ + Events: Events{Stop: true, Idle: false, Tool: false}, + Notify: Notify{ + Protocol: "osc777", + TitleTemplate: "{agent} — {context}", + BodyStop: "等待输入", + BodyIdle: "空闲 60s+,等待输入", + BodyTool: "工具执行完成", + }, + } +} + +func DefaultPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml") +} + +func Load(path string) (Config, error) { + cfg := Default() + if path == "" { + return cfg, nil + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return cfg, nil + } + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func LoadDefault() (Config, error) { + return Load(DefaultPath()) +} + +func (c Config) EventEnabled(event string) bool { + switch strings.ToLower(event) { + case "stop": + return c.Events.Stop + case "idle": + return c.Events.Idle + case "tool": + return c.Events.Tool + default: + return false + } +} + +func (c Config) BodyForEvent(event string) string { + switch strings.ToLower(event) { + case "idle": + return c.Notify.BodyIdle + case "tool": + return c.Notify.BodyTool + default: + return c.Notify.BodyStop + } +} + +func (c Config) WriteDefault(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if os.IsExist(err) { + return nil + } + return err + } + defer f.Close() + return toml.NewEncoder(f).Encode(Default()) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/config/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/config go.mod go.sum +git commit -m "feat: add TOML config with defaults and event toggles" +``` + +--- + +### Task 3: OSC Sequence Builder + +**Files:** +- Create: `internal/notify/osc.go` +- Create: `internal/notify/osc_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/notify/osc_test.go`: + +```go +package notify + +import "testing" + +func TestBuildOSC777(t *testing.T) { + seq := BuildSequence("osc777", "Claude — proj", "等待输入") + want := "\033]777;notify;Claude — proj;等待输入\007" + if seq != want { + t.Fatalf("got %q want %q", seq, want) + } +} + +func TestBuildOSC9(t *testing.T) { + seq := BuildSequence("osc9", "ignored", "等待输入") + want := "\033]9;等待输入\007" + if seq != want { + t.Fatalf("got %q want %q", seq, want) + } +} + +func TestSemicolonInTitleEscaped(t *testing.T) { + seq := BuildSequence("osc777", "a;b", "body") + if seq != "\033]777;notify;a\\;b;body\007" { + t.Fatalf("unexpected escape: %q", seq) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/notify/... -run TestBuild -v` +Expected: FAIL + +- [ ] **Step 3: Implement OSC builder** + +Create `internal/notify/osc.go`: + +```go +package notify + +import "strings" + +func BuildSequence(protocol, title, body string) string { + switch protocol { + case "osc9": + return "\033]9;" + escapeOSCField(body) + "\007" + default: + return "\033]777;notify;" + escapeOSCField(title) + ";" + escapeOSCField(body) + "\007" + } +} + +func escapeOSCField(s string) string { + return strings.ReplaceAll(s, ";", "\\;") +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/notify/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/osc.go internal/notify/osc_test.go +git commit -m "feat: add OSC 777/9 sequence builder" +``` + +--- + +### Task 4: tmux Passthrough Layer + +**Files:** +- Create: `internal/tmux/tmux.go` +- Create: `internal/tmux/tmux_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/tmux/tmux_test.go`: + +```go +package tmux + +import "testing" + +func TestInTmux(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-123,1,0") + if !InTmux() { + t.Fatal("expected InTmux true") + } +} + +func TestWrapPassthroughSingle(t *testing.T) { + inner := "\033]777;notify;t;b\007" + got := WrapPassthrough(inner) + want := "\033Ptmux;\033" + inner + "\033\\" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestWrapPassthroughNested(t *testing.T) { + inner := "\033]777;notify;t;b\007" + got := WrapPassthroughLayers(inner, 2) + once := WrapPassthrough(inner) + twice := WrapPassthrough(once) + if got != twice { + t.Fatalf("nested wrap mismatch") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/tmux/... -v` +Expected: FAIL + +- [ ] **Step 3: Implement tmux helpers** + +Create `internal/tmux/tmux.go`: + +```go +package tmux + +import ( + "os" + "os/exec" + "strings" +) + +func InTmux() bool { + return os.Getenv("TMUX") != "" +} + +func TmuxLayerCount() int { + if !InTmux() { + return 0 + } + // Each TMUX env var in nested attach typically appears once per server attach chain. + // Conservative: count commas segments; fallback 1 if in tmux. + parts := strings.Split(os.Getenv("TMUX"), ",") + if len(parts) >= 1 { + return 1 + } + return 0 +} + +func ClientTTY() (string, error) { + if !InTmux() { + return "", nil + } + out, err := exec.Command("tmux", "display-message", "-p", "#{client_tty}").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func WrapPassthrough(seq string) string { + return "\033Ptmux;\033" + seq + "\033\\" +} + +func WrapPassthroughLayers(seq string, layers int) string { + out := seq + for i := 0; i < layers; i++ { + out = WrapPassthrough(out) + } + return out +} + +func AllowPassthroughEnabled() (bool, string, error) { + if !InTmux() { + return true, "", nil + } + out, err := exec.Command("tmux", "show-option", "-gv", "allow-passthrough").Output() + if err != nil { + return false, "", err + } + val := strings.TrimSpace(string(out)) + return val == "on" || val == "all", val, nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/tmux/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/tmux +git commit -m "feat: add tmux passthrough and client_tty helpers" +``` + +--- + +### Task 5: Notification Sender + +**Files:** +- Create: `internal/notify/send.go` +- Create: `internal/notify/send_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/notify/send_test.go`: + +```go +package notify + +import ( + "bytes" + "testing" +) + +func TestSendDirect(t *testing.T) { + var buf bytes.Buffer + err := Send(SendOptions{ + Protocol: "osc777", + Title: "Cursor — app", + Body: "等待输入", + Writer: &buf, + InTmux: false, + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("777;notify")) { + t.Fatalf("missing osc777: %q", buf.String()) + } +} + +func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) { + var buf bytes.Buffer + err := Send(SendOptions{ + Protocol: "osc777", + Title: "t", + Body: "b", + Writer: &buf, + InTmux: true, + Layers: 1, + ClientTTY: "", + }) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(buf.Bytes(), []byte("\033Ptmux;")) { + t.Fatalf("expected passthrough prefix, got %q", buf.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/notify/... -run TestSend -v` +Expected: FAIL + +- [ ] **Step 3: Implement sender** + +Create `internal/notify/send.go`: + +```go +package notify + +import ( + "fmt" + "io" + "os" + + "github.com/longbin/agent-notify/internal/tmux" +) + +type SendOptions struct { + Protocol string + Title string + Body string + Writer io.Writer + InTmux bool + Layers int + ClientTTY string +} + +func Send(opts SendOptions) error { + seq := BuildSequence(opts.Protocol, opts.Title, opts.Body) + 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 + } + } + + out := seq + if opts.InTmux { + layers := opts.Layers + if layers <= 0 { + layers = 1 + } + out = tmux.WrapPassthroughLayers(seq, layers) + } + _, err := io.WriteString(w, out) + return err +} + +func SendAuto(protocol, title, body string) error { + inTmux := tmux.InTmux() + clientTTY, _ := tmux.ClientTTY() + return Send(SendOptions{ + Protocol: protocol, + Title: title, + Body: body, + InTmux: inTmux, + Layers: tmuxLayerCountSafe(), + ClientTTY: clientTTY, + }) +} + +func tmuxLayerCountSafe() int { + if !tmux.InTmux() { + return 0 + } + return 1 +} + +func TestNotification(title, body string) error { + if err := SendAuto("osc777", title, body); err != nil { + return fmt.Errorf("send test notification: %w", err) + } + return nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/notify/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/send.go internal/notify/send_test.go +git commit -m "feat: send OSC via stdout, client_tty, or tmux passthrough" +``` + +--- + +### Task 6: Context / Template Rendering + +**Files:** +- Create: `internal/context/meta.go` +- Create: `internal/context/meta_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/context/meta_test.go`: + +```go +package context + +import "testing" + +func TestRenderTitle(t *testing.T) { + meta := Meta{Agent: "Cursor", Context: "myapp"} + got := Render("{agent} — {context}", meta) + want := "Cursor — myapp" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestContextFromCWD(t *testing.T) { + meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"} + 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") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/context/... -v` +Expected: FAIL + +- [ ] **Step 3: Implement meta** + +Create `internal/context/meta.go`: + +```go +package context + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Meta struct { + Agent string + CWD string + Context string + Event string +} + +func Render(tmpl string, m Meta) string { + ctx := m.ResolveContext(m.Context) + 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 + } + if m.Context != "" { + return m.Context + } + cwd := m.CWD + if cwd == "" { + cwd, _ = os.Getwd() + } + if cwd == "" { + return "unknown" + } + 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 == "" { + cwd, _ = os.Getwd() + } + if a := os.Getenv("AGENT_NOTIFY_AGENT"); a != "" { + agent = a + } + if e := os.Getenv("AGENT_NOTIFY_EVENT"); e != "" { + event = e + } + return Meta{ + Agent: agent, + CWD: cwd, + Context: TmuxWindowName(), + Event: event, + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/context/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/context +git commit -m "feat: resolve notification title context from tmux or cwd" +``` + +--- + +### Task 7: Hook Handlers (Cursor + Claude) + +**Files:** +- Create: `internal/hook/cursor.go` +- Create: `internal/hook/claude.go` +- Create: `internal/hook/hook_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/hook/hook_test.go`: + +```go +package hook + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/longbin/agent-notify/internal/config" +) + +func TestCursorStopHookDisabled(t *testing.T) { + cfg := config.Default() + cfg.Events.Stop = false + err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } +} + +func TestClaudeStopOutputsTerminalSequence(t *testing.T) { + cfg := config.Default() + var out bytes.Buffer + err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out) + if err != nil { + t.Fatal(err) + } + var resp map[string]string + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !strings.Contains(resp["terminalSequence"], "777;notify") { + t.Fatalf("bad sequence: %v", resp) + } +} + +func TestClaudeStopHookActiveSkips(t *testing.T) { + cfg := config.Default() + var out bytes.Buffer + err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out.String()) != "{}" { + t.Fatalf("expected {}, got %q", out.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/hook/... -v` +Expected: FAIL + +- [ ] **Step 3: Implement Cursor hook** + +Create `internal/hook/cursor.go`: + +```go +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/notify" +) + +type cursorPayload struct { + WorkspaceRoots []string `json:"workspace_roots"` +} + +func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error { + if !cfg.EventEnabled(event) { + return nil + } + var payload cursorPayload + _ = json.NewDecoder(r).Decode(&payload) + + meta := context.MetaFromEnv("Cursor", event) + if len(payload.WorkspaceRoots) > 0 { + meta.CWD = payload.WorkspaceRoots[0] + } + title := context.Render(cfg.Notify.TitleTemplate, meta) + body := cfg.BodyForEvent(event) + return notify.SendAuto(cfg.Notify.Protocol, title, body) +} +``` + +- [ ] **Step 4: Implement Claude hook** + +Create `internal/hook/claude.go`: + +```go +package hook + +import ( + "encoding/json" + "io" + + "github.com/longbin/agent-notify/internal/config" + "github.com/longbin/agent-notify/internal/context" + "github.com/longbin/agent-notify/internal/notify" +) + +type claudePayload struct { + StopHookActive bool `json:"stop_hook_active"` + Message string `json:"message"` +} + +type claudeResponse struct { + TerminalSequence string `json:"terminalSequence,omitempty"` +} + +func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error { + if !cfg.EventEnabled(event) { + _, err := io.WriteString(w, "{}\n") + return err + } + var payload claudePayload + _ = json.NewDecoder(r).Decode(&payload) + if event == "stop" && payload.StopHookActive { + _, err := io.WriteString(w, "{}\n") + return err + } + + meta := context.MetaFromEnv("Claude", event) + title := context.Render(cfg.Notify.TitleTemplate, meta) + body := cfg.BodyForEvent(event) + seq := notify.BuildSequence(cfg.Notify.Protocol, title, body) + resp := claudeResponse{TerminalSequence: seq} + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return enc.Encode(resp) +} +``` + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/hook/... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/hook +git commit -m "feat: add Cursor and Claude hook handlers" +``` + +--- + +### Task 8: Install Package + +**Files:** +- Create: `internal/install/install.go` +- Create: `internal/install/install_test.go` + +- [ ] **Step 1: Write failing tests** + +Create `internal/install/install_test.go`: + +```go +package install + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMergeCursorHooks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + existing := `{"version":1,"hooks":{"beforeShellExecution":[{"command":"other"}]}}` + os.WriteFile(path, []byte(existing), 0644) + + if err := MergeCursorHooks(path, false); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + var doc map[string]any + json.Unmarshal(data, &doc) + hooks := doc["hooks"].(map[string]any) + stop := hooks["stop"].([]any) + if len(stop) != 1 { + t.Fatalf("expected stop hook added") + } +} + +func TestMergeCursorHooksNoOverwrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + existing := `{"version":1,"hooks":{"stop":[{"command":"existing"}]}}` + os.WriteFile(path, []byte(existing), 0644) + MergeCursorHooks(path, false) + data, _ := os.ReadFile(path) + if string(data) != existing { + t.Fatal("should not overwrite existing stop hook without force") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/install/... -v` +Expected: FAIL + +- [ ] **Step 3: Implement install** + +Create `internal/install/install.go`: + +```go +package install + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/longbin/agent-notify/internal/config" +) + +const cursorHookCmd = "agent-notify hook cursor stop" +const cursorToolCmd = "agent-notify hook cursor tool" +const claudeStopCmd = "agent-notify hook claude stop" +const claudeIdleCmd = "agent-notify hook claude idle" + +func CursorHooksPath() string { + return filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json") +} + +func ClaudeSettingsPath() string { + return filepath.Join(os.Getenv("HOME"), ".claude", "settings.json") +} + +func MergeCursorHooks(path string, force bool) error { + doc := map[string]any{"version": 1, "hooks": map[string]any{}} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &doc) + } + hooks, _ := doc["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + doc["hooks"] = hooks + } + addHook(hooks, "stop", cursorHookCmd, force) + cfg, _ := config.LoadDefault() + if cfg.Events.Tool { + addHook(hooks, "afterShellExecution", cursorToolCmd, force) + } + return writeJSON(path, doc) +} + +func addHook(hooks map[string]any, name, command string, force bool) { + entry := []any{map[string]string{"command": command}} + if existing, ok := hooks[name]; ok && !force { + _ = existing + return + } + hooks[name] = entry +} + +func MergeClaudeSettings(path string, force bool) error { + doc := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &doc) + } + hooks, _ := doc["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + doc["hooks"] = hooks + } + setClaudeHook(hooks, "Stop", claudeStopCmd, force) + cfg, _ := config.LoadDefault() + if cfg.Events.Idle { + setClaudeHook(hooks, "Notification", claudeIdleCmd, force) + } + return writeJSON(path, doc) +} + +func setClaudeHook(hooks map[string]any, event, command string, force bool) { + if _, ok := hooks[event]; ok && !force { + return + } + hooks[event] = []any{ + map[string]any{ + "hooks": []any{ + map[string]string{ + "type": "command", + "command": command, + }, + }, + }, + } +} + +func writeJSON(path string, doc any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0644) +} + +func InstallAll(force bool) error { + if err := config.WriteDefault(config.DefaultPath()); err != nil { + return err + } + if err := MergeCursorHooks(CursorHooksPath(), force); err != nil { + return err + } + return MergeClaudeSettings(ClaudeSettingsPath(), force) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/install/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/install +git commit -m "feat: merge Cursor and Claude hook configs on install" +``` + +--- + +### Task 9: Wire CLI Commands + +**Files:** +- Modify: `cmd/agent-notify/main.go` + +- [ ] **Step 1: Replace main.go with full CLI** + +Replace `cmd/agent-notify/main.go` with: + +```go +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/longbin/agent-notify/internal/config" + "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/notify" + "github.com/longbin/agent-notify/internal/tmux" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + if err := run(os.Args[1], os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "agent-notify:", err) + os.Exit(1) + } +} + +func run(cmd string, args []string) error { + switch cmd { + case "send": + return cmdSend(args) + case "hook": + return cmdHook(args) + case "install": + return cmdInstall(args) + case "test": + return cmdTest() + case "doctor": + return cmdDoctor() + case "help", "-h", "--help": + printUsage() + return nil + default: + printUsage() + return fmt.Errorf("unknown command %q", cmd) + } +} + +func cmdSend(args []string) error { + fs := flag.NewFlagSet("send", flag.ExitOnError) + title := fs.String("title", "", "notification title") + body := fs.String("body", "", "notification body") + event := fs.String("event", "stop", "event type") + _ = fs.Parse(args) + cfg, err := config.LoadDefault() + if err != nil { + return err + } + if *title == "" { + meta := context.MetaFromEnv("Agent", *event) + *title = context.Render(cfg.Notify.TitleTemplate, meta) + } + if *body == "" { + *body = cfg.BodyForEvent(*event) + } + return notify.SendAuto(cfg.Notify.Protocol, *title, *body) +} + +func cmdHook(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: agent-notify hook ") + } + agent, event := args[0], args[1] + cfg, err := config.LoadDefault() + if err != nil { + return err + } + switch agent { + case "cursor": + return hook.RunCursor(os.Stdin, cfg, event, os.Stdout) + case "claude": + return hook.RunClaude(os.Stdin, cfg, event, os.Stdout) + default: + return fmt.Errorf("unknown agent %q", agent) + } +} + +func cmdInstall(args []string) error { + fs := flag.NewFlagSet("install", flag.ExitOnError) + all := fs.Bool("all", false, "install all") + force := fs.Bool("force", false, "overwrite existing hooks") + _ = fs.Parse(args) + if *all || len(args) == 0 { + return install.InstallAll(*force) + } + return fmt.Errorf("use --all") +} + +func cmdTest() error { + return notify.TestNotification("agent-notify", "测试通知 — 如果你看到这条,说明配置正确") +} + +func cmdDoctor() error { + fmt.Println("agent-notify doctor") + if tmux.InTmux() { + fmt.Println("✓ running inside tmux") + ok, val, err := tmux.AllowPassthroughEnabled() + if err != nil { + fmt.Printf("✗ allow-passthrough check failed: %v\n", err) + } else if ok { + fmt.Printf("✓ allow-passthrough=%s\n", val) + } else { + fmt.Printf("✗ allow-passthrough=%q — add to ~/.tmux.conf: set -g allow-passthrough on\n", val) + } + tty, _ := tmux.ClientTTY() + fmt.Printf(" client_tty=%s\n", tty) + } else { + fmt.Println(" not in tmux (direct Ghostty mode)") + } + cfgPath := config.DefaultPath() + fmt.Printf(" config=%s\n", cfgPath) + return nil +} + +func printUsage() { + fmt.Fprint(os.Stderr, `Usage: agent-notify +Commands: + send [--title T] [--body B] [--event stop|idle|tool] + hook cursor stop|tool + hook claude stop|idle + install [--all] [--force] + test + doctor +`) +} +``` + +- [ ] **Step 2: Build and smoke test** + +Run: +```bash +go build -o bin/agent-notify ./cmd/agent-notify +./bin/agent-notify doctor +./bin/agent-notify test +``` +Expected: doctor prints status; test sends OSC (visible in Ghostty) + +- [ ] **Step 3: Run all tests** + +Run: `go test ./...` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add cmd/agent-notify/main.go +git commit -m "feat: wire send, hook, install, test, and doctor commands" +``` + +--- + +### Task 10: README and Manual Integration Tests + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Write README** + +Create `README.md` covering: +- 安装:`make install` +- tmux 配置:`set -g allow-passthrough on` +- Hook 安装:`agent-notify install --all` +- 手动测试矩阵(无 tmux / 本地 tmux / SSH 远程 tmux) +- Cursor CLI 仅支持 `cursor-agent`,不支持 Cursor IDE +- Claude `terminalSequence` 机制说明 + +- [ ] **Step 2: Manual verification checklist** + +Run each and confirm Ghostty notification: + +```bash +# 1. 无 tmux +agent-notify test + +# 2. 本地 tmux +tmux new-session -d 'agent-notify test' + +# 3. install + doctor +agent-notify install --all +agent-notify doctor +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add README with setup and manual test matrix" +``` + +--- + +## Spec Coverage Check + +| Spec requirement | Task | +|------------------|------| +| OSC 777/9 | Task 3 | +| tmux client_tty + DCS passthrough | Task 4, 5 | +| Config TOML + event toggles | Task 2 | +| Title `{agent} — {context}` | Task 6 | +| Cursor CLI stop/tool hooks | Task 7, 8, 9 | +| Claude Stop/Notification + terminalSequence | Task 7, 8, 9 | +| Claude stop_hook_active guard | Task 7 | +| install --all merge hooks | Task 8, 9 | +| doctor + test commands | Task 9 | +| No Cursor IDE support | Task 10 README | +| No notify-send fallback | Out of scope (documented in spec) | + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-26-agent-notify.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — 每个 Task 派发独立 subagent,任务间做 review,迭代快 +2. **Inline Execution** — 在本会话用 executing-plans 批量执行,checkpoint 处暂停 review + +**Which approach?** diff --git a/docs/superpowers/specs/2026-05-26-agent-notify-design.md b/docs/superpowers/specs/2026-05-26-agent-notify-design.md new file mode 100644 index 0000000..935bcac --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-agent-notify-design.md @@ -0,0 +1,349 @@ +# agent-notify 设计规格 + +**日期:** 2026-05-26 +**状态:** 已批准(brainstorming) +**目标:** 在本地/远程 tmux 中运行 Cursor CLI 与 Claude Code 时,通过 Agent hook 触发 OSC 777 桌面通知,经 tmux 透传至 Ghostty 终端。 + +--- + +## 1. 背景与目标 + +用户经常在本地 tmux 和远程 SSH tmux 中使用 AI Agent CLI(Cursor CLI、Claude Code)。当 Agent 完成一轮回复、回到等待输入状态时,用户希望收到桌面通知,而无需一直盯着终端。 + +Ghostty 终端支持 OSC 777(带标题/正文的桌面通知)和 OSC 9。tmux 默认会吞掉 OSC 序列,需通过 DCS passthrough 或写入 client TTY 透传。大部分 Agent CLI 支持 hook,可在 hook 中触发通知。 + +### 成功标准 + +- Agent 完成一轮回复、等待输入时,Ghostty 弹出桌面通知(默认开启) +- 通知标题包含 Agent 名称(Cursor / Claude)和项目目录或 tmux 窗口名 +- 同一套 CLI 在以下场景均可工作: + - 本地 Ghostty → 本地 tmux → Agent + - 本地 Ghostty → SSH → 远程 tmux → Agent + - 本地 Ghostty → 本地 tmux → SSH → 远程 tmux → Agent(嵌套 tmux) +- 提供 `agent-notify install` 一键写入 Cursor 与 Claude Code 的 hook 配置 +- 触发事件可配置:stop(默认开)、idle(默认关)、tool(默认关) + +### 非目标(首版) + +- notify-send 等系统通知回退 +- macOS / Windows 支持 +- 不支持 Cursor IDE,仅支持 Cursor CLI(`cursor-agent`) +- Cursor CLI 的 `afterAgentResponse` hook(CLI 中不可靠) + +--- + +## 2. 方案选择 + +在 brainstorming 中评估了三种方案: + +| 方案 | 描述 | 结论 | +|------|------|------| +| A | 自研 `agent-notify` CLI + 安装脚本 | **选用** | +| B | 包装 soloterm/tnotify | 外部依赖,Claude terminalSequence 适配不内聚 | +| C | 纯 Shell 脚本 | 嵌套 tmux 逻辑难维护 | + +--- + +## 3. 整体架构 + +``` +┌─────────────┐ hook 触发 ┌──────────────────┐ +│ Cursor CLI │ ────────────────► │ │ +│ Claude Code │ ────────────────► │ agent-notify │ +└─────────────┘ stdin/env/flag │ (核心 CLI) │ + └────────┬─────────┘ + │ OSC 777 + ▼ + ┌──────────────────────────┐ + │ tmux 透传层 (0~N 层) │ + └────────────┬─────────────┘ + │ SSH (远程场景) + ▼ + ┌─────────────┐ + │ Ghostty │ + └─────────────┘ +``` + +### 组件 + +1. **agent-notify CLI**(Go 单二进制) + - `send`:发送通知(供 hook 或直接调用) + - `hook`:Agent 专用入口,解析 stdin JSON + - `install`:写入 hook 配置与默认 config + - `test`:发送测试通知 + - `doctor`:检查 Ghostty/tmux/allow-passthrough 配置 + +2. **Hook 适配层** + - Cursor:`~/.cursor/hooks.json` + - Claude Code:`~/.claude/settings.json` + +3. **配置文件** + - `~/.config/agent-notify/config.toml` + +--- + +## 4. 通知协议 + +### OSC 格式 + +Ghostty 优先使用 **OSC 777**(支持标题 + 正文): + +``` +\033]777;notify;{title};{body}\007 +``` + +OSC 9 作为备选(仅正文): + +``` +\033]9;{body}\007 +``` + +首版默认使用 OSC 777。 + +### tmux 透传策略 + +发送优先级: + +1. **写 client TTY**(单层 tmux 最可靠) + - `tmux display-message -p '#{client_tty}'` + - 将 OSC 序列写入该 TTY + +2. **DCS passthrough**(嵌套 tmux 必需) + - 每层 tmux 包裹:`\033Ptmux;\033{inner}\033\\` + - 嵌套 N 层则包裹 N 次 + +3. **直写 stdout**(无 tmux 且 hook 允许时) + +### tmux 前置配置 + +用户需在涉及的每一层 tmux 中启用(安装脚本检测并提示): + +```tmux +set -g allow-passthrough on # 需要 tmux 3.2+ +``` + +### 远程 SSH 说明 + +- 远程 tmux 中写入 client TTY 时,数据经 SSH pty 传回本地 +- 若本地还有 tmux,本地 tmux 也需 `allow-passthrough on`,否则 OSC 在本地被吞掉 +- 嵌套 tmux(本地 tmux → SSH → 远程 tmux)需双层 passthrough 或双层 DCS 包裹 + +--- + +## 5. Hook 接入 + +### 触发事件映射 + +| 事件 | 含义 | Cursor CLI hook | Claude Code hook | 默认 | +|------|------|-----------------|------------------|------| +| stop | Agent 完成回复,等待输入 | `stop` | `Stop` | 开 | +| idle | 长时间无输入(约 60s) | 无等价 hook | `Notification` | 关 | +| tool | shell/工具执行结束 | `afterShellExecution` | `PostToolUse`(shell 类) | 关 | + +### 通知内容 + +- **标题:** `{agent} — {context}` + - `{agent}`:`Cursor` 或 `Claude` + - `{context}`:tmux 窗口名(`#{window_name}`);若无 tmux 则用 `basename(cwd)` +- **正文:** 按事件类型 + - stop:`等待输入` + - idle:`空闲 60s+,等待输入` + - tool:`工具执行完成` + +模板可在 config.toml 中覆盖。 + +### Cursor CLI 集成 + +配置文件:`~/.cursor/hooks.json`(全局)或项目级 `.cursor/hooks.json` + +```json +{ + "version": 1, + "hooks": { + "stop": [ + { "command": "agent-notify hook cursor stop" } + ], + "afterShellExecution": [ + { "command": "agent-notify hook cursor tool" } + ] + } +} +``` + +- `stop` hook 在 CLI 中可用 +- hook 进程的 stdout 可能被捕获,CLI 内部通过写 TTY / DCS passthrough 发送 OSC,不依赖 stdout +- `afterShellExecution` 仅在 config 中 `events.tool = true` 时由 install 写入 + +### Claude Code 集成 + +配置文件:`~/.claude/settings.json` + +Claude Code v2.1.139+ 的 hook 进程无 controlling TTY,**不可**直接写 `/dev/tty`。须通过 JSON 返回 `terminalSequence`,由 Claude Code 代为写入终端: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "agent-notify hook claude stop" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "agent-notify hook claude idle" + } + ] + } + ] + } +} +``` + +`agent-notify hook claude *` 输出: + +```json +{"terminalSequence": "\033]777;notify;Cursor — myproject;等待输入\007"} +``` + +- `Stop` hook 必须检查 `stop_hook_active`:若为 true 则输出空 JSON 并 exit 0,避免无限循环 +- `terminalSequence` 中的 OSC 序列由 Claude Code 写入其终端路径,天然兼容 tmux + +--- + +## 6. CLI 接口 + +### 命令 + +``` +agent-notify send [--title T] [--body B] [--event stop|idle|tool] +agent-notify hook cursor stop|tool +agent-notify hook claude stop|idle +agent-notify install [--cursor] [--claude] [--all] [--force] +agent-notify test +agent-notify doctor +``` + +### 环境变量(可选覆盖) + +| 变量 | 含义 | +|------|------| +| `AGENT_NOTIFY_AGENT` | Agent 名称 | +| `AGENT_NOTIFY_CWD` | 工作目录 | +| `AGENT_NOTIFY_EVENT` | 事件类型 | + +### 配置文件 + +路径:`~/.config/agent-notify/config.toml` + +```toml +[events] +stop = true +idle = false +tool = false + +[notify] +protocol = "osc777" # osc777 | osc9 +title_template = "{agent} — {context}" +body_stop = "等待输入" +body_idle = "空闲 60s+,等待输入" +body_tool = "工具执行完成" +``` + +`hook` 子命令读取 config,若对应 event 为 false 则静默 exit 0。 + +--- + +## 7. 安装流程 + +`agent-notify install --all` 执行: + +1. 检测 `agent-notify` 是否在 PATH +2. 运行 `doctor`:检查是否在 tmux、tmux 版本、`allow-passthrough` 状态 +3. 写入 `~/.config/agent-notify/config.toml`(不存在时) +4. 合并写入 Cursor `~/.cursor/hooks.json`(不覆盖已有同 event hook,除非 `--force`) +5. 合并写入 Claude `~/.claude/settings.json` +6. 运行 `agent-notify test` 验证通知 + +--- + +## 8. 错误处理 + +| 场景 | 行为 | +|------|------| +| 不在 tmux | 直接写 stdout(Cursor)或返回 terminalSequence(Claude) | +| tmux 无 client_tty | 回退 DCS passthrough | +| config 中 event 关闭 | hook 静默 exit 0 | +| Claude stop_hook_active=true | 不发送通知,输出 `{}` | +| doctor 发现 allow-passthrough 未开 | 打印修复提示,不阻断 install | +| OSC 发送失败 | exit 1,stderr 输出原因(hook 不应阻断 Agent) | + +Cursor/Claude hook 脚本始终以 exit 0 结束(Claude Stop 除外需遵循 stop_hook_active 规则),避免影响 Agent 正常运行。 + +--- + +## 9. 技术选型 + +- **语言:** Go 1.22+ +- **依赖:** 标准库为主;TOML 解析可用 `github.com/BurntSushi/toml` +- **分发:** `go install github.com/.../agent-notify@latest` 或仓库内 `make install` +- **平台:** Linux + Ghostty(首版) + +--- + +## 10. 测试计划 + +### 单元测试 + +- OSC 777/9 序列生成 +- tmux 层数检测与 DCS 多层包裹 +- config 解析与 event 开关 +- Claude hook JSON 输出格式 + +### 集成测试(手动) + +| 场景 | 命令 | 期望 | +|------|------|------| +| 无 tmux | `agent-notify test` | Ghostty 弹出通知 | +| 本地 tmux | 在 tmux 内 `agent-notify test` | Ghostty 弹出通知 | +| 远程 tmux | SSH 到远程 tmux 内 test | 本地 Ghostty 弹出通知 | +| 嵌套 tmux | 本地 tmux → SSH → 远程 tmux test | 本地 Ghostty 弹出通知 | +| Cursor stop | cursor-agent 完成一轮 | 通知标题含 Cursor + 项目名 | +| Claude Stop | claude 完成一轮 | 通知标题含 Claude + 上下文 | + +--- + +## 11. 项目结构(预期) + +``` +agent-notify/ +├── cmd/agent-notify/main.go +├── internal/ +│ ├── notify/ # OSC 生成与发送 +│ ├── tmux/ # 层数检测、passthrough、client_tty +│ ├── hook/ # cursor/claude stdin 解析 +│ ├── config/ # TOML 配置 +│ └── install/ # hook 配置合并写入 +├── docs/superpowers/specs/ +│ └── 2026-05-26-agent-notify-design.md +├── go.mod +├── Makefile +└── README.md +``` + +--- + +## 12. 参考资料 + +- [Ghostty OSC 实现](https://github.com/ghostty-org/ghostty/blob/main/src/terminal/osc.zig) +- [Claude Code Hooks - terminalSequence](https://code.claude.com/docs/en/hooks) +- [Cursor Hooks 文档](https://cursor.com/docs/hooks) +- [tmux OSC passthrough(linw1995)](https://www.linw1995.com/en/agent-native-system-notifications/) +- [soloterm/tnotify](https://github.com/soloterm/tnotify) diff --git a/go.mod b/go.mod index d514fb6..57bc55c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/longbin/agent-notify go 1.22.2 + +require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d860aed --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,102 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Events Events `toml:"events"` + Notify Notify `toml:"notify"` +} + +type Events struct { + Stop bool `toml:"stop"` + Idle bool `toml:"idle"` + Tool bool `toml:"tool"` +} + +type Notify struct { + Protocol string `toml:"protocol"` + TitleTemplate string `toml:"title_template"` + BodyStop string `toml:"body_stop"` + BodyIdle string `toml:"body_idle"` + BodyTool string `toml:"body_tool"` +} + +func Default() Config { + return Config{ + Events: Events{Stop: true, Idle: false, Tool: false}, + Notify: Notify{ + Protocol: "osc777", + TitleTemplate: "{agent} — {context}", + BodyStop: "等待输入", + BodyIdle: "空闲 60s+,等待输入", + BodyTool: "工具执行完成", + }, + } +} + +func DefaultPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml") +} + +func Load(path string) (Config, error) { + cfg := Default() + if path == "" { + return cfg, nil + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return cfg, nil + } + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func LoadDefault() (Config, error) { + return Load(DefaultPath()) +} + +func (c Config) EventEnabled(event string) bool { + switch strings.ToLower(event) { + case "stop": + return c.Events.Stop + case "idle": + return c.Events.Idle + case "tool": + return c.Events.Tool + default: + return false + } +} + +func (c Config) BodyForEvent(event string) string { + switch strings.ToLower(event) { + case "idle": + return c.Notify.BodyIdle + case "tool": + return c.Notify.BodyTool + default: + return c.Notify.BodyStop + } +} + +func (c Config) WriteDefault(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if os.IsExist(err) { + return nil + } + return err + } + defer f.Close() + return toml.NewEncoder(f).Encode(Default()) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6b53742 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := Default() + if !cfg.Events.Stop { + t.Fatal("expected stop=true by default") + } + if cfg.Events.Idle { + t.Fatal("expected idle=false by default") + } + if cfg.Notify.Protocol != "osc777" { + t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol) + } +} + +func TestLoadFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := ` +[events] +stop = false +tool = true + +[notify] +body_stop = "custom stop" +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Events.Stop { + t.Fatal("expected stop=false") + } + if !cfg.Events.Tool { + t.Fatal("expected tool=true") + } + if cfg.Notify.BodyStop != "custom stop" { + t.Fatalf("got %q", cfg.Notify.BodyStop) + } +} + +func TestEventEnabled(t *testing.T) { + cfg := Default() + if !cfg.EventEnabled("stop") { + t.Fatal("expected stop enabled by default") + } + if !cfg.EventEnabled("STOP") { + t.Fatal("expected case-insensitive match") + } + cfg.Events.Stop = false + if cfg.EventEnabled("stop") { + t.Fatal("expected stop disabled") + } +} diff --git a/internal/context/meta.go b/internal/context/meta.go new file mode 100644 index 0000000..7ab1739 --- /dev/null +++ b/internal/context/meta.go @@ -0,0 +1,69 @@ +package context + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Meta struct { + Agent string + CWD string + Context string + Event string +} + +func Render(tmpl string, m Meta) string { + ctx := m.ResolveContext(m.Context) + 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 + } + if m.Context != "" { + return m.Context + } + cwd := m.CWD + if cwd == "" { + cwd, _ = os.Getwd() + } + if cwd == "" { + return "unknown" + } + 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 == "" { + cwd, _ = os.Getwd() + } + if a := os.Getenv("AGENT_NOTIFY_AGENT"); a != "" { + agent = a + } + if e := os.Getenv("AGENT_NOTIFY_EVENT"); e != "" { + event = e + } + return Meta{ + Agent: agent, + CWD: cwd, + Context: TmuxWindowName(), + Event: event, + } +} diff --git a/internal/context/meta_test.go b/internal/context/meta_test.go new file mode 100644 index 0000000..42cb2a8 --- /dev/null +++ b/internal/context/meta_test.go @@ -0,0 +1,26 @@ +package context + +import "testing" + +func TestRenderTitle(t *testing.T) { + meta := Meta{Agent: "Cursor", Context: "myapp"} + got := Render("{agent} — {context}", meta) + want := "Cursor — myapp" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestContextFromCWD(t *testing.T) { + meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"} + 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") + } +} diff --git a/internal/hook/claude.go b/internal/hook/claude.go new file mode 100644 index 0000000..501094d --- /dev/null +++ b/internal/hook/claude.go @@ -0,0 +1,41 @@ +package hook + +import ( + "encoding/json" + "io" + + "github.com/longbin/agent-notify/internal/config" + "github.com/longbin/agent-notify/internal/context" + "github.com/longbin/agent-notify/internal/notify" +) + +type claudePayload struct { + StopHookActive bool `json:"stop_hook_active"` + Message string `json:"message"` +} + +type claudeResponse struct { + TerminalSequence string `json:"terminalSequence,omitempty"` +} + +func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error { + if !cfg.EventEnabled(event) { + _, err := io.WriteString(w, "{}\n") + return err + } + var payload claudePayload + _ = json.NewDecoder(r).Decode(&payload) + if event == "stop" && payload.StopHookActive { + _, err := io.WriteString(w, "{}\n") + return err + } + + meta := context.MetaFromEnv("Claude", event) + title := context.Render(cfg.Notify.TitleTemplate, meta) + body := cfg.BodyForEvent(event) + seq := notify.BuildSequence(cfg.Notify.Protocol, title, body) + resp := claudeResponse{TerminalSequence: seq} + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return enc.Encode(resp) +} diff --git a/internal/hook/cursor.go b/internal/hook/cursor.go new file mode 100644 index 0000000..dfc0e79 --- /dev/null +++ b/internal/hook/cursor.go @@ -0,0 +1,30 @@ +package hook + +import ( + "encoding/json" + "io" + + "github.com/longbin/agent-notify/internal/config" + "github.com/longbin/agent-notify/internal/context" + "github.com/longbin/agent-notify/internal/notify" +) + +type cursorPayload struct { + WorkspaceRoots []string `json:"workspace_roots"` +} + +func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error { + if !cfg.EventEnabled(event) { + return nil + } + var payload cursorPayload + _ = json.NewDecoder(r).Decode(&payload) + + meta := context.MetaFromEnv("Cursor", event) + if len(payload.WorkspaceRoots) > 0 { + meta.CWD = payload.WorkspaceRoots[0] + } + title := context.Render(cfg.Notify.TitleTemplate, meta) + body := cfg.BodyForEvent(event) + return notify.SendAuto(cfg.Notify.Protocol, title, body) +} diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go new file mode 100644 index 0000000..21dc222 --- /dev/null +++ b/internal/hook/hook_test.go @@ -0,0 +1,47 @@ +package hook + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/longbin/agent-notify/internal/config" +) + +func TestCursorStopHookDisabled(t *testing.T) { + cfg := config.Default() + cfg.Events.Stop = false + err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } +} + +func TestClaudeStopOutputsTerminalSequence(t *testing.T) { + cfg := config.Default() + var out bytes.Buffer + err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out) + if err != nil { + t.Fatal(err) + } + var resp map[string]string + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !strings.Contains(resp["terminalSequence"], "777;notify") { + t.Fatalf("bad sequence: %v", resp) + } +} + +func TestClaudeStopHookActiveSkips(t *testing.T) { + cfg := config.Default() + var out bytes.Buffer + err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out.String()) != "{}" { + t.Fatalf("expected {}, got %q", out.String()) + } +} diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 0000000..48daa44 --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,104 @@ +package install + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/longbin/agent-notify/internal/config" +) + +const cursorHookCmd = "agent-notify hook cursor stop" +const cursorToolCmd = "agent-notify hook cursor tool" +const claudeStopCmd = "agent-notify hook claude stop" +const claudeIdleCmd = "agent-notify hook claude idle" + +func CursorHooksPath() string { + return filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json") +} + +func ClaudeSettingsPath() string { + return filepath.Join(os.Getenv("HOME"), ".claude", "settings.json") +} + +func MergeCursorHooks(path string, force bool) error { + doc := map[string]any{"version": 1, "hooks": map[string]any{}} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &doc) + } + hooks, _ := doc["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + doc["hooks"] = hooks + } + addHook(hooks, "stop", cursorHookCmd, force) + cfg, _ := config.LoadDefault() + if cfg.Events.Tool { + addHook(hooks, "afterShellExecution", cursorToolCmd, force) + } + return writeJSON(path, doc) +} + +func addHook(hooks map[string]any, name, command string, force bool) { + if existing, ok := hooks[name]; ok && !force { + _ = existing + return + } + hooks[name] = []any{map[string]string{"command": command}} +} + +func MergeClaudeSettings(path string, force bool) error { + doc := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &doc) + } + hooks, _ := doc["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + doc["hooks"] = hooks + } + setClaudeHook(hooks, "Stop", claudeStopCmd, force) + cfg, _ := config.LoadDefault() + if cfg.Events.Idle { + setClaudeHook(hooks, "Notification", claudeIdleCmd, force) + } + return writeJSON(path, doc) +} + +func setClaudeHook(hooks map[string]any, event, command string, force bool) { + if _, ok := hooks[event]; ok && !force { + return + } + hooks[event] = []any{ + map[string]any{ + "hooks": []any{ + map[string]string{ + "type": "command", + "command": command, + }, + }, + }, + } +} + +func writeJSON(path string, doc any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0644) +} + +func InstallAll(force bool) error { + if err := config.Default().WriteDefault(config.DefaultPath()); err != nil { + return err + } + if err := MergeCursorHooks(CursorHooksPath(), force); err != nil { + return err + } + return MergeClaudeSettings(ClaudeSettingsPath(), force) +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go new file mode 100644 index 0000000..2cd2cf6 --- /dev/null +++ b/internal/install/install_test.go @@ -0,0 +1,44 @@ +package install + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMergeCursorHooks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + existing := `{"version":1,"hooks":{"beforeShellExecution":[{"command":"other"}]}}` + os.WriteFile(path, []byte(existing), 0644) + + if err := MergeCursorHooks(path, false); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + var doc map[string]any + json.Unmarshal(data, &doc) + hooks := doc["hooks"].(map[string]any) + stop := hooks["stop"].([]any) + if len(stop) != 1 { + t.Fatalf("expected stop hook added") + } +} + +func TestMergeCursorHooksNoOverwrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + existing := `{"version":1,"hooks":{"stop":[{"command":"existing"}]}}` + os.WriteFile(path, []byte(existing), 0644) + MergeCursorHooks(path, false) + data, _ := os.ReadFile(path) + var doc map[string]any + json.Unmarshal(data, &doc) + hooks := doc["hooks"].(map[string]any) + stop := hooks["stop"].([]any) + entry := stop[0].(map[string]any) + if entry["command"] != "existing" { + t.Fatal("should not overwrite existing stop hook without force") + } +} diff --git a/internal/notify/osc.go b/internal/notify/osc.go new file mode 100644 index 0000000..963e3a9 --- /dev/null +++ b/internal/notify/osc.go @@ -0,0 +1,16 @@ +package notify + +import "strings" + +func BuildSequence(protocol, title, body string) string { + switch protocol { + case "osc9": + return "\033]9;" + escapeOSCField(body) + "\007" + default: + return "\033]777;notify;" + escapeOSCField(title) + ";" + escapeOSCField(body) + "\007" + } +} + +func escapeOSCField(s string) string { + return strings.ReplaceAll(s, ";", "\\;") +} diff --git a/internal/notify/osc_test.go b/internal/notify/osc_test.go new file mode 100644 index 0000000..c4b17e9 --- /dev/null +++ b/internal/notify/osc_test.go @@ -0,0 +1,26 @@ +package notify + +import "testing" + +func TestBuildOSC777(t *testing.T) { + seq := BuildSequence("osc777", "Claude — proj", "等待输入") + want := "\033]777;notify;Claude — proj;等待输入\007" + if seq != want { + t.Fatalf("got %q want %q", seq, want) + } +} + +func TestBuildOSC9(t *testing.T) { + seq := BuildSequence("osc9", "ignored", "等待输入") + want := "\033]9;等待输入\007" + if seq != want { + t.Fatalf("got %q want %q", seq, want) + } +} + +func TestSemicolonInTitleEscaped(t *testing.T) { + seq := BuildSequence("osc777", "a;b", "body") + if seq != "\033]777;notify;a\\;b;body\007" { + t.Fatalf("unexpected escape: %q", seq) + } +} diff --git a/internal/notify/send.go b/internal/notify/send.go new file mode 100644 index 0000000..78b19e1 --- /dev/null +++ b/internal/notify/send.go @@ -0,0 +1,71 @@ +package notify + +import ( + "fmt" + "io" + "os" + + "github.com/longbin/agent-notify/internal/tmux" +) + +type SendOptions struct { + Protocol string + Title string + Body string + Writer io.Writer + InTmux bool + Layers int + ClientTTY string +} + +func Send(opts SendOptions) error { + seq := BuildSequence(opts.Protocol, opts.Title, opts.Body) + 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 + } + } + + out := seq + if opts.InTmux { + layers := opts.Layers + if layers <= 0 { + layers = 1 + } + out = tmux.WrapPassthroughLayers(seq, layers) + } + _, err := io.WriteString(w, out) + return err +} + +func SendAuto(protocol, title, body string) error { + inTmux := tmux.InTmux() + clientTTY, _ := tmux.ClientTTY() + layers := 0 + if inTmux { + layers = 1 + } + return Send(SendOptions{ + Protocol: protocol, + Title: title, + Body: body, + InTmux: inTmux, + Layers: layers, + ClientTTY: clientTTY, + }) +} + +func TestNotification(title, body string) error { + if err := SendAuto("osc777", title, body); err != nil { + return fmt.Errorf("send test notification: %w", err) + } + return nil +} diff --git a/internal/notify/send_test.go b/internal/notify/send_test.go new file mode 100644 index 0000000..561ce75 --- /dev/null +++ b/internal/notify/send_test.go @@ -0,0 +1,42 @@ +package notify + +import ( + "bytes" + "testing" +) + +func TestSendDirect(t *testing.T) { + var buf bytes.Buffer + err := Send(SendOptions{ + Protocol: "osc777", + Title: "Cursor — app", + Body: "等待输入", + Writer: &buf, + InTmux: false, + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("777;notify")) { + t.Fatalf("missing osc777: %q", buf.String()) + } +} + +func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) { + var buf bytes.Buffer + err := Send(SendOptions{ + Protocol: "osc777", + Title: "t", + Body: "b", + Writer: &buf, + InTmux: true, + Layers: 1, + ClientTTY: "", + }) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(buf.Bytes(), []byte("\033Ptmux;")) { + t.Fatalf("expected passthrough prefix, got %q", buf.String()) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go new file mode 100644 index 0000000..cd2bf56 --- /dev/null +++ b/internal/tmux/tmux.go @@ -0,0 +1,46 @@ +package tmux + +import ( + "os" + "os/exec" + "strings" +) + +func InTmux() bool { + return os.Getenv("TMUX") != "" +} + +func ClientTTY() (string, error) { + if !InTmux() { + return "", nil + } + out, err := exec.Command("tmux", "display-message", "-p", "#{client_tty}").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func WrapPassthrough(seq string) string { + return "\033Ptmux;\033" + seq + "\033\\" +} + +func WrapPassthroughLayers(seq string, layers int) string { + out := seq + for i := 0; i < layers; i++ { + out = WrapPassthrough(out) + } + return out +} + +func AllowPassthroughEnabled() (bool, string, error) { + if !InTmux() { + return true, "", nil + } + out, err := exec.Command("tmux", "show-option", "-gv", "allow-passthrough").Output() + if err != nil { + return false, "", err + } + val := strings.TrimSpace(string(out)) + return val == "on" || val == "all", val, nil +} diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go new file mode 100644 index 0000000..20aada7 --- /dev/null +++ b/internal/tmux/tmux_test.go @@ -0,0 +1,29 @@ +package tmux + +import "testing" + +func TestInTmux(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-123,1,0") + if !InTmux() { + t.Fatal("expected InTmux true") + } +} + +func TestWrapPassthroughSingle(t *testing.T) { + inner := "\033]777;notify;t;b\007" + got := WrapPassthrough(inner) + want := "\033Ptmux;\033" + inner + "\033\\" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestWrapPassthroughNested(t *testing.T) { + inner := "\033]777;notify;t;b\007" + got := WrapPassthroughLayers(inner, 2) + once := WrapPassthrough(inner) + twice := WrapPassthrough(once) + if got != twice { + t.Fatalf("nested wrap mismatch") + } +}