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>
30 KiB
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
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:
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 <command>
Commands:
send Send a notification
hook Agent hook entrypoint
install Install hook configs
test Send test notification
doctor Check environment
`)
}
- Step 3: Create 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
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:
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
go get github.com/BurntSushi/toml
Create internal/config/config.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
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:
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:
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
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:
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:
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
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:
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:
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
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:
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:
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
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:
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:
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:
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
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:
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:
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
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:
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 <cursor|claude> <event>")
}
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 <command>
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:
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
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:
# 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
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:
- Subagent-Driven (recommended) — 每个 Task 派发独立 subagent,任务间做 review,迭代快
- Inline Execution — 在本会话用 executing-plans 批量执行,checkpoint 处暂停 review
Which approach?