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
+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])
}
}