diff --git a/README.md b/README.md index 10f26d9..9263ada 100644 --- a/README.md +++ b/README.md @@ -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 +agent-notify inbox done +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 +agent-notify inbox done +agent-notify inbox tui +``` + ## Hook 配置位置 | Agent | 配置文件 | Hook 事件 | diff --git a/cmd/agent-notify/inbox.go b/cmd/agent-notify/inbox.go new file mode 100644 index 0000000..51ef935 --- /dev/null +++ b/cmd/agent-notify/inbox.go @@ -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 ") + } + 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 ") + } + 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 ") + } + 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 ") + } + 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) +} diff --git a/cmd/agent-notify/inbox_test.go b/cmd/agent-notify/inbox_test.go new file mode 100644 index 0000000..bfd95c7 --- /dev/null +++ b/cmd/agent-notify/inbox_test.go @@ -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()) + } +} diff --git a/cmd/agent-notify/main.go b/cmd/agent-notify/main.go index f565816..a296b65 100644 --- a/cmd/agent-notify/main.go +++ b/cmd/agent-notify/main.go @@ -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 version doctor `) diff --git a/docs/superpowers/plans/2026-05-26-agent-notify-inbox.md b/docs/superpowers/plans/2026-05-26-agent-notify-inbox.md new file mode 100644 index 0000000..88d9ac1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-agent-notify-inbox.md @@ -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 ` prints details +- `inbox done ` marks records done and prints count +- `inbox rm ` 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 ` +- `agent-notify inbox done ` +- `agent-notify inbox rm ` +- `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 ` 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 + 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 +agent-notify inbox done +``` + +**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. diff --git a/go.mod b/go.mod index 57bc55c..c6ec8a8 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index f74b269..f33aa16 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 0f87cdf..4e05eae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6b53742..15e704a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { diff --git a/internal/hook/claude.go b/internal/hook/claude.go index 5d11d68..054a990 100644 --- a/internal/hook/claude.go +++ b/internal/hook/claude.go @@ -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 } diff --git a/internal/hook/cursor.go b/internal/hook/cursor.go index d525616..44bf635 100644 --- a/internal/hook/cursor.go +++ b/internal/hook/cursor.go @@ -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 } diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index f33efb0..40c2bad 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -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 + }) +} diff --git a/internal/hook/inbox.go b/internal/hook/inbox.go new file mode 100644 index 0000000..0571246 --- /dev/null +++ b/internal/hook/inbox.go @@ -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) +} diff --git a/internal/inbox/build.go b/internal/inbox/build.go new file mode 100644 index 0000000..d2e42ae --- /dev/null +++ b/internal/inbox/build.go @@ -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"), + }, + } +} diff --git a/internal/inbox/build_test.go b/internal/inbox/build_test.go new file mode 100644 index 0000000..145f3bf --- /dev/null +++ b/internal/inbox/build_test.go @@ -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) + } +} diff --git a/internal/inbox/client.go b/internal/inbox/client.go new file mode 100644 index 0000000..31e575d --- /dev/null +++ b/internal/inbox/client.go @@ -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" +} diff --git a/internal/inbox/record.go b/internal/inbox/record.go new file mode 100644 index 0000000..04056f6 --- /dev/null +++ b/internal/inbox/record.go @@ -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"` +} diff --git a/internal/inbox/server.go b/internal/inbox/server.go new file mode 100644 index 0000000..57b3ccc --- /dev/null +++ b/internal/inbox/server.go @@ -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[:])) +} diff --git a/internal/inbox/server_test.go b/internal/inbox/server_test.go new file mode 100644 index 0000000..d4ead43 --- /dev/null +++ b/internal/inbox/server_test.go @@ -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") + } +} diff --git a/internal/inbox/sshconfig.go b/internal/inbox/sshconfig.go new file mode 100644 index 0000000..01bc6fd --- /dev/null +++ b/internal/inbox/sshconfig.go @@ -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 +} diff --git a/internal/inbox/sshconfig_test.go b/internal/inbox/sshconfig_test.go new file mode 100644 index 0000000..ab26cc3 --- /dev/null +++ b/internal/inbox/sshconfig_test.go @@ -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) + } +} diff --git a/internal/inbox/store.go b/internal/inbox/store.go new file mode 100644 index 0000000..5f97b1d --- /dev/null +++ b/internal/inbox/store.go @@ -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 +} diff --git a/internal/inbox/store_test.go b/internal/inbox/store_test.go new file mode 100644 index 0000000..69188e3 --- /dev/null +++ b/internal/inbox/store_test.go @@ -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) + } +} diff --git a/internal/inbox/tui.go b/internal/inbox/tui.go new file mode 100644 index 0000000..b850091 --- /dev/null +++ b/internal/inbox/tui.go @@ -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 + } +} diff --git a/internal/inbox/tui_test.go b/internal/inbox/tui_test.go new file mode 100644 index 0000000..c211e7f --- /dev/null +++ b/internal/inbox/tui_test.go @@ -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]) + } +}