feat: implement agent-notify CLI with hooks and tmux passthrough

Add OSC 777 notification sender, Cursor/Claude hook adapters,
config/install commands, and README for Ghostty + tmux setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 11:19:34 +08:00
parent f13dcd6cae
commit 5fecb6d215
22 changed files with 2729 additions and 11 deletions
+102
View File
@@ -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())
}
+63
View File
@@ -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")
}
}
+69
View File
@@ -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,
}
}
+26
View File
@@ -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")
}
}
+41
View File
@@ -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)
}
+30
View File
@@ -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)
}
+47
View File
@@ -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())
}
}
+104
View File
@@ -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)
}
+44
View File
@@ -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")
}
}
+16
View File
@@ -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, ";", "\\;")
}
+26
View File
@@ -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)
}
}
+71
View File
@@ -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
}
+42
View File
@@ -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())
}
}
+46
View File
@@ -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
}
+29
View File
@@ -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")
}
}