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
+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())
}
}