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
|
||||
|
||||
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 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
|
||||
|
||||
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