Files
agent-dashboard/docs/superpowers/plans/2026-05-26-agent-notify.md
laily 5fecb6d215 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>
2026-05-26 11:19:34 +08:00

1412 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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**
```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 <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:
```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?**