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:
@@ -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. 无 tmux(Ghostty 直接)
|
||||||
|
agent-notify test
|
||||||
|
|
||||||
|
# 2. 本地 tmux
|
||||||
|
tmux new-session -d 'agent-notify test'
|
||||||
|
|
||||||
|
# 3. 远程 tmux(SSH 到远程后在 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` 字段输出 OSC(hook 进程无 TTY)
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make build
|
||||||
|
```
|
||||||
Executable
BIN
Binary file not shown.
+113
-11
@@ -1,8 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"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() {
|
func main() {
|
||||||
@@ -10,23 +18,117 @@ func main() {
|
|||||||
printUsage()
|
printUsage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
if err := run(os.Args[1], os.Args[2:]); err != nil {
|
||||||
case "send", "hook", "install", "test", "doctor", "help", "-h", "--help":
|
fmt.Fprintln(os.Stderr, "agent-notify:", err)
|
||||||
fmt.Fprintf(os.Stderr, "agent-notify: %s not implemented yet\n", os.Args[1])
|
|
||||||
os.Exit(1)
|
|
||||||
default:
|
|
||||||
printUsage()
|
|
||||||
os.Exit(1)
|
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() {
|
func printUsage() {
|
||||||
fmt.Fprint(os.Stderr, `Usage: agent-notify <command>
|
fmt.Fprint(os.Stderr, `Usage: agent-notify <command>
|
||||||
Commands:
|
Commands:
|
||||||
send Send a notification
|
send [--title T] [--body B] [--event stop|idle|tool]
|
||||||
hook Agent hook entrypoint
|
hook cursor stop|tool
|
||||||
install Install hook configs
|
hook claude stop|idle
|
||||||
test Send test notification
|
install [--all] [--force]
|
||||||
doctor Check environment
|
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 CLI(Cursor 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` hook(CLI 中不可靠)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 | 直接写 stdout(Cursor)或返回 terminalSequence(Claude) |
|
||||||
|
| tmux 无 client_tty | 回退 DCS passthrough |
|
||||||
|
| config 中 event 关闭 | hook 静默 exit 0 |
|
||||||
|
| Claude stop_hook_active=true | 不发送通知,输出 `{}` |
|
||||||
|
| doctor 发现 allow-passthrough 未开 | 打印修复提示,不阻断 install |
|
||||||
|
| OSC 发送失败 | exit 1,stderr 输出原因(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 passthrough(linw1995)](https://www.linw1995.com/en/agent-native-system-notifications/)
|
||||||
|
- [soloterm/tnotify](https://github.com/soloterm/tnotify)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
module github.com/longbin/agent-notify
|
module github.com/longbin/agent-notify
|
||||||
|
|
||||||
go 1.22.2
|
go 1.22.2
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ";", "\\;")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user