5fecb6d215
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>
1412 lines
30 KiB
Markdown
1412 lines
30 KiB
Markdown
# 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?**
|