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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 11:19:34 +08:00
parent f13dcd6cae
commit 5fecb6d215
22 changed files with 2729 additions and 11 deletions
+96
View File
@@ -0,0 +1,96 @@
# agent-notify
在 tmux(含 SSH 远程)中运行 Cursor CLI 与 Claude Code 时,通过 Agent hook 触发 Ghostty 桌面通知(OSC 777)。
**支持:** Cursor CLI`cursor-agent`)、Claude Code
**不支持:** Cursor IDE
## 前置条件
- [Ghostty](https://ghostty.org/) 终端(支持 OSC 777
- tmux 3.2+(若在 tmux 内使用)
在每一层 tmux 的 `~/.tmux.conf` 中添加:
```tmux
set -g allow-passthrough on
```
## 安装
```bash
make install
# 或
go install ./cmd/agent-notify
```
## 配置 Hook
```bash
agent-notify install --all
agent-notify doctor
agent-notify test
```
配置文件:`~/.config/agent-notify/config.toml`
```toml
[events]
stop = true # Agent 完成回复,等待输入
idle = false # Claude 空闲 60s+Notification hook
tool = false # shell/工具执行结束
[notify]
protocol = "osc777"
title_template = "{agent} — {context}"
body_stop = "等待输入"
```
## 命令
```bash
agent-notify send --event stop
agent-notify hook cursor stop # Cursor CLI stop hook
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON
agent-notify test
agent-notify doctor
agent-notify install --all [--force]
```
## Hook 配置位置
| Agent | 配置文件 | Hook 事件 |
|-------|---------|-----------|
| Cursor CLI | `~/.cursor/hooks.json` | `stop`, `afterShellExecution`tool 开启时) |
| Claude Code | `~/.claude/settings.json` | `Stop`, `Notification`idle 开启时) |
## 手动测试矩阵
```bash
# 1. 无 tmuxGhostty 直接)
agent-notify test
# 2. 本地 tmux
tmux new-session -d 'agent-notify test'
# 3. 远程 tmuxSSH 到远程后在 tmux 内)
agent-notify test
# 4. 嵌套 tmux(本地 tmux → SSH → 远程 tmux
# 确保两层 tmux 都设置了 allow-passthrough on
agent-notify test
```
## 工作原理
1. Agent hook 调用 `agent-notify hook ...`
2. CLI 生成 OSC 777 序列:`\033]777;notify;标题;正文\007`
3. 在 tmux 内优先写入 `client_tty`,否则用 DCS passthrough 透传
4. Claude Code 通过 hook JSON 的 `terminalSequence` 字段输出 OSChook 进程无 TTY
## 开发
```bash
make test
make build
```
BIN
View File
Binary file not shown.
+113 -11
View File
@@ -1,8 +1,16 @@
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() {
@@ -10,23 +18,117 @@ func main() {
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()
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 Send a notification
hook Agent hook entrypoint
install Install hook configs
test Send test notification
doctor Check environment
send [--title T] [--body B] [--event stop|idle|tool]
hook cursor stop|tool
hook claude stop|idle
install [--all] [--force]
test
doctor
`)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
# agent-notify 设计规格
**日期:** 2026-05-26
**状态:** 已批准(brainstorming
**目标:** 在本地/远程 tmux 中运行 Cursor CLI 与 Claude Code 时,通过 Agent hook 触发 OSC 777 桌面通知,经 tmux 透传至 Ghostty 终端。
---
## 1. 背景与目标
用户经常在本地 tmux 和远程 SSH tmux 中使用 AI Agent CLICursor CLI、Claude Code)。当 Agent 完成一轮回复、回到等待输入状态时,用户希望收到桌面通知,而无需一直盯着终端。
Ghostty 终端支持 OSC 777(带标题/正文的桌面通知)和 OSC 9。tmux 默认会吞掉 OSC 序列,需通过 DCS passthrough 或写入 client TTY 透传。大部分 Agent CLI 支持 hook,可在 hook 中触发通知。
### 成功标准
- Agent 完成一轮回复、等待输入时,Ghostty 弹出桌面通知(默认开启)
- 通知标题包含 Agent 名称(Cursor / Claude)和项目目录或 tmux 窗口名
- 同一套 CLI 在以下场景均可工作:
- 本地 Ghostty → 本地 tmux → Agent
- 本地 Ghostty → SSH → 远程 tmux → Agent
- 本地 Ghostty → 本地 tmux → SSH → 远程 tmux → Agent(嵌套 tmux
- 提供 `agent-notify install` 一键写入 Cursor 与 Claude Code 的 hook 配置
- 触发事件可配置:stop(默认开)、idle(默认关)、tool(默认关)
### 非目标(首版)
- notify-send 等系统通知回退
- macOS / Windows 支持
- 不支持 Cursor IDE,仅支持 Cursor CLI`cursor-agent`
- Cursor CLI 的 `afterAgentResponse` hookCLI 中不可靠)
---
## 2. 方案选择
在 brainstorming 中评估了三种方案:
| 方案 | 描述 | 结论 |
|------|------|------|
| A | 自研 `agent-notify` CLI + 安装脚本 | **选用** |
| B | 包装 soloterm/tnotify | 外部依赖,Claude terminalSequence 适配不内聚 |
| C | 纯 Shell 脚本 | 嵌套 tmux 逻辑难维护 |
---
## 3. 整体架构
```
┌─────────────┐ hook 触发 ┌──────────────────┐
│ Cursor CLI │ ────────────────► │ │
│ Claude Code │ ────────────────► │ agent-notify │
└─────────────┘ stdin/env/flag │ (核心 CLI) │
└────────┬─────────┘
│ OSC 777
┌──────────────────────────┐
│ tmux 透传层 (0~N 层) │
└────────────┬─────────────┘
│ SSH (远程场景)
┌─────────────┐
│ Ghostty │
└─────────────┘
```
### 组件
1. **agent-notify CLI**Go 单二进制)
- `send`:发送通知(供 hook 或直接调用)
- `hook`Agent 专用入口,解析 stdin JSON
- `install`:写入 hook 配置与默认 config
- `test`:发送测试通知
- `doctor`:检查 Ghostty/tmux/allow-passthrough 配置
2. **Hook 适配层**
- Cursor`~/.cursor/hooks.json`
- Claude Code`~/.claude/settings.json`
3. **配置文件**
- `~/.config/agent-notify/config.toml`
---
## 4. 通知协议
### OSC 格式
Ghostty 优先使用 **OSC 777**(支持标题 + 正文):
```
\033]777;notify;{title};{body}\007
```
OSC 9 作为备选(仅正文):
```
\033]9;{body}\007
```
首版默认使用 OSC 777。
### tmux 透传策略
发送优先级:
1. **写 client TTY**(单层 tmux 最可靠)
- `tmux display-message -p '#{client_tty}'`
- 将 OSC 序列写入该 TTY
2. **DCS passthrough**(嵌套 tmux 必需)
- 每层 tmux 包裹:`\033Ptmux;\033{inner}\033\\`
- 嵌套 N 层则包裹 N 次
3. **直写 stdout**(无 tmux 且 hook 允许时)
### tmux 前置配置
用户需在涉及的每一层 tmux 中启用(安装脚本检测并提示):
```tmux
set -g allow-passthrough on # 需要 tmux 3.2+
```
### 远程 SSH 说明
- 远程 tmux 中写入 client TTY 时,数据经 SSH pty 传回本地
- 若本地还有 tmux,本地 tmux 也需 `allow-passthrough on`,否则 OSC 在本地被吞掉
- 嵌套 tmux(本地 tmux → SSH → 远程 tmux)需双层 passthrough 或双层 DCS 包裹
---
## 5. Hook 接入
### 触发事件映射
| 事件 | 含义 | Cursor CLI hook | Claude Code hook | 默认 |
|------|------|-----------------|------------------|------|
| stop | Agent 完成回复,等待输入 | `stop` | `Stop` | 开 |
| idle | 长时间无输入(约 60s | 无等价 hook | `Notification` | 关 |
| tool | shell/工具执行结束 | `afterShellExecution` | `PostToolUse`shell 类) | 关 |
### 通知内容
- **标题:** `{agent} — {context}`
- `{agent}``Cursor``Claude`
- `{context}`tmux 窗口名(`#{window_name}`);若无 tmux 则用 `basename(cwd)`
- **正文:** 按事件类型
- stop`等待输入`
- idle`空闲 60s+,等待输入`
- tool`工具执行完成`
模板可在 config.toml 中覆盖。
### Cursor CLI 集成
配置文件:`~/.cursor/hooks.json`(全局)或项目级 `.cursor/hooks.json`
```json
{
"version": 1,
"hooks": {
"stop": [
{ "command": "agent-notify hook cursor stop" }
],
"afterShellExecution": [
{ "command": "agent-notify hook cursor tool" }
]
}
}
```
- `stop` hook 在 CLI 中可用
- hook 进程的 stdout 可能被捕获,CLI 内部通过写 TTY / DCS passthrough 发送 OSC,不依赖 stdout
- `afterShellExecution` 仅在 config 中 `events.tool = true` 时由 install 写入
### Claude Code 集成
配置文件:`~/.claude/settings.json`
Claude Code v2.1.139+ 的 hook 进程无 controlling TTY**不可**直接写 `/dev/tty`。须通过 JSON 返回 `terminalSequence`,由 Claude Code 代为写入终端:
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "agent-notify hook claude stop"
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "agent-notify hook claude idle"
}
]
}
]
}
}
```
`agent-notify hook claude *` 输出:
```json
{"terminalSequence": "\033]777;notify;Cursor — myproject;等待输入\007"}
```
- `Stop` hook 必须检查 `stop_hook_active`:若为 true 则输出空 JSON 并 exit 0,避免无限循环
- `terminalSequence` 中的 OSC 序列由 Claude Code 写入其终端路径,天然兼容 tmux
---
## 6. CLI 接口
### 命令
```
agent-notify send [--title T] [--body B] [--event stop|idle|tool]
agent-notify hook cursor stop|tool
agent-notify hook claude stop|idle
agent-notify install [--cursor] [--claude] [--all] [--force]
agent-notify test
agent-notify doctor
```
### 环境变量(可选覆盖)
| 变量 | 含义 |
|------|------|
| `AGENT_NOTIFY_AGENT` | Agent 名称 |
| `AGENT_NOTIFY_CWD` | 工作目录 |
| `AGENT_NOTIFY_EVENT` | 事件类型 |
### 配置文件
路径:`~/.config/agent-notify/config.toml`
```toml
[events]
stop = true
idle = false
tool = false
[notify]
protocol = "osc777" # osc777 | osc9
title_template = "{agent} — {context}"
body_stop = "等待输入"
body_idle = "空闲 60s+,等待输入"
body_tool = "工具执行完成"
```
`hook` 子命令读取 config,若对应 event 为 false 则静默 exit 0。
---
## 7. 安装流程
`agent-notify install --all` 执行:
1. 检测 `agent-notify` 是否在 PATH
2. 运行 `doctor`:检查是否在 tmux、tmux 版本、`allow-passthrough` 状态
3. 写入 `~/.config/agent-notify/config.toml`(不存在时)
4. 合并写入 Cursor `~/.cursor/hooks.json`(不覆盖已有同 event hook,除非 `--force`
5. 合并写入 Claude `~/.claude/settings.json`
6. 运行 `agent-notify test` 验证通知
---
## 8. 错误处理
| 场景 | 行为 |
|------|------|
| 不在 tmux | 直接写 stdoutCursor)或返回 terminalSequenceClaude |
| tmux 无 client_tty | 回退 DCS passthrough |
| config 中 event 关闭 | hook 静默 exit 0 |
| Claude stop_hook_active=true | 不发送通知,输出 `{}` |
| doctor 发现 allow-passthrough 未开 | 打印修复提示,不阻断 install |
| OSC 发送失败 | exit 1stderr 输出原因(hook 不应阻断 Agent |
Cursor/Claude hook 脚本始终以 exit 0 结束(Claude Stop 除外需遵循 stop_hook_active 规则),避免影响 Agent 正常运行。
---
## 9. 技术选型
- **语言:** Go 1.22+
- **依赖:** 标准库为主;TOML 解析可用 `github.com/BurntSushi/toml`
- **分发:** `go install github.com/.../agent-notify@latest` 或仓库内 `make install`
- **平台:** Linux + Ghostty(首版)
---
## 10. 测试计划
### 单元测试
- OSC 777/9 序列生成
- tmux 层数检测与 DCS 多层包裹
- config 解析与 event 开关
- Claude hook JSON 输出格式
### 集成测试(手动)
| 场景 | 命令 | 期望 |
|------|------|------|
| 无 tmux | `agent-notify test` | Ghostty 弹出通知 |
| 本地 tmux | 在 tmux 内 `agent-notify test` | Ghostty 弹出通知 |
| 远程 tmux | SSH 到远程 tmux 内 test | 本地 Ghostty 弹出通知 |
| 嵌套 tmux | 本地 tmux → SSH → 远程 tmux test | 本地 Ghostty 弹出通知 |
| Cursor stop | cursor-agent 完成一轮 | 通知标题含 Cursor + 项目名 |
| Claude Stop | claude 完成一轮 | 通知标题含 Claude + 上下文 |
---
## 11. 项目结构(预期)
```
agent-notify/
├── cmd/agent-notify/main.go
├── internal/
│ ├── notify/ # OSC 生成与发送
│ ├── tmux/ # 层数检测、passthrough、client_tty
│ ├── hook/ # cursor/claude stdin 解析
│ ├── config/ # TOML 配置
│ └── install/ # hook 配置合并写入
├── docs/superpowers/specs/
│ └── 2026-05-26-agent-notify-design.md
├── go.mod
├── Makefile
└── README.md
```
---
## 12. 参考资料
- [Ghostty OSC 实现](https://github.com/ghostty-org/ghostty/blob/main/src/terminal/osc.zig)
- [Claude Code Hooks - terminalSequence](https://code.claude.com/docs/en/hooks)
- [Cursor Hooks 文档](https://cursor.com/docs/hooks)
- [tmux OSC passthroughlinw1995](https://www.linw1995.com/en/agent-native-system-notifications/)
- [soloterm/tnotify](https://github.com/soloterm/tnotify)
+2
View File
@@ -1,3 +1,5 @@
module github.com/longbin/agent-notify
go 1.22.2
require github.com/BurntSushi/toml v1.6.0 // indirect
+2
View File
@@ -0,0 +1,2 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+102
View File
@@ -0,0 +1,102 @@
package config
import (
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
type Config struct {
Events Events `toml:"events"`
Notify Notify `toml:"notify"`
}
type Events struct {
Stop bool `toml:"stop"`
Idle bool `toml:"idle"`
Tool bool `toml:"tool"`
}
type Notify struct {
Protocol string `toml:"protocol"`
TitleTemplate string `toml:"title_template"`
BodyStop string `toml:"body_stop"`
BodyIdle string `toml:"body_idle"`
BodyTool string `toml:"body_tool"`
}
func Default() Config {
return Config{
Events: Events{Stop: true, Idle: false, Tool: false},
Notify: Notify{
Protocol: "osc777",
TitleTemplate: "{agent} — {context}",
BodyStop: "等待输入",
BodyIdle: "空闲 60s+,等待输入",
BodyTool: "工具执行完成",
},
}
}
func DefaultPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml")
}
func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return cfg, nil
}
if _, err := toml.DecodeFile(path, &cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func LoadDefault() (Config, error) {
return Load(DefaultPath())
}
func (c Config) EventEnabled(event string) bool {
switch strings.ToLower(event) {
case "stop":
return c.Events.Stop
case "idle":
return c.Events.Idle
case "tool":
return c.Events.Tool
default:
return false
}
}
func (c Config) BodyForEvent(event string) string {
switch strings.ToLower(event) {
case "idle":
return c.Notify.BodyIdle
case "tool":
return c.Notify.BodyTool
default:
return c.Notify.BodyStop
}
}
func (c Config) WriteDefault(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if os.IsExist(err) {
return nil
}
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(Default())
}
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfig(t *testing.T) {
cfg := Default()
if !cfg.Events.Stop {
t.Fatal("expected stop=true by default")
}
if cfg.Events.Idle {
t.Fatal("expected idle=false by default")
}
if cfg.Notify.Protocol != "osc777" {
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
}
}
func TestLoadFromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `
[events]
stop = false
tool = true
[notify]
body_stop = "custom stop"
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Events.Stop {
t.Fatal("expected stop=false")
}
if !cfg.Events.Tool {
t.Fatal("expected tool=true")
}
if cfg.Notify.BodyStop != "custom stop" {
t.Fatalf("got %q", cfg.Notify.BodyStop)
}
}
func TestEventEnabled(t *testing.T) {
cfg := Default()
if !cfg.EventEnabled("stop") {
t.Fatal("expected stop enabled by default")
}
if !cfg.EventEnabled("STOP") {
t.Fatal("expected case-insensitive match")
}
cfg.Events.Stop = false
if cfg.EventEnabled("stop") {
t.Fatal("expected stop disabled")
}
}
+69
View File
@@ -0,0 +1,69 @@
package context
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
type Meta struct {
Agent string
CWD string
Context string
Event string
}
func Render(tmpl string, m Meta) string {
ctx := m.ResolveContext(m.Context)
out := strings.ReplaceAll(tmpl, "{agent}", m.Agent)
out = strings.ReplaceAll(out, "{context}", ctx)
return out
}
func (m Meta) ResolveContext(window string) string {
if window != "" {
return window
}
if m.Context != "" {
return m.Context
}
cwd := m.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
if cwd == "" {
return "unknown"
}
return filepath.Base(cwd)
}
func TmuxWindowName() string {
if os.Getenv("TMUX") == "" {
return ""
}
out, err := exec.Command("tmux", "display-message", "-p", "#{window_name}").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func MetaFromEnv(agent, event string) Meta {
cwd := os.Getenv("AGENT_NOTIFY_CWD")
if cwd == "" {
cwd, _ = os.Getwd()
}
if a := os.Getenv("AGENT_NOTIFY_AGENT"); a != "" {
agent = a
}
if e := os.Getenv("AGENT_NOTIFY_EVENT"); e != "" {
event = e
}
return Meta{
Agent: agent,
CWD: cwd,
Context: TmuxWindowName(),
Event: event,
}
}
+26
View File
@@ -0,0 +1,26 @@
package context
import "testing"
func TestRenderTitle(t *testing.T) {
meta := Meta{Agent: "Cursor", Context: "myapp"}
got := Render("{agent} — {context}", meta)
want := "Cursor — myapp"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestContextFromCWD(t *testing.T) {
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
if meta.ResolveContext("") != "myapp" {
t.Fatalf("got %q", meta.ResolveContext(""))
}
}
func TestContextPrefersWindow(t *testing.T) {
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
if meta.ResolveContext("tmux-win") != "tmux-win" {
t.Fatalf("expected window name")
}
}
+41
View File
@@ -0,0 +1,41 @@
package hook
import (
"encoding/json"
"io"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/context"
"github.com/longbin/agent-notify/internal/notify"
)
type claudePayload struct {
StopHookActive bool `json:"stop_hook_active"`
Message string `json:"message"`
}
type claudeResponse struct {
TerminalSequence string `json:"terminalSequence,omitempty"`
}
func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error {
if !cfg.EventEnabled(event) {
_, err := io.WriteString(w, "{}\n")
return err
}
var payload claudePayload
_ = json.NewDecoder(r).Decode(&payload)
if event == "stop" && payload.StopHookActive {
_, err := io.WriteString(w, "{}\n")
return err
}
meta := context.MetaFromEnv("Claude", event)
title := context.Render(cfg.Notify.TitleTemplate, meta)
body := cfg.BodyForEvent(event)
seq := notify.BuildSequence(cfg.Notify.Protocol, title, body)
resp := claudeResponse{TerminalSequence: seq}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(resp)
}
+30
View File
@@ -0,0 +1,30 @@
package hook
import (
"encoding/json"
"io"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/context"
"github.com/longbin/agent-notify/internal/notify"
)
type cursorPayload struct {
WorkspaceRoots []string `json:"workspace_roots"`
}
func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error {
if !cfg.EventEnabled(event) {
return nil
}
var payload cursorPayload
_ = json.NewDecoder(r).Decode(&payload)
meta := context.MetaFromEnv("Cursor", event)
if len(payload.WorkspaceRoots) > 0 {
meta.CWD = payload.WorkspaceRoots[0]
}
title := context.Render(cfg.Notify.TitleTemplate, meta)
body := cfg.BodyForEvent(event)
return notify.SendAuto(cfg.Notify.Protocol, title, body)
}
+47
View File
@@ -0,0 +1,47 @@
package hook
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/longbin/agent-notify/internal/config"
)
func TestCursorStopHookDisabled(t *testing.T) {
cfg := config.Default()
cfg.Events.Stop = false
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
}
func TestClaudeStopOutputsTerminalSequence(t *testing.T) {
cfg := config.Default()
var out bytes.Buffer
err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out)
if err != nil {
t.Fatal(err)
}
var resp map[string]string
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp["terminalSequence"], "777;notify") {
t.Fatalf("bad sequence: %v", resp)
}
}
func TestClaudeStopHookActiveSkips(t *testing.T) {
cfg := config.Default()
var out bytes.Buffer
err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out.String()) != "{}" {
t.Fatalf("expected {}, got %q", out.String())
}
}
+104
View File
@@ -0,0 +1,104 @@
package install
import (
"encoding/json"
"os"
"path/filepath"
"github.com/longbin/agent-notify/internal/config"
)
const cursorHookCmd = "agent-notify hook cursor stop"
const cursorToolCmd = "agent-notify hook cursor tool"
const claudeStopCmd = "agent-notify hook claude stop"
const claudeIdleCmd = "agent-notify hook claude idle"
func CursorHooksPath() string {
return filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json")
}
func ClaudeSettingsPath() string {
return filepath.Join(os.Getenv("HOME"), ".claude", "settings.json")
}
func MergeCursorHooks(path string, force bool) error {
doc := map[string]any{"version": 1, "hooks": map[string]any{}}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &doc)
}
hooks, _ := doc["hooks"].(map[string]any)
if hooks == nil {
hooks = map[string]any{}
doc["hooks"] = hooks
}
addHook(hooks, "stop", cursorHookCmd, force)
cfg, _ := config.LoadDefault()
if cfg.Events.Tool {
addHook(hooks, "afterShellExecution", cursorToolCmd, force)
}
return writeJSON(path, doc)
}
func addHook(hooks map[string]any, name, command string, force bool) {
if existing, ok := hooks[name]; ok && !force {
_ = existing
return
}
hooks[name] = []any{map[string]string{"command": command}}
}
func MergeClaudeSettings(path string, force bool) error {
doc := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &doc)
}
hooks, _ := doc["hooks"].(map[string]any)
if hooks == nil {
hooks = map[string]any{}
doc["hooks"] = hooks
}
setClaudeHook(hooks, "Stop", claudeStopCmd, force)
cfg, _ := config.LoadDefault()
if cfg.Events.Idle {
setClaudeHook(hooks, "Notification", claudeIdleCmd, force)
}
return writeJSON(path, doc)
}
func setClaudeHook(hooks map[string]any, event, command string, force bool) {
if _, ok := hooks[event]; ok && !force {
return
}
hooks[event] = []any{
map[string]any{
"hooks": []any{
map[string]string{
"type": "command",
"command": command,
},
},
},
}
}
func writeJSON(path string, doc any) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0644)
}
func InstallAll(force bool) error {
if err := config.Default().WriteDefault(config.DefaultPath()); err != nil {
return err
}
if err := MergeCursorHooks(CursorHooksPath(), force); err != nil {
return err
}
return MergeClaudeSettings(ClaudeSettingsPath(), force)
}
+44
View File
@@ -0,0 +1,44 @@
package install
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestMergeCursorHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
existing := `{"version":1,"hooks":{"beforeShellExecution":[{"command":"other"}]}}`
os.WriteFile(path, []byte(existing), 0644)
if err := MergeCursorHooks(path, false); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
var doc map[string]any
json.Unmarshal(data, &doc)
hooks := doc["hooks"].(map[string]any)
stop := hooks["stop"].([]any)
if len(stop) != 1 {
t.Fatalf("expected stop hook added")
}
}
func TestMergeCursorHooksNoOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
existing := `{"version":1,"hooks":{"stop":[{"command":"existing"}]}}`
os.WriteFile(path, []byte(existing), 0644)
MergeCursorHooks(path, false)
data, _ := os.ReadFile(path)
var doc map[string]any
json.Unmarshal(data, &doc)
hooks := doc["hooks"].(map[string]any)
stop := hooks["stop"].([]any)
entry := stop[0].(map[string]any)
if entry["command"] != "existing" {
t.Fatal("should not overwrite existing stop hook without force")
}
}
+16
View File
@@ -0,0 +1,16 @@
package notify
import "strings"
func BuildSequence(protocol, title, body string) string {
switch protocol {
case "osc9":
return "\033]9;" + escapeOSCField(body) + "\007"
default:
return "\033]777;notify;" + escapeOSCField(title) + ";" + escapeOSCField(body) + "\007"
}
}
func escapeOSCField(s string) string {
return strings.ReplaceAll(s, ";", "\\;")
}
+26
View File
@@ -0,0 +1,26 @@
package notify
import "testing"
func TestBuildOSC777(t *testing.T) {
seq := BuildSequence("osc777", "Claude — proj", "等待输入")
want := "\033]777;notify;Claude — proj;等待输入\007"
if seq != want {
t.Fatalf("got %q want %q", seq, want)
}
}
func TestBuildOSC9(t *testing.T) {
seq := BuildSequence("osc9", "ignored", "等待输入")
want := "\033]9;等待输入\007"
if seq != want {
t.Fatalf("got %q want %q", seq, want)
}
}
func TestSemicolonInTitleEscaped(t *testing.T) {
seq := BuildSequence("osc777", "a;b", "body")
if seq != "\033]777;notify;a\\;b;body\007" {
t.Fatalf("unexpected escape: %q", seq)
}
}
+71
View File
@@ -0,0 +1,71 @@
package notify
import (
"fmt"
"io"
"os"
"github.com/longbin/agent-notify/internal/tmux"
)
type SendOptions struct {
Protocol string
Title string
Body string
Writer io.Writer
InTmux bool
Layers int
ClientTTY string
}
func Send(opts SendOptions) error {
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
w := opts.Writer
if w == nil {
w = os.Stdout
}
if opts.InTmux && opts.ClientTTY != "" {
f, err := os.OpenFile(opts.ClientTTY, os.O_WRONLY, 0)
if err == nil {
defer f.Close()
_, err = io.WriteString(f, seq)
return err
}
}
out := seq
if opts.InTmux {
layers := opts.Layers
if layers <= 0 {
layers = 1
}
out = tmux.WrapPassthroughLayers(seq, layers)
}
_, err := io.WriteString(w, out)
return err
}
func SendAuto(protocol, title, body string) error {
inTmux := tmux.InTmux()
clientTTY, _ := tmux.ClientTTY()
layers := 0
if inTmux {
layers = 1
}
return Send(SendOptions{
Protocol: protocol,
Title: title,
Body: body,
InTmux: inTmux,
Layers: layers,
ClientTTY: clientTTY,
})
}
func TestNotification(title, body string) error {
if err := SendAuto("osc777", title, body); err != nil {
return fmt.Errorf("send test notification: %w", err)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package notify
import (
"bytes"
"testing"
)
func TestSendDirect(t *testing.T) {
var buf bytes.Buffer
err := Send(SendOptions{
Protocol: "osc777",
Title: "Cursor — app",
Body: "等待输入",
Writer: &buf,
InTmux: false,
})
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte("777;notify")) {
t.Fatalf("missing osc777: %q", buf.String())
}
}
func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) {
var buf bytes.Buffer
err := Send(SendOptions{
Protocol: "osc777",
Title: "t",
Body: "b",
Writer: &buf,
InTmux: true,
Layers: 1,
ClientTTY: "",
})
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(buf.Bytes(), []byte("\033Ptmux;")) {
t.Fatalf("expected passthrough prefix, got %q", buf.String())
}
}
+46
View File
@@ -0,0 +1,46 @@
package tmux
import (
"os"
"os/exec"
"strings"
)
func InTmux() bool {
return os.Getenv("TMUX") != ""
}
func ClientTTY() (string, error) {
if !InTmux() {
return "", nil
}
out, err := exec.Command("tmux", "display-message", "-p", "#{client_tty}").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func WrapPassthrough(seq string) string {
return "\033Ptmux;\033" + seq + "\033\\"
}
func WrapPassthroughLayers(seq string, layers int) string {
out := seq
for i := 0; i < layers; i++ {
out = WrapPassthrough(out)
}
return out
}
func AllowPassthroughEnabled() (bool, string, error) {
if !InTmux() {
return true, "", nil
}
out, err := exec.Command("tmux", "show-option", "-gv", "allow-passthrough").Output()
if err != nil {
return false, "", err
}
val := strings.TrimSpace(string(out))
return val == "on" || val == "all", val, nil
}
+29
View File
@@ -0,0 +1,29 @@
package tmux
import "testing"
func TestInTmux(t *testing.T) {
t.Setenv("TMUX", "/tmp/tmux-123,1,0")
if !InTmux() {
t.Fatal("expected InTmux true")
}
}
func TestWrapPassthroughSingle(t *testing.T) {
inner := "\033]777;notify;t;b\007"
got := WrapPassthrough(inner)
want := "\033Ptmux;\033" + inner + "\033\\"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestWrapPassthroughNested(t *testing.T) {
inner := "\033]777;notify;t;b\007"
got := WrapPassthroughLayers(inner, 2)
once := WrapPassthrough(inner)
twice := WrapPassthrough(once)
if got != twice {
t.Fatalf("nested wrap mismatch")
}
}