feat: add inbox

This commit is contained in:
2026-05-26 17:49:31 +08:00
parent f7fcd0e966
commit 68b5c8bc81
25 changed files with 2108 additions and 1 deletions
+41
View File
@@ -48,6 +48,14 @@ tool = false # shell/工具执行结束
protocol = "osc777"
title_template = "{agent} — {context}" # context = 工作目录名
body_stop = "等待输入"
[inbox]
enabled = true
socket = "/run/user/1000/agent-notify.sock"
remote_socket = "/tmp/agent-notify-longbin.sock"
addr = "127.0.0.1:17777"
fallback_local = true
timeout_ms = 500
```
## 命令
@@ -56,12 +64,45 @@ body_stop = "等待输入"
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 inbox serve # 本地接收远程通知记录
agent-notify inbox list # 列出未处理通知
agent-notify inbox show <id>
agent-notify inbox done <id>
agent-notify inbox tui # Bubble Tea TUI
agent-notify inbox ssh-config install # 自动写 ~/.ssh/config RemoteForward
agent-notify test cursor [-v]
agent-notify test claude [--apply]
agent-notify doctor
agent-notify install --all [--force]
```
## 本地汇总 Inbox
在本地 Ghostty 所在机器启动接收服务:
```bash
agent-notify inbox serve
```
让命令自动写 SSH `RemoteForward` 配置:
```bash
agent-notify inbox ssh-config install
```
命令会在 `~/.ssh/config` 写入一个托管块,并把写入内容打印出来。已有 SSH 连接需要重连后才会生效。
默认写入的转发形式是:远程创建 `/tmp/agent-notify-$USER.sock`,转发到本地 `$XDG_RUNTIME_DIR/agent-notify.sock`。远程 hook 会尝试通过这个 SSH 反向转发把记录写回本地 inbox;如果本地接收服务不可用,会 fallback 写到远程机器自己的 `~/.local/state/agent-notify/inbox.jsonl`,避免丢记录。
查看和处理:
```bash
agent-notify inbox list
agent-notify inbox show <id>
agent-notify inbox done <id>
agent-notify inbox tui
```
## Hook 配置位置
| Agent | 配置文件 | Hook 事件 |
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox"
)
func cmdInbox(args []string, stdout, stderr io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
}
switch args[0] {
case "list":
return inboxList(args[1:], stdout)
case "show":
return inboxShow(args[1:], stdout)
case "done":
return inboxDone(args[1:], stdout)
case "rm":
return inboxRemove(args[1:], stdout)
case "clear":
return inboxClear(args[1:], stdout)
case "serve":
return inboxServe(args[1:], stderr)
case "ssh-config":
return inboxSSHConfig(args[1:], stdout)
case "tui":
return inbox.RunTUI(inbox.NewStore(""))
default:
return fmt.Errorf("unknown inbox command %q", args[0])
}
}
func inboxList(args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("inbox list", flag.ExitOnError)
all := fs.Bool("all", false, "include all records")
done := fs.Bool("done", false, "show done records")
_ = fs.Parse(args)
records, err := inbox.NewStore("").List()
if err != nil {
return err
}
for _, rec := range records {
if !*all && !*done && rec.Status != inbox.StatusPending {
continue
}
if *done && rec.Status != inbox.StatusDone {
continue
}
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s/%s\t%s\t%s\n",
rec.ID, rec.Status, rec.Host, rec.Agent, rec.Event, rec.CWD, rec.Title)
}
return nil
}
func inboxShow(args []string, stdout io.Writer) error {
if len(args) != 1 {
return fmt.Errorf("usage: agent-notify inbox show <id>")
}
rec, ok, err := findRecord(args[0])
if err != nil {
return err
}
if !ok {
return fmt.Errorf("inbox record %q not found", args[0])
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(rec)
}
func inboxDone(args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox done <id...>")
}
n, err := inbox.NewStore("").MarkDone(args)
if err != nil {
return err
}
fmt.Fprintf(stdout, "marked %d done\n", n)
return nil
}
func inboxRemove(args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox rm <id...>")
}
n, err := inbox.NewStore("").Remove(args)
if err != nil {
return err
}
fmt.Fprintf(stdout, "removed %d\n", n)
return nil
}
func inboxClear(args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("inbox clear", flag.ExitOnError)
done := fs.Bool("done", false, "clear done records only")
_ = fs.Parse(args)
var (
n int
err error
)
if *done {
n, err = inbox.NewStore("").ClearDone()
} else {
n, err = inbox.NewStore("").ClearAll()
}
if err != nil {
return err
}
fmt.Fprintf(stdout, "cleared %d\n", n)
return nil
}
func inboxServe(args []string, stderr io.Writer) error {
cfg, err := config.LoadDefault()
if err != nil {
return err
}
fs := flag.NewFlagSet("inbox serve", flag.ExitOnError)
socket := fs.String("socket", cfg.Inbox.Socket, "Unix socket path")
addr := fs.String("addr", "", "TCP address")
_ = fs.Parse(args)
handler := inbox.NewHandler(inbox.NewStore(""))
server := &http.Server{Handler: handler}
if *addr != "" {
ln, err := net.Listen("tcp", *addr)
if err != nil {
return err
}
fmt.Fprintf(stderr, "agent-notify inbox listening on tcp %s\n", *addr)
return server.Serve(ln)
}
if err := os.RemoveAll(*socket); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
return err
}
ln, err := net.Listen("unix", *socket)
if err != nil {
return err
}
if err := os.Chmod(*socket, 0600); err != nil {
ln.Close()
return err
}
fmt.Fprintf(stderr, "agent-notify inbox listening on unix %s\n", *socket)
return server.Serve(ln)
}
func inboxSSHConfig(args []string, stdout io.Writer) error {
if len(args) == 0 || args[0] != "install" {
return fmt.Errorf("usage: agent-notify inbox ssh-config install [--path PATH] [--socket PATH]")
}
cfg, err := config.LoadDefault()
if err != nil {
return err
}
fs := flag.NewFlagSet("inbox ssh-config install", flag.ExitOnError)
path := fs.String("path", inbox.DefaultSSHConfigPath(), "SSH config path")
socket := fs.String("socket", cfg.Inbox.Socket, "local agent-notify Unix socket path")
remoteSocket := fs.String("remote-socket", cfg.Inbox.RemoteSocket, "remote agent-notify Unix socket path")
_ = fs.Parse(args[1:])
block, err := inbox.InstallSSHConfig(*path, *remoteSocket, *socket)
if err != nil {
return err
}
fmt.Fprintf(stdout, "wrote SSH config: %s\n\n%s\nReconnect existing SSH sessions for RemoteForward to take effect.\n", *path, block)
return nil
}
func findRecord(id string) (inbox.Record, bool, error) {
records, err := inbox.NewStore("").List()
if err != nil {
return inbox.Record{}, false, err
}
for _, rec := range records {
if rec.ID == id {
return rec, true, nil
}
}
return inbox.Record{}, false, nil
}
func uploadTestRecord(cfg config.Config) error {
rec := inbox.BuildRecord(inbox.BuildInput{
Agent: "agent-notify",
Event: "test",
Title: "agent-notify test",
Body: "inbox test",
Source: inbox.SourceLocal,
})
timeout := time.Duration(cfg.Inbox.TimeoutMS) * time.Millisecond
client := inbox.NewClient(inbox.ClientConfig{Socket: cfg.Inbox.Socket, Addr: cfg.Inbox.Addr, Timeout: timeout})
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return client.Upload(ctx, rec)
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/longbin/agent-notify/internal/inbox"
)
func TestInboxListAndDoneCommands(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store := inbox.NewStore("")
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Host: "host-a", Agent: "Cursor", Event: "stop", CWD: "/tmp/proj", Title: "ready"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "id-1") || !strings.Contains(out.String(), "ready") {
t.Fatalf("unexpected list output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"done", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "marked 1 done") {
t.Fatalf("unexpected done output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "id-1") {
t.Fatalf("done record should be hidden by default: %s", out.String())
}
}
func TestInboxShowAndRemoveCommands(t *testing.T) {
t.Setenv("HOME", t.TempDir())
store := inbox.NewStore("")
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Body: "body text", Title: "title"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := cmdInbox([]string{"show", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "body text") {
t.Fatalf("unexpected show output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"rm", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "removed 1") {
t.Fatalf("unexpected rm output: %s", out.String())
}
}
func TestInboxSSHConfigInstallCommandPrintsBlock(t *testing.T) {
t.Setenv("HOME", t.TempDir())
path := filepath.Join(t.TempDir(), "ssh_config")
var out bytes.Buffer
if err := cmdInbox([]string{"ssh-config", "install", "--path", path, "--remote-socket", "/tmp/remote-agent-notify.sock", "--socket", "/run/user/1000/agent-notify.sock"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "RemoteForward /tmp/remote-agent-notify.sock /run/user/1000/agent-notify.sock") {
t.Fatalf("config not written: %s", string(data))
}
if !strings.Contains(out.String(), "wrote SSH config") || !strings.Contains(out.String(), "# BEGIN agent-notify inbox") {
t.Fatalf("block not printed: %s", out.String())
}
}
+3
View File
@@ -39,6 +39,8 @@ func run(cmd string, args []string) error {
return cmdDoctor()
case "logs":
return cmdLogs(args)
case "inbox":
return cmdInbox(args, os.Stdout, os.Stderr)
case "version", "-V":
return cmdVersion()
case "help", "-h", "--help":
@@ -202,6 +204,7 @@ Commands:
test cursor [--try-all]
test claude [--apply]
logs [--tail 30]
inbox <list|show|done|rm|clear|serve|ssh-config>
version
doctor
`)
@@ -0,0 +1,537 @@
# Agent Notify Inbox Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Collect agent notifications from local and remote machines into a local pending list that can be viewed first via CLI and later via a Bubble Tea TUI.
**Architecture:** Add an `internal/inbox` package for records, JSONL storage, local receiver server, remote uploader, and SSH config setup. Hooks will keep sending Ghostty notifications, then attempt to upload an inbox record to the configured local receiver; if upload fails, they write a local fallback JSONL record. CLI commands operate on the local JSONL store, while `inbox serve` receives remote records over Unix socket or TCP through SSH `RemoteForward`.
**Tech Stack:** Go stdlib (`net/http`, `net`, JSONL files, `flag`), existing `config`, `context`, `hook`, `logx`, `tmux`; Bubble Tea added only when implementing the TUI phase.
---
### Task 1: Config Model For Inbox
**Files:**
- Modify: `internal/config/config.go`
- Modify: `internal/config/config_test.go`
**Step 1: Add failing config tests**
Add tests that assert:
- `config.Default().Inbox.Enabled == true`
- default socket path is non-empty and prefers `$XDG_RUNTIME_DIR/agent-notify.sock` when available
- default TCP address is `127.0.0.1:17777`
- TOML can override `[inbox] enabled`, `socket`, `addr`, `fallback_local`, and `timeout_ms`
**Step 2: Run the focused test**
Run: `go test ./internal/config`
Expected: FAIL because `Inbox` config does not exist yet.
**Step 3: Implement inbox config**
Add:
```go
type Inbox struct {
Enabled bool `toml:"enabled"`
Socket string `toml:"socket"`
Addr string `toml:"addr"`
FallbackLocal bool `toml:"fallback_local"`
TimeoutMS int `toml:"timeout_ms"`
}
```
Update `Config` and `Default()`:
- `Enabled: true`
- `Socket: DefaultInboxSocket()`
- `Addr: "127.0.0.1:17777"`
- `FallbackLocal: true`
- `TimeoutMS: 500`
Add `DefaultInboxSocket()` in `config`:
- if `$XDG_RUNTIME_DIR` is set, return `$XDG_RUNTIME_DIR/agent-notify.sock`
- otherwise return `$HOME/.local/state/agent-notify/agent-notify.sock`
**Step 4: Run focused test again**
Run: `go test ./internal/config`
Expected: PASS.
---
### Task 2: Inbox Record And JSONL Store
**Files:**
- Create: `internal/inbox/record.go`
- Create: `internal/inbox/store.go`
- Create: `internal/inbox/store_test.go`
**Step 1: Add failing store tests**
Cover:
- appending records creates `~/.local/state/agent-notify/inbox.jsonl`
- `List` returns records in file order
- `Pending` filters `status == "pending"`
- `MarkDone(ids...)` rewrites only matching pending records to `done`
- invalid JSONL lines are skipped, not fatal
**Step 2: Run focused test**
Run: `go test ./internal/inbox`
Expected: FAIL because package does not exist yet.
**Step 3: Implement record model**
Record fields:
- `id`
- `time`
- `host`
- `agent`
- `event`
- `cwd`
- `title`
- `body`
- `status`
- `source`
- `tmux`
Use a nested `TmuxContext` with `session`, `window`, `pane`.
Generate IDs with timestamp + random suffix from stdlib, for example `20260526-165001-a1b2c3`.
**Step 4: Implement JSONL store**
Store path:
- default `~/.local/state/agent-notify/inbox.jsonl`
- injectable path for tests
Operations:
- `Append(record Record) error`
- `List() ([]Record, error)`
- `Pending() ([]Record, error)`
- `MarkDone(ids []string) (int, error)`
- `Remove(ids []string) (int, error)`
- `ClearDone() (int, error)`
- `ClearAll() (int, error)`
Rewrite operations should write to a temp file and rename.
**Step 5: Run tests**
Run: `go test ./internal/inbox`
Expected: PASS.
---
### Task 3: Inbox HTTP Receiver And Uploader
**Files:**
- Create: `internal/inbox/server.go`
- Create: `internal/inbox/client.go`
- Create: `internal/inbox/server_test.go`
**Step 1: Add failing server/client tests**
Cover:
- `POST /inbox` appends a valid record
- invalid method returns 405
- invalid JSON returns 400
- client can POST to an HTTP test server
- client timeout is respected by using a small timeout
**Step 2: Run focused test**
Run: `go test ./internal/inbox`
Expected: FAIL for missing server/client.
**Step 3: Implement receiver**
Implement an HTTP handler:
- `POST /inbox`
- decode `Record`
- fill missing `id`, `time`, and `status=pending`
- append to store
- return JSON `{ "ok": true, "id": "..." }`
**Step 4: Implement uploader**
Uploader behavior:
- prefer Unix socket when configured
- support TCP address fallback through `http://127.0.0.1:17777/inbox`
- timeout from config
- no retries in hook path
For Unix socket, use an `http.Client` with custom `Transport.DialContext`.
**Step 5: Run focused tests**
Run: `go test ./internal/inbox`
Expected: PASS.
---
### Task 4: Build Records From Hook Context
**Files:**
- Create: `internal/inbox/build.go`
- Create: `internal/inbox/build_test.go`
- Modify: `internal/tmux` only if existing APIs do not expose session/window/pane cleanly
**Step 1: Add failing build tests**
Cover:
- host is populated from `os.Hostname`
- cwd/title/body/agent/event are copied
- tmux fields are empty outside tmux
- source can be `local` or `remote`
**Step 2: Run focused test**
Run: `go test ./internal/inbox`
Expected: FAIL.
**Step 3: Implement builder**
Add a builder function that takes agent, event, cwd, title, body and returns a complete pending `Record`.
If tmux env vars are enough, capture:
- `TMUX_PANE` as pane
- session/window best effort from `tmux display-message` if current tmux helpers already support shelling out
Do not block hook completion if tmux metadata cannot be collected.
**Step 4: Run test**
Run: `go test ./internal/inbox`
Expected: PASS.
---
### Task 5: Hook Integration With Upload And Local Fallback
**Files:**
- Modify: `internal/hook/cursor.go`
- Modify: `internal/hook/claude.go`
- Modify: `internal/hook/hook_test.go`
**Step 1: Add failing hook tests**
Cover:
- enabled cursor hook calls the inbox uploader once after notification path
- disabled hook does not record
- upload failure with `FallbackLocal=true` appends local fallback
- upload failure with `FallbackLocal=false` does not append fallback
- Claude hook records after writing `terminalSequence`
Use package-level function variables for uploader/store append just like existing `sendForHook` stubs.
**Step 2: Run focused tests**
Run: `go test ./internal/hook`
Expected: FAIL.
**Step 3: Implement integration**
After notification is successfully generated/sent:
- build inbox record
- if `cfg.Inbox.Enabled`, try upload
- on upload error, log `inbox upload failed`
- if fallback enabled, append local record with `source="fallback"`
Avoid returning inbox upload/fallback errors from hook unless local fallback write itself is unexpectedly fatal and needs surfacing. Notification behavior should remain primary.
**Step 4: Run focused tests**
Run: `go test ./internal/hook`
Expected: PASS.
---
### Task 6: CLI Inbox Commands
**Files:**
- Modify: `cmd/agent-notify/main.go`
- Create: `cmd/agent-notify/inbox.go`
- Create: `cmd/agent-notify/inbox_test.go`
**Step 1: Add failing command tests**
Cover:
- `inbox list` prints pending records
- `inbox list --all` includes done records
- `inbox show <id>` prints details
- `inbox done <id...>` marks records done and prints count
- `inbox rm <id...>` removes records
- `inbox clear --done` clears done records
**Step 2: Run command tests**
Run: `go test ./cmd/agent-notify`
Expected: FAIL.
**Step 3: Implement command routing**
Add `inbox` to top-level `run`.
Subcommands:
- `agent-notify inbox list [--all|--done|--pending]`
- `agent-notify inbox show <id>`
- `agent-notify inbox done <id...>`
- `agent-notify inbox rm <id...>`
- `agent-notify inbox clear [--done]`
Keep output simple and scriptable:
- one line per list item
- short ID, local time, status, host, agent/event, cwd, title
**Step 4: Run focused tests**
Run: `go test ./cmd/agent-notify`
Expected: PASS.
---
### Task 7: Inbox Serve Command
**Files:**
- Modify: `cmd/agent-notify/inbox.go`
- Create or modify: `cmd/agent-notify/inbox_test.go`
**Step 1: Add failing tests**
Cover parsing and listener selection:
- `serve --socket <path>` uses Unix socket mode
- `serve --addr 127.0.0.1:17777` uses TCP mode
- missing flags default to config socket
Do not write an infinite server test through `main`; test smaller functions.
**Step 2: Run focused test**
Run: `go test ./cmd/agent-notify`
Expected: FAIL.
**Step 3: Implement serve**
Add:
- `agent-notify inbox serve [--socket PATH] [--addr ADDR]`
Behavior:
- default to Unix socket from config
- remove stale socket before listen
- chmod socket `0600`
- print listening location to stderr
- serve until interrupted
**Step 4: Run focused tests**
Run: `go test ./cmd/agent-notify`
Expected: PASS.
---
### Task 8: SSH Config Auto Writer
**Files:**
- Create: `internal/inbox/sshconfig.go`
- Create: `internal/inbox/sshconfig_test.go`
- Modify: `cmd/agent-notify/inbox.go`
- Modify: `cmd/agent-notify/inbox_test.go`
**Step 1: Add failing SSH config tests**
Cover:
- empty file gets a managed block
- existing managed block is replaced, not duplicated
- unrelated user config is preserved
- generated block uses the configured socket path
- command returns the exact block so CLI can print it after writing
**Step 2: Run focused tests**
Run: `go test ./internal/inbox ./cmd/agent-notify`
Expected: FAIL.
**Step 3: Implement managed block**
Use markers:
```text
# BEGIN agent-notify inbox
Host *
RemoteForward <socket> <socket>
ExitOnForwardFailure no
ServerAliveInterval 30
# END agent-notify inbox
```
Write to `~/.ssh/config` by default.
Rules:
- create `~/.ssh` as `0700`
- create config as `0600`
- preserve existing content
- replace old managed block idempotently
- do not parse or modify other `Host` sections
**Step 4: Add CLI command**
Add:
- `agent-notify inbox ssh-config install [--path PATH] [--socket PATH]`
After writing, print:
- target file path
- the exact managed block that was written
- one reminder that existing SSH sessions must reconnect for `RemoteForward` to take effect
**Step 5: Run focused tests**
Run: `go test ./internal/inbox ./cmd/agent-notify`
Expected: PASS.
---
### Task 9: Documentation For CLI Inbox
**Files:**
- Modify: `README.md`
**Step 1: Document local receiver setup**
Add:
```bash
agent-notify inbox serve
```
**Step 2: Document SSH config auto setup**
Add:
```bash
agent-notify inbox ssh-config install
```
Explain that the command writes a managed `Host *` block and prints it after writing.
**Step 3: Document list workflow**
Add:
```bash
agent-notify inbox list
agent-notify inbox show <id>
agent-notify inbox done <id>
```
**Step 4: Run docs-adjacent checks**
Run: `go test ./...`
Expected: PASS.
---
### Task 10: Bubble Tea TUI
**Files:**
- Modify: `go.mod`
- Create: `internal/inbox/tui.go`
- Modify: `cmd/agent-notify/inbox.go`
**Step 1: Add dependency**
Run:
```bash
go get github.com/charmbracelet/bubbletea
```
**Step 2: Implement initial TUI**
Add:
- `agent-notify inbox tui`
Features:
- list pending items
- `j/k` or arrows move
- `enter` opens details in-place
- `d` marks selected item done
- `r` reloads file
- `q` quits
No Ghostty/tmux jump behavior in this phase.
**Step 3: Run full verification**
Run:
```bash
go test ./...
make build
```
Expected: PASS.
---
### Task 11: Final Verification
**Files:**
- All touched files
**Step 1: Run full test suite**
Run:
```bash
go test ./...
```
Expected: PASS.
**Step 2: Build binary**
Run:
```bash
make build
```
Expected: PASS.
**Step 3: Manual smoke test**
Run:
```bash
tmpdir="$(mktemp -d)"
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox ssh-config install
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox serve --socket "$tmpdir/agent-notify.sock"
```
In another shell, send a test POST or run a hook simulation and confirm:
```bash
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox list
```
Expected: the test record appears as pending.
+20 -1
View File
@@ -2,4 +2,23 @@ module github.com/longbin/agent-notify
go 1.22.2
require github.com/BurntSushi/toml v1.6.0 // indirect
require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbletea v0.26.6
)
require (
github.com/charmbracelet/x/ansi v0.4.5 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+29
View File
@@ -1,2 +1,31 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=
github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+33
View File
@@ -11,6 +11,7 @@ import (
type Config struct {
Events Events `toml:"events"`
Notify Notify `toml:"notify"`
Inbox Inbox `toml:"inbox"`
}
type Events struct {
@@ -28,6 +29,15 @@ type Notify struct {
BodyTool string `toml:"body_tool"`
}
type Inbox struct {
Enabled bool `toml:"enabled"`
Socket string `toml:"socket"`
RemoteSocket string `toml:"remote_socket"`
Addr string `toml:"addr"`
FallbackLocal bool `toml:"fallback_local"`
TimeoutMS int `toml:"timeout_ms"`
}
func Default() Config {
return Config{
Events: Events{Stop: true, Response: true, Idle: false, Tool: false},
@@ -38,9 +48,32 @@ func Default() Config {
BodyIdle: "空闲 60s+,等待输入",
BodyTool: "工具执行完成",
},
Inbox: Inbox{
Enabled: true,
Socket: DefaultInboxSocket(),
RemoteSocket: DefaultInboxRemoteSocket(),
Addr: "127.0.0.1:17777",
FallbackLocal: true,
TimeoutMS: 500,
},
}
}
func DefaultInboxRemoteSocket() string {
user := os.Getenv("USER")
if user == "" {
user = "user"
}
return filepath.Join("/tmp", "agent-notify-"+user+".sock")
}
func DefaultInboxSocket() string {
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
return filepath.Join(runtimeDir, "agent-notify.sock")
}
return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "agent-notify.sock")
}
func DefaultPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml")
}
+56
View File
@@ -7,6 +7,7 @@ import (
)
func TestDefaultConfig(t *testing.T) {
t.Setenv("USER", "example")
cfg := Default()
if !cfg.Events.Stop {
t.Fatal("expected stop=true by default")
@@ -17,6 +18,24 @@ func TestDefaultConfig(t *testing.T) {
if cfg.Notify.Protocol != "osc777" {
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
}
if !cfg.Inbox.Enabled {
t.Fatal("expected inbox enabled by default")
}
if cfg.Inbox.Addr != "127.0.0.1:17777" {
t.Fatalf("expected default inbox addr, got %q", cfg.Inbox.Addr)
}
if cfg.Inbox.Socket == "" {
t.Fatal("expected default inbox socket")
}
if cfg.Inbox.RemoteSocket != "/tmp/agent-notify-example.sock" {
t.Fatalf("expected default remote socket, got %q", cfg.Inbox.RemoteSocket)
}
if !cfg.Inbox.FallbackLocal {
t.Fatal("expected local fallback enabled by default")
}
if cfg.Inbox.TimeoutMS != 500 {
t.Fatalf("expected 500ms timeout, got %d", cfg.Inbox.TimeoutMS)
}
}
func TestLoadFromFile(t *testing.T) {
@@ -29,6 +48,14 @@ tool = true
[notify]
body_stop = "custom stop"
[inbox]
enabled = false
socket = "/tmp/custom-agent-notify.sock"
remote_socket = "/tmp/custom-remote-agent-notify.sock"
addr = "127.0.0.1:18888"
fallback_local = false
timeout_ms = 250
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
@@ -46,6 +73,35 @@ body_stop = "custom stop"
if cfg.Notify.BodyStop != "custom stop" {
t.Fatalf("got %q", cfg.Notify.BodyStop)
}
if cfg.Inbox.Enabled {
t.Fatal("expected inbox disabled")
}
if cfg.Inbox.Socket != "/tmp/custom-agent-notify.sock" {
t.Fatalf("got inbox socket %q", cfg.Inbox.Socket)
}
if cfg.Inbox.RemoteSocket != "/tmp/custom-remote-agent-notify.sock" {
t.Fatalf("got inbox remote socket %q", cfg.Inbox.RemoteSocket)
}
if cfg.Inbox.Addr != "127.0.0.1:18888" {
t.Fatalf("got inbox addr %q", cfg.Inbox.Addr)
}
if cfg.Inbox.FallbackLocal {
t.Fatal("expected fallback_local=false")
}
if cfg.Inbox.TimeoutMS != 250 {
t.Fatalf("got timeout %d", cfg.Inbox.TimeoutMS)
}
}
func TestDefaultInboxSocketPrefersXDGRuntimeDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/run/user/1234")
t.Setenv("HOME", "/home/example")
got := DefaultInboxSocket()
want := "/run/user/1234/agent-notify.sock"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestEventEnabled(t *testing.T) {
+1
View File
@@ -53,5 +53,6 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
return err
}
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title)
recordInbox(cfg, "Claude", event, meta.CWD, title, body)
return nil
}
+1
View File
@@ -64,6 +64,7 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
return err
}
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title)
recordInbox(cfg, "Cursor", event, meta.CWD, title, body)
return nil
}
+104
View File
@@ -3,10 +3,12 @@ package hook
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox"
)
func TestCursorStopHookDisabled(t *testing.T) {
@@ -46,3 +48,105 @@ func TestClaudeStopHookActiveSkips(t *testing.T) {
t.Fatalf("expected {}, got %q", out.String())
}
}
func TestCursorHookUploadsInboxRecord(t *testing.T) {
stubCursorSend(t)
var uploaded inbox.Record
stubInbox(t,
func(rec inbox.Record, cfg config.Config) error {
uploaded = rec
return nil
},
func(rec inbox.Record) error {
t.Fatalf("unexpected fallback append: %+v", rec)
return nil
},
)
cfg := config.Default()
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if uploaded.Agent != "Cursor" || uploaded.Event != "stop" || uploaded.CWD != "/tmp/proj" {
t.Fatalf("unexpected upload record: %+v", uploaded)
}
}
func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
stubCursorSend(t)
var fallback inbox.Record
stubInbox(t,
func(rec inbox.Record, cfg config.Config) error {
return errors.New("offline")
},
func(rec inbox.Record) error {
fallback = rec
return nil
},
)
cfg := config.Default()
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if fallback.Source != inbox.SourceFallback || fallback.Title == "" {
t.Fatalf("unexpected fallback record: %+v", fallback)
}
}
func TestDisabledHookDoesNotRecordInbox(t *testing.T) {
stubCursorSend(t)
stubInbox(t,
func(rec inbox.Record, cfg config.Config) error {
t.Fatalf("unexpected upload: %+v", rec)
return nil
},
func(rec inbox.Record) error {
t.Fatalf("unexpected fallback: %+v", rec)
return nil
},
)
cfg := config.Default()
cfg.Events.Stop = false
if err := RunCursor(bytes.NewReader([]byte(`{}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
}
func TestClaudeHookUploadsInboxRecord(t *testing.T) {
var uploaded inbox.Record
stubInbox(t,
func(rec inbox.Record, cfg config.Config) error {
uploaded = rec
return nil
},
func(rec inbox.Record) error {
t.Fatalf("unexpected fallback append: %+v", rec)
return nil
},
)
cfg := config.Default()
var out bytes.Buffer
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
t.Fatal(err)
}
if uploaded.Agent != "Claude" || uploaded.Event != "stop" || uploaded.Title == "" {
t.Fatalf("unexpected upload record: %+v", uploaded)
}
}
func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, appendLocal func(inbox.Record) error) {
t.Helper()
prevUpload := uploadInboxRecord
prevAppend := appendInboxRecord
uploadInboxRecord = upload
appendInboxRecord = appendLocal
t.Cleanup(func() {
uploadInboxRecord = prevUpload
appendInboxRecord = prevAppend
})
}
+53
View File
@@ -0,0 +1,53 @@
package hook
import (
"context"
"time"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox"
"github.com/longbin/agent-notify/internal/logx"
)
var (
uploadInboxRecord = defaultUploadInboxRecord
appendInboxRecord = func(rec inbox.Record) error {
return inbox.NewStore("").Append(rec)
}
)
func recordInbox(cfg config.Config, agent, event, cwd, title, body string) {
if !cfg.Inbox.Enabled {
return
}
rec := inbox.BuildRecord(inbox.BuildInput{
Agent: agent,
Event: event,
CWD: cwd,
Title: title,
Body: body,
Source: inbox.SourceRemote,
})
if err := uploadInboxRecord(rec, cfg); err != nil {
logx.Append("inbox upload failed: %v", err)
if !cfg.Inbox.FallbackLocal {
return
}
rec.Source = inbox.SourceFallback
if err := appendInboxRecord(rec); err != nil {
logx.Append("inbox fallback append failed: %v", err)
}
}
}
func defaultUploadInboxRecord(rec inbox.Record, cfg config.Config) error {
timeout := time.Duration(cfg.Inbox.TimeoutMS) * time.Millisecond
client := inbox.NewClient(inbox.ClientConfig{
Socket: cfg.Inbox.RemoteSocket,
Addr: cfg.Inbox.Addr,
Timeout: timeout,
})
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return client.Upload(ctx, rec)
}
+38
View File
@@ -0,0 +1,38 @@
package inbox
import (
"os"
"time"
)
type BuildInput struct {
Agent string
Event string
CWD string
Title string
Body string
Source string
}
func BuildRecord(input BuildInput) Record {
host, _ := os.Hostname()
source := input.Source
if source == "" {
source = SourceLocal
}
return Record{
ID: NewID(time.Now()),
Time: time.Now(),
Host: host,
Agent: input.Agent,
Event: input.Event,
CWD: input.CWD,
Title: input.Title,
Body: input.Body,
Status: StatusPending,
Source: source,
Tmux: TmuxContext{
Pane: os.Getenv("TMUX_PANE"),
},
}
}
+35
View File
@@ -0,0 +1,35 @@
package inbox
import "testing"
func TestBuildRecordPopulatesMetadata(t *testing.T) {
t.Setenv("TMUX_PANE", "%12")
rec := BuildRecord(BuildInput{
Agent: "Cursor",
Event: "stop",
CWD: "/work/proj",
Title: "Cursor - proj",
Body: "等待输入",
Source: SourceLocal,
})
if rec.ID == "" {
t.Fatal("expected id")
}
if rec.Time.IsZero() {
t.Fatal("expected time")
}
if rec.Host == "" {
t.Fatal("expected host")
}
if rec.Agent != "Cursor" || rec.Event != "stop" || rec.CWD != "/work/proj" {
t.Fatalf("unexpected record: %+v", rec)
}
if rec.Status != StatusPending || rec.Source != SourceLocal {
t.Fatalf("unexpected status/source: %+v", rec)
}
if rec.Tmux.Pane != "%12" {
t.Fatalf("unexpected tmux pane: %+v", rec.Tmux)
}
}
+75
View File
@@ -0,0 +1,75 @@
package inbox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
)
type ClientConfig struct {
URL string
Socket string
Addr string
Timeout time.Duration
}
type Client struct {
cfg ClientConfig
}
func NewClient(cfg ClientConfig) Client {
if cfg.Timeout <= 0 {
cfg.Timeout = 500 * time.Millisecond
}
return Client{cfg: cfg}
}
func (c Client) Upload(ctx context.Context, rec Record) error {
body, err := json.Marshal(rec)
if err != nil {
return err
}
url := c.url()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/inbox", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: c.cfg.Timeout}
if c.cfg.Socket != "" {
socket := c.cfg.Socket
client.Transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
},
}
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("inbox upload failed: %s", resp.Status)
}
return nil
}
func (c Client) url() string {
if c.cfg.URL != "" {
return strings.TrimRight(c.cfg.URL, "/")
}
if c.cfg.Socket != "" {
return "http://unix"
}
if c.cfg.Addr != "" {
return "http://" + c.cfg.Addr
}
return "http://127.0.0.1:17777"
}
+34
View File
@@ -0,0 +1,34 @@
package inbox
import "time"
const (
StatusPending = "pending"
StatusDone = "done"
)
const (
SourceLocal = "local"
SourceRemote = "remote"
SourceFallback = "fallback"
)
type TmuxContext struct {
Session string `json:"session,omitempty"`
Window string `json:"window,omitempty"`
Pane string `json:"pane,omitempty"`
}
type Record struct {
ID string `json:"id"`
Time time.Time `json:"time"`
Host string `json:"host,omitempty"`
Agent string `json:"agent,omitempty"`
Event string `json:"event,omitempty"`
CWD string `json:"cwd,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Status string `json:"status"`
Source string `json:"source,omitempty"`
Tmux TmuxContext `json:"tmux,omitempty"`
}
+54
View File
@@ -0,0 +1,54 @@
package inbox
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
)
func NewHandler(store Store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/inbox", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var rec Record
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
CompleteRecord(&rec)
if err := store.Append(rec); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": rec.ID})
})
return mux
}
func CompleteRecord(rec *Record) {
if rec.ID == "" {
rec.ID = NewID(time.Now())
}
if rec.Time.IsZero() {
rec.Time = time.Now()
}
if rec.Status == "" {
rec.Status = StatusPending
}
}
func NewID(t time.Time) string {
var b [3]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%s-000000", t.Format("20060102-150405"))
}
return fmt.Sprintf("%s-%s", t.Format("20060102-150405"), hex.EncodeToString(b[:]))
}
+92
View File
@@ -0,0 +1,92 @@
package inbox
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHandlerAppendsInboxRecord(t *testing.T) {
store := NewStore(t.TempDir() + "/inbox.jsonl")
handler := NewHandler(store)
body := bytes.NewBufferString(`{"host":"remote-a","agent":"cursor","event":"stop","title":"done"}`)
req := httptest.NewRequest(http.MethodPost, "/inbox", body)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 {
t.Fatalf("expected 1 record, got %d", len(recs))
}
if recs[0].ID == "" || recs[0].Time.IsZero() || recs[0].Status != StatusPending {
t.Fatalf("record not completed: %+v", recs[0])
}
}
func TestHandlerRejectsInvalidRequests(t *testing.T) {
handler := NewHandler(NewStore(t.TempDir() + "/inbox.jsonl"))
for _, tc := range []struct {
name string
method string
body string
want int
}{
{name: "method", method: http.MethodGet, body: `{}`, want: http.StatusMethodNotAllowed},
{name: "json", method: http.MethodPost, body: `{bad`, want: http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, "/inbox", bytes.NewBufferString(tc.body))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Fatalf("status=%d want=%d", rr.Code, tc.want)
}
})
}
}
func TestClientPostsRecord(t *testing.T) {
var got Record
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/inbox" {
t.Fatalf("path=%s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(ClientConfig{URL: server.URL, Timeout: time.Second})
if err := client.Upload(context.Background(), Record{ID: "id-1", Title: "ready"}); err != nil {
t.Fatal(err)
}
if got.ID != "id-1" || got.Title != "ready" {
t.Fatalf("unexpected upload body: %+v", got)
}
}
func TestClientReturnsErrorForServerFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient(ClientConfig{URL: server.URL, Timeout: time.Second})
if err := client.Upload(context.Background(), Record{}); err == nil {
t.Fatal("expected upload error")
}
}
+77
View File
@@ -0,0 +1,77 @@
package inbox
import (
"os"
"path/filepath"
"strings"
)
const (
sshConfigBegin = "# BEGIN agent-notify inbox"
sshConfigEnd = "# END agent-notify inbox"
)
func DefaultSSHConfigPath() string {
return filepath.Join(os.Getenv("HOME"), ".ssh", "config")
}
func SSHConfigBlock(remoteSocket, localSocket string) string {
return strings.Join([]string{
sshConfigBegin,
"Host *",
" RemoteForward " + remoteSocket + " " + localSocket,
" ExitOnForwardFailure no",
" ServerAliveInterval 30",
sshConfigEnd,
"",
}, "\n")
}
func InstallSSHConfig(path, remoteSocket, localSocket string) (string, error) {
if path == "" {
path = DefaultSSHConfigPath()
}
block := SSHConfigBlock(remoteSocket, localSocket)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return "", err
}
var current string
if data, err := os.ReadFile(path); err == nil {
current = string(data)
} else if !os.IsNotExist(err) {
return "", err
}
next := replaceManagedBlock(current, block)
if err := os.WriteFile(path, []byte(next), 0600); err != nil {
return "", err
}
return block, nil
}
func replaceManagedBlock(current, block string) string {
start := strings.Index(current, sshConfigBegin)
end := strings.Index(current, sshConfigEnd)
if start >= 0 && end >= start {
end += len(sshConfigEnd)
for end < len(current) && (current[end] == '\n' || current[end] == '\r') {
end++
}
prefix := strings.TrimRight(current[:start], "\n")
suffix := strings.TrimLeft(current[end:], "\n")
var parts []string
if prefix != "" {
parts = append(parts, prefix)
}
parts = append(parts, strings.TrimRight(block, "\n"))
if suffix != "" {
parts = append(parts, suffix)
}
return strings.Join(parts, "\n\n") + "\n"
}
if strings.TrimSpace(current) == "" {
return block
}
return strings.TrimRight(current, "\n") + "\n\n" + block
}
+65
View File
@@ -0,0 +1,65 @@
package inbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallSSHConfigCreatesManagedBlock(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
block, err := InstallSSHConfig(path, "/tmp/remote-agent-notify.sock", "/run/user/1000/agent-notify.sock")
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "# BEGIN agent-notify inbox") {
t.Fatalf("missing managed block: %s", text)
}
if !strings.Contains(text, "RemoteForward /tmp/remote-agent-notify.sock /run/user/1000/agent-notify.sock") {
t.Fatalf("missing RemoteForward: %s", text)
}
if !strings.Contains(block, "Host *") || !strings.Contains(block, "RemoteForward") {
t.Fatalf("unexpected returned block: %s", block)
}
}
func TestInstallSSHConfigReplacesManagedBlockAndPreservesUserConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
existing := `Host prod
HostName prod.example
# BEGIN agent-notify inbox
Host *
RemoteForward /old.sock /old.sock
# END agent-notify inbox
`
if err := os.WriteFile(path, []byte(existing), 0600); err != nil {
t.Fatal(err)
}
_, err := InstallSSHConfig(path, "/remote-new.sock", "/local-new.sock")
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(data)
if strings.Count(text, "# BEGIN agent-notify inbox") != 1 {
t.Fatalf("expected one managed block: %s", text)
}
if !strings.Contains(text, "Host prod") {
t.Fatalf("user config not preserved: %s", text)
}
if strings.Contains(text, "/old.sock") || !strings.Contains(text, "/remote-new.sock") || !strings.Contains(text, "/local-new.sock") {
t.Fatalf("block not replaced: %s", text)
}
}
+178
View File
@@ -0,0 +1,178 @@
package inbox
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
)
type Store struct {
path string
}
func DefaultStorePath() string {
return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "inbox.jsonl")
}
func NewStore(path string) Store {
if path == "" {
path = DefaultStorePath()
}
return Store{path: path}
}
func (s Store) Path() string {
return s.path
}
func (s Store) Append(rec Record) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
f, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
if err := json.NewEncoder(f).Encode(rec); err != nil {
return err
}
return nil
}
func (s Store) List() ([]Record, error) {
f, err := os.Open(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var records []Record
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var rec Record
if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil {
continue
}
records = append(records, rec)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return records, nil
}
func (s Store) Pending() ([]Record, error) {
records, err := s.List()
if err != nil {
return nil, err
}
var pending []Record
for _, rec := range records {
if rec.Status == StatusPending {
pending = append(pending, rec)
}
}
return pending, nil
}
func (s Store) MarkDone(ids []string) (int, error) {
idSet := makeSet(ids)
return s.rewrite(func(rec Record) (Record, bool, bool) {
if _, ok := idSet[rec.ID]; !ok {
return rec, true, false
}
if rec.Status == StatusDone {
return rec, true, false
}
rec.Status = StatusDone
return rec, true, true
})
}
func (s Store) Remove(ids []string) (int, error) {
idSet := makeSet(ids)
return s.rewrite(func(rec Record) (Record, bool, bool) {
if _, ok := idSet[rec.ID]; ok {
return rec, false, true
}
return rec, true, false
})
}
func (s Store) ClearDone() (int, error) {
return s.rewrite(func(rec Record) (Record, bool, bool) {
if rec.Status == StatusDone {
return rec, false, true
}
return rec, true, false
})
}
func (s Store) ClearAll() (int, error) {
records, err := s.List()
if err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return 0, err
}
if err := os.WriteFile(s.path, nil, 0644); err != nil {
return 0, err
}
return len(records), nil
}
func (s Store) rewrite(fn func(Record) (Record, bool, bool)) (int, error) {
records, err := s.List()
if err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return 0, err
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), "inbox-*.jsonl")
if err != nil {
return 0, err
}
tmpPath := tmp.Name()
changed := 0
enc := json.NewEncoder(tmp)
for _, rec := range records {
next, keep, didChange := fn(rec)
if didChange {
changed++
}
if !keep {
continue
}
if err := enc.Encode(next); err != nil {
tmp.Close()
os.Remove(tmpPath)
return 0, err
}
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return 0, err
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
return 0, err
}
return changed, nil
}
func makeSet(values []string) map[string]struct{} {
set := make(map[string]struct{}, len(values))
for _, value := range values {
set[value] = struct{}{}
}
return set
}
+131
View File
@@ -0,0 +1,131 @@
package inbox
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAppendCreatesJSONLFile(t *testing.T) {
dir := t.TempDir()
store := NewStore(filepath.Join(dir, "inbox.jsonl"))
rec := Record{
ID: "id-1",
Time: time.Date(2026, 5, 26, 16, 0, 0, 0, time.UTC),
Host: "host-a",
Agent: "cursor",
Event: "stop",
CWD: "/work/proj",
Title: "Cursor - proj",
Body: "等待输入",
Status: StatusPending,
}
if err := store.Append(rec); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "inbox.jsonl")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected inbox file: %v", err)
}
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 {
t.Fatalf("expected 1 record, got %d", len(recs))
}
if recs[0].ID != "id-1" || recs[0].Status != StatusPending {
t.Fatalf("unexpected record: %+v", recs[0])
}
}
func TestPendingFiltersDoneRecords(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
mustAppend(t, store, Record{ID: "pending", Status: StatusPending})
mustAppend(t, store, Record{ID: "done", Status: StatusDone})
recs, err := store.Pending()
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 || recs[0].ID != "pending" {
t.Fatalf("unexpected pending records: %+v", recs)
}
}
func TestMarkDoneRewritesMatchingRecords(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
mustAppend(t, store, Record{ID: "a", Status: StatusPending})
mustAppend(t, store, Record{ID: "b", Status: StatusPending})
n, err := store.MarkDone([]string{"b"})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("expected 1 updated, got %d", n)
}
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if recs[0].Status != StatusPending || recs[1].Status != StatusDone {
t.Fatalf("unexpected statuses: %+v", recs)
}
}
func TestRemoveAndClearDone(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
mustAppend(t, store, Record{ID: "a", Status: StatusPending})
mustAppend(t, store, Record{ID: "b", Status: StatusDone})
mustAppend(t, store, Record{ID: "c", Status: StatusPending})
removed, err := store.Remove([]string{"a"})
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("expected 1 removed, got %d", removed)
}
cleared, err := store.ClearDone()
if err != nil {
t.Fatal(err)
}
if cleared != 1 {
t.Fatalf("expected 1 cleared, got %d", cleared)
}
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 || recs[0].ID != "c" {
t.Fatalf("unexpected records: %+v", recs)
}
}
func TestListSkipsInvalidJSONLLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "inbox.jsonl")
data := []byte("{bad json\n{\"id\":\"ok\",\"status\":\"pending\"}\n")
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
store := NewStore(path)
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 || recs[0].ID != "ok" {
t.Fatalf("unexpected records: %+v", recs)
}
}
func mustAppend(t *testing.T, store Store, rec Record) {
t.Helper()
if err := store.Append(rec); err != nil {
t.Fatal(err)
}
}
+115
View File
@@ -0,0 +1,115 @@
package inbox
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type tuiModel struct {
store Store
records []Record
cursor int
detail bool
message string
}
func RunTUI(store Store) error {
_, err := tea.NewProgram(newTUIModel(store)).Run()
return err
}
func newTUIModel(store Store) tuiModel {
model := tuiModel{store: store}
model.reload()
return model
}
func (m tuiModel) Init() tea.Cmd {
return nil
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch key.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.records)-1 {
m.cursor++
}
case "enter":
m.detail = !m.detail
case "r":
m.reload()
case "d":
if len(m.records) == 0 {
return m, nil
}
rec := m.records[m.cursor]
n, err := m.store.MarkDone([]string{rec.ID})
if err != nil {
m.message = err.Error()
return m, nil
}
m.message = fmt.Sprintf("marked %d done", n)
m.reload()
}
return m, nil
}
func (m tuiModel) View() string {
var b strings.Builder
b.WriteString("agent-notify inbox\n")
b.WriteString("j/k move enter details d done r reload q quit\n\n")
if m.message != "" {
b.WriteString(m.message)
b.WriteString("\n\n")
}
if len(m.records) == 0 {
b.WriteString("No pending notifications.\n")
return b.String()
}
for i, rec := range m.records {
prefix := " "
if i == m.cursor {
prefix = "> "
}
fmt.Fprintf(&b, "%s%s %s %s/%s %s %s\n", prefix, rec.ID, rec.Host, rec.Agent, rec.Event, rec.CWD, rec.Title)
if i == m.cursor && m.detail {
if rec.Body != "" {
fmt.Fprintf(&b, " body: %s\n", rec.Body)
}
if !rec.Time.IsZero() {
fmt.Fprintf(&b, " time: %s\n", rec.Time.Local().Format("2006-01-02 15:04:05"))
}
if rec.Tmux.Pane != "" {
fmt.Fprintf(&b, " tmux pane: %s\n", rec.Tmux.Pane)
}
}
}
return b.String()
}
func (m *tuiModel) reload() {
records, err := m.store.Pending()
if err != nil {
m.message = err.Error()
return
}
m.records = records
if m.cursor >= len(m.records) {
m.cursor = len(m.records) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
+37
View File
@@ -0,0 +1,37 @@
package inbox
import (
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestTUIViewShowsRecords(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
mustAppend(t, store, Record{ID: "id-1", Status: StatusPending, Host: "host", Agent: "Cursor", Event: "stop", Title: "ready"})
model := newTUIModel(store)
view := model.View()
if !strings.Contains(view, "ready") || !strings.Contains(view, "id-1") {
t.Fatalf("unexpected view: %s", view)
}
}
func TestTUIDoneMarksSelectedRecordDone(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
mustAppend(t, store, Record{ID: "id-1", Status: StatusPending, Title: "ready"})
model := newTUIModel(store)
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
model = updated.(tuiModel)
recs, err := store.List()
if err != nil {
t.Fatal(err)
}
if recs[0].Status != StatusDone {
t.Fatalf("expected done, got %+v", recs[0])
}
}