Compare commits
10 Commits
1e33bfa4bc
...
3a5102117c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5102117c | |||
| 9000eeeaa7 | |||
| df433a9179 | |||
| 8c43a96817 | |||
| 6802a432d7 | |||
| e95b3315b0 | |||
| 034738fa3f | |||
| d1abd2b15d | |||
| cc502c7173 | |||
| c04c9e6cf2 |
@@ -10,7 +10,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.22.2"
|
||||
GO_VERSION: "1.25.0"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -50,12 +50,13 @@ title_template = "{agent} — {context}" # context = 工作目录名
|
||||
body_stop = "等待输入"
|
||||
|
||||
[inbox]
|
||||
enabled = false
|
||||
|
||||
[remote]
|
||||
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
|
||||
url = "http://your-server:8080"
|
||||
token = "shared-secret"
|
||||
timeout_ms = 2000
|
||||
```
|
||||
|
||||
## 命令
|
||||
@@ -64,44 +65,38 @@ timeout_ms = 500
|
||||
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 server --listen :8080 # 远程汇总服务(见下方 Remote Dashboard)
|
||||
agent-notify inbox serve # 已弃用:见下方 Inbox(deprecated)
|
||||
agent-notify test cursor [-v]
|
||||
agent-notify test claude [--apply]
|
||||
agent-notify doctor
|
||||
agent-notify install --all [--force]
|
||||
```
|
||||
|
||||
## 本地汇总 Inbox
|
||||
## Remote Dashboard
|
||||
|
||||
在本地 Ghostty 所在机器启动接收服务:
|
||||
在可访问的机器上启动汇总服务(需设置与客户端相同的 token):
|
||||
|
||||
```bash
|
||||
agent-notify inbox serve
|
||||
AGENT_NOTIFY_TOKEN=secret agent-notify server --listen :8080
|
||||
```
|
||||
|
||||
让命令自动写 SSH `RemoteForward` 配置:
|
||||
在 `~/.config/agent-notify/config.toml` 中启用远程上报(`agent-notify install` 会生成带 `[remote]` 的默认配置):
|
||||
|
||||
```bash
|
||||
agent-notify inbox ssh-config install
|
||||
```toml
|
||||
[remote]
|
||||
enabled = true
|
||||
url = "http://your-server:8080"
|
||||
token = "shared-secret"
|
||||
```
|
||||
|
||||
命令会在 `~/.ssh/config` 写入一个托管块,并把写入内容打印出来。已有 SSH 连接需要重连后才会生效。
|
||||
远程 Agent hook 在 `stop` 等事件时会向该服务 POST 状态;浏览器打开服务根路径即可查看 Web UI(输入 token 后加载会话列表)。
|
||||
|
||||
默认写入的转发形式是:远程创建 `/tmp/agent-notify-$USER.sock`,转发到本地 `$XDG_RUNTIME_DIR/agent-notify.sock`。远程 hook 会尝试通过这个 SSH 反向转发把记录写回本地 inbox;如果本地接收服务不可用,会 fallback 写到远程机器自己的 `~/.local/state/agent-notify/inbox.jsonl`,避免丢记录。
|
||||
## 本地汇总 Inbox(已弃用)
|
||||
|
||||
查看和处理:
|
||||
> **Deprecated:** 请改用上方的 Remote Dashboard(`agent-notify server` + `[remote]`)。`agent-notify inbox` 子命令仍会打印弃用警告并继续工作,便于过渡。
|
||||
|
||||
```bash
|
||||
agent-notify inbox list
|
||||
agent-notify inbox show <id>
|
||||
agent-notify inbox done <id>
|
||||
agent-notify inbox tui
|
||||
```
|
||||
旧流程:在本地 Ghostty 机器运行 `agent-notify inbox serve`,用 `agent-notify inbox ssh-config install` 配置 SSH `RemoteForward`,通过 Unix socket 把远程通知写回本地 `inbox.jsonl`,并用 `inbox list` / `tui` 等处理。
|
||||
|
||||
## Hook 配置位置
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func cmdInbox(args []string, stdout, stderr io.Writer) error {
|
||||
fmt.Fprintln(stderr, "warning: inbox is deprecated; use agent-notify server and [remote] config")
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ func run(cmd string, args []string) error {
|
||||
return cmdLogs(args)
|
||||
case "inbox":
|
||||
return cmdInbox(args, os.Stdout, os.Stderr)
|
||||
case "server":
|
||||
return cmdServer(args)
|
||||
case "version", "-V":
|
||||
return cmdVersion()
|
||||
case "help", "-h", "--help":
|
||||
@@ -190,6 +192,20 @@ func cmdDoctor() error {
|
||||
}
|
||||
cfgPath := config.DefaultPath()
|
||||
fmt.Printf(" config=%s\n", cfgPath)
|
||||
cfg, err := config.LoadDefault()
|
||||
if err != nil {
|
||||
fmt.Printf("✗ config load failed: %v\n", err)
|
||||
} else if cfg.Remote.Enabled {
|
||||
if cfg.Remote.URL == "" {
|
||||
fmt.Println("✗ remote.enabled but remote.url is empty — set url to your agent-notify server (e.g. http://host:8080)")
|
||||
}
|
||||
if cfg.Remote.Token == "" {
|
||||
fmt.Println("✗ remote.enabled but remote.token is empty — must match AGENT_NOTIFY_TOKEN on agent-notify server")
|
||||
}
|
||||
if cfg.Remote.URL != "" && cfg.Remote.Token != "" {
|
||||
fmt.Printf("✓ remote dashboard configured (%s)\n", cfg.Remote.URL)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" hook_log=%s\n", logx.Path())
|
||||
return nil
|
||||
}
|
||||
@@ -205,6 +221,7 @@ Commands:
|
||||
test claude [--apply]
|
||||
logs [--tail 30]
|
||||
inbox <list|show|done|rm|clear|serve|ssh-config>
|
||||
server [--listen ADDR] [--db PATH] [--token TOKEN]
|
||||
version
|
||||
doctor
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/server"
|
||||
)
|
||||
|
||||
func cmdServer(args []string) error {
|
||||
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||
listen := fs.String("listen", ":8080", "listen address")
|
||||
db := fs.String("db", "./agent-notify.db", "SQLite database path")
|
||||
token := fs.String("token", "", "API bearer token")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
tok := *token
|
||||
if tok == "" {
|
||||
tok = os.Getenv("AGENT_NOTIFY_TOKEN")
|
||||
}
|
||||
if tok == "" {
|
||||
return fmt.Errorf("--token or AGENT_NOTIFY_TOKEN is required")
|
||||
}
|
||||
|
||||
store, err := server.Open(*db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
handler := server.NewHandler(store, tok)
|
||||
fmt.Fprintf(os.Stderr, "agent-notify server listening on %s\n", *listen)
|
||||
return http.ListenAndServe(*listen, handler)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
module github.com/longbin/agent-notify
|
||||
|
||||
go 1.22.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/charmbracelet/bubbletea v0.26.6
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -12,13 +13,21 @@ require (
|
||||
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/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -10,8 +10,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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=
|
||||
@@ -20,12 +30,49 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -66,6 +66,8 @@ func Default() Config {
|
||||
},
|
||||
Remote: Remote{
|
||||
Enabled: false,
|
||||
URL: "",
|
||||
Token: "",
|
||||
TimeoutMS: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ 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)
|
||||
if cfg.Remote.Enabled {
|
||||
reportRemoteHook(cfg, "Claude", event, meta.CWD, "", "")
|
||||
} else if cfg.Inbox.Enabled {
|
||||
recordInbox(cfg, "Claude", event, meta.CWD, title, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+10
-4
@@ -12,9 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type cursorPayload struct {
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Status string `json:"status"`
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Status string `json:"status"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -64,7 +66,11 @@ 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)
|
||||
if cfg.Remote.Enabled {
|
||||
reportRemoteHook(cfg, "Cursor", event, meta.CWD, payload.TranscriptPath, payload.ConversationID)
|
||||
} else if cfg.Inbox.Enabled {
|
||||
recordInbox(cfg, "Cursor", event, meta.CWD, title, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func TestCursorStopHookDisabled(t *testing.T) {
|
||||
@@ -64,6 +67,7 @@ func TestCursorHookUploadsInboxRecord(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -87,6 +91,7 @@ func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -130,6 +135,7 @@ func TestClaudeHookUploadsInboxRecord(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
var out bytes.Buffer
|
||||
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -150,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app
|
||||
appendInboxRecord = prevAppend
|
||||
})
|
||||
}
|
||||
|
||||
func stubReportRemote(t *testing.T, fn func(config.Config, string, string, string, string, string)) {
|
||||
t.Helper()
|
||||
prev := reportRemoteHook
|
||||
reportRemoteHook = fn
|
||||
t.Cleanup(func() { reportRemoteHook = prev })
|
||||
}
|
||||
|
||||
func TestCursorHookReportsRemoteOnStop(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
var got remote.StatusReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = true
|
||||
cfg.Remote.URL = srv.URL
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"],"conversation_id":"conv-1","transcript_path":"/tmp/t.jsonl"}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Agent != "Cursor" || got.Event != "stop" || got.Status != "waiting" || got.CWD != "/tmp/proj" {
|
||||
t.Fatalf("unexpected report: %+v", got)
|
||||
}
|
||||
if got.ConversationID != "conv-1" {
|
||||
t.Fatalf("conversation_id: got %q", got.ConversationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookSkipsRemoteWhenDisabled(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
stubReportRemote(t, func(config.Config, string, string, string, string, string) {
|
||||
t.Fatal("reportRemote should not run when remote disabled")
|
||||
})
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = false
|
||||
if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookRemoteReportFailureStillOK(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "fail", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = true
|
||||
cfg.Remote.URL = srv.URL
|
||||
if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("hook should return nil on remote failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/hostmeta"
|
||||
"github.com/longbin/agent-notify/internal/logx"
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
"github.com/longbin/agent-notify/internal/transcript"
|
||||
)
|
||||
|
||||
var reportRemoteHook = reportRemote
|
||||
|
||||
func reportRemote(cfg config.Config, agent, event, cwd string, transcriptPath, conversationID string) {
|
||||
if !cfg.Remote.Enabled || cfg.Remote.URL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hostname, err := hostmeta.Hostname()
|
||||
if err != nil {
|
||||
logx.Append("remote report hostname: %v", err)
|
||||
hostname = ""
|
||||
}
|
||||
ips := hostmeta.IPs()
|
||||
|
||||
var lastUser, lastAgent string
|
||||
if transcriptPath != "" {
|
||||
lastUser, lastAgent, err = transcript.LastMessages(transcriptPath, 2000)
|
||||
if err != nil {
|
||||
logx.Append("remote report transcript: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.Remote.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
report := remote.StatusReport{
|
||||
Hostname: hostname,
|
||||
IPs: ips,
|
||||
Agent: agent,
|
||||
CWD: cwd,
|
||||
Status: eventToStatus(event),
|
||||
Event: event,
|
||||
ConversationID: conversationID,
|
||||
LastUser: lastUser,
|
||||
LastAgent: lastAgent,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
client := remote.NewClient(remote.ClientConfig{
|
||||
URL: cfg.Remote.URL,
|
||||
Token: cfg.Remote.Token,
|
||||
Timeout: timeout,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := client.Report(ctx, report); err != nil {
|
||||
logx.Append("remote report failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func eventToStatus(event string) string {
|
||||
switch strings.ToLower(event) {
|
||||
case "stop":
|
||||
return "waiting"
|
||||
case "response":
|
||||
return "running"
|
||||
case "tool":
|
||||
return "tool"
|
||||
case "idle":
|
||||
return "idle"
|
||||
default:
|
||||
return "waiting"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
URL string
|
||||
Token string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg ClientConfig
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
return Client{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c Client) Report(ctx context.Context, report StatusReport) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := strings.TrimRight(c.cfg.URL, "/") + "/api/v1/status"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: c.cfg.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("remote report failed: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ips,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
CWD string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
LastUser string `json:"last_user,omitempty"`
|
||||
LastAgent string `json:"last_agent,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func SessionKey(hostname, primaryIP, cwd, agent string) string {
|
||||
sum := sha256.Sum256([]byte(hostname + "\x00" + primaryIP + "\x00" + cwd + "\x00" + agent))
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
func PrimaryIP(ips []string) string {
|
||||
for _, s := range ips {
|
||||
ip := net.ParseIP(s)
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip.To4().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionKeyStable(t *testing.T) {
|
||||
k1 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
k2 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
if k1 != k2 || len(k1) != 32 {
|
||||
t.Fatalf("key=%q len=%d", k1, len(k1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPostStatus(t *testing.T) {
|
||||
var got StatusReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/v1/status" {
|
||||
http.Error(w, "path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
http.Error(w, "auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(ClientConfig{URL: srv.URL, Token: "secret", Timeout: time.Second})
|
||||
err := c.Report(context.Background(), StatusReport{Hostname: "h", Agent: "Cursor", CWD: "/p", Status: "waiting"})
|
||||
if err != nil || got.Hostname != "h" {
|
||||
t.Fatalf("err=%v got=%+v", err, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func NewHandler(store *Store, token string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", handleHealthz)
|
||||
mux.Handle("/api/v1/status", bearerAuth(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
handleStatusPost(store, w, r)
|
||||
case http.MethodGet:
|
||||
handleStatusList(store, w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})))
|
||||
mux.Handle("/api/v1/meta", bearerAuth(token, http.HandlerFunc(handleMeta(store))))
|
||||
mux.Handle("/", webHandler())
|
||||
return mux
|
||||
}
|
||||
|
||||
func handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func bearerAuth(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkBearer(r, token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func checkBearer(r *http.Request, token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(h) < len(prefix) || !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
got := h[len(prefix):]
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func handleStatusPost(store *Store, w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var report remote.StatusReport
|
||||
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.Upsert(report); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func handleStatusList(store *Store, w http.ResponseWriter, r *http.Request) {
|
||||
filters := ListFilters{
|
||||
Host: r.URL.Query().Get("host"),
|
||||
CWD: r.URL.Query().Get("cwd"),
|
||||
Agent: r.URL.Query().Get("agent"),
|
||||
Status: r.URL.Query().Get("status"),
|
||||
}
|
||||
rows, err := store.List(filters)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []SessionRow{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
|
||||
func handleMeta(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
meta, err := store.Meta()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(meta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHTTPPostWithoutToken401(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
body := `{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"2026-06-02T00:00:00Z"}`
|
||||
resp, err := http.Post(srv.URL+"/api/v1/status", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPostValidToken200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
body := `{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"` + now + `"}`
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/status", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
var okResp map[string]bool
|
||||
if err := json.NewDecoder(resp.Body).Decode(&okResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okResp["ok"] {
|
||||
t.Fatalf("response: %+v", okResp)
|
||||
}
|
||||
|
||||
rows, err := store.List(ListFilters{Agent: "Cursor"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row in store, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Hostname != "h" || rows[0].Status != "waiting" {
|
||||
t.Fatalf("row: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetAgentFilter(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
for _, agent := range []string{"Cursor", "Claude"} {
|
||||
if err := store.Upsert(remote.StatusReport{
|
||||
Hostname: "host-filter",
|
||||
IPs: []string{"10.0.0.1"},
|
||||
Agent: agent,
|
||||
CWD: "/proj",
|
||||
Status: "waiting",
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/status?agent=Cursor", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []SessionRow
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d: %s", len(rows), data)
|
||||
}
|
||||
if rows[0].Agent != "Cursor" {
|
||||
t.Fatalf("agent: got %q want Cursor", rows[0].Agent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetRootNoAuth200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "agent-notify") {
|
||||
t.Fatalf("expected dashboard HTML, got: %q", string(body[:min(80, len(body))]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHealthz200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
const offlineThreshold = 5 * time.Minute
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type SessionRow struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ips"`
|
||||
Agent string `json:"agent"`
|
||||
CWD string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
LastUser string `json:"last_user,omitempty"`
|
||||
LastAgent string `json:"last_agent,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type ListFilters struct {
|
||||
Host string
|
||||
CWD string
|
||||
Agent string
|
||||
Status string
|
||||
}
|
||||
|
||||
type MetaResult struct {
|
||||
Hosts []string `json:"hosts"`
|
||||
CWDs []string `json:"cwds"`
|
||||
Agents []string `json:"agents"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_key TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
ips TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
event TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
last_user TEXT NOT NULL DEFAULT '',
|
||||
last_agent TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_hostname ON sessions(hostname)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(report remote.StatusReport) error {
|
||||
key := remote.SessionKey(report.Hostname, remote.PrimaryIP(report.IPs), report.CWD, report.Agent)
|
||||
ipsJSON, err := json.Marshal(report.IPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receivedAt := time.Now().UTC()
|
||||
updatedAt := report.UpdatedAt.UTC()
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = receivedAt
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO sessions (
|
||||
session_key, hostname, ips, agent, cwd, status, event,
|
||||
conversation_id, last_user, last_agent, updated_at, received_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
hostname = excluded.hostname,
|
||||
ips = excluded.ips,
|
||||
agent = excluded.agent,
|
||||
cwd = excluded.cwd,
|
||||
status = excluded.status,
|
||||
event = excluded.event,
|
||||
conversation_id = excluded.conversation_id,
|
||||
last_user = CASE WHEN excluded.last_user = '' THEN sessions.last_user ELSE excluded.last_user END,
|
||||
last_agent = CASE WHEN excluded.last_agent = '' THEN sessions.last_agent ELSE excluded.last_agent END,
|
||||
updated_at = excluded.updated_at,
|
||||
received_at = excluded.received_at
|
||||
`, key, report.Hostname, string(ipsJSON), report.Agent, report.CWD, report.Status,
|
||||
report.Event, report.ConversationID, report.LastUser, report.LastAgent,
|
||||
updatedAt.Format(time.RFC3339Nano), receivedAt.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) List(filters ListFilters) ([]SessionRow, error) {
|
||||
query := `SELECT session_key, hostname, ips, agent, cwd, status, event,
|
||||
conversation_id, last_user, last_agent, updated_at, received_at
|
||||
FROM sessions WHERE 1=1`
|
||||
var args []any
|
||||
|
||||
if filters.Host != "" {
|
||||
query += ` AND hostname = ?`
|
||||
args = append(args, filters.Host)
|
||||
}
|
||||
if filters.CWD != "" {
|
||||
query += ` AND cwd = ?`
|
||||
args = append(args, filters.CWD)
|
||||
}
|
||||
if filters.Agent != "" {
|
||||
query += ` AND agent = ?`
|
||||
args = append(args, filters.Agent)
|
||||
}
|
||||
query += ` ORDER BY updated_at DESC`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []SessionRow
|
||||
for rows.Next() {
|
||||
row, err := scanSessionRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Status = displayStatus(row.Status, row.UpdatedAt)
|
||||
if filters.Status != "" && row.Status != filters.Status {
|
||||
continue
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func displayStatus(stored string, updatedAt time.Time) string {
|
||||
if time.Since(updatedAt) > offlineThreshold {
|
||||
return "offline"
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
func (s *Store) Meta() (MetaResult, error) {
|
||||
var meta MetaResult
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
dest *[]string
|
||||
}{
|
||||
{`SELECT DISTINCT hostname FROM sessions ORDER BY hostname`, &meta.Hosts},
|
||||
{`SELECT DISTINCT cwd FROM sessions ORDER BY cwd`, &meta.CWDs},
|
||||
{`SELECT DISTINCT agent FROM sessions ORDER BY agent`, &meta.Agents},
|
||||
} {
|
||||
rows, err := s.db.Query(q.sql)
|
||||
if err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
rows.Close()
|
||||
return MetaResult{}, err
|
||||
}
|
||||
*q.dest = append(*q.dest, v)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSessionRow(rows rowScanner) (SessionRow, error) {
|
||||
var row SessionRow
|
||||
var ipsJSON, updatedAtStr, receivedAtStr string
|
||||
if err := rows.Scan(
|
||||
&row.SessionKey, &row.Hostname, &ipsJSON, &row.Agent, &row.CWD, &row.Status,
|
||||
&row.Event, &row.ConversationID, &row.LastUser, &row.LastAgent,
|
||||
&updatedAtStr, &receivedAtStr,
|
||||
); err != nil {
|
||||
return SessionRow{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ipsJSON), &row.IPs); err != nil {
|
||||
return SessionRow{}, fmt.Errorf("decode ips: %w", err)
|
||||
}
|
||||
if row.IPs == nil {
|
||||
row.IPs = []string{}
|
||||
}
|
||||
var err error
|
||||
row.UpdatedAt, err = parseTime(updatedAtStr)
|
||||
if err != nil {
|
||||
return SessionRow{}, fmt.Errorf("updated_at: %w", err)
|
||||
}
|
||||
row.ReceivedAt, err = parseTime(receivedAtStr)
|
||||
if err != nil {
|
||||
return SessionRow{}, fmt.Errorf("received_at: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid time %q", s)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func TestStoreUpsertAndList(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
base := remote.StatusReport{
|
||||
Hostname: "host-a",
|
||||
IPs: []string{"10.0.0.1"},
|
||||
Agent: "Cursor",
|
||||
CWD: "/proj",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
r1 := base
|
||||
r1.Status = "waiting"
|
||||
if err := s.Upsert(r1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r2 := base
|
||||
r2.Status = "running"
|
||||
if err := s.Upsert(r2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "running" {
|
||||
t.Fatalf("expected latest status running, got %q", rows[0].Status)
|
||||
}
|
||||
key := remote.SessionKey("host-a", "10.0.0.1", "/proj", "Cursor")
|
||||
if rows[0].SessionKey != key {
|
||||
t.Fatalf("session_key: got %q want %q", rows[0].SessionKey, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOfflineAfter5Min(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
stale := time.Now().UTC().Add(-10 * time.Minute)
|
||||
report := remote.StatusReport{
|
||||
Hostname: "host-b",
|
||||
IPs: []string{"192.168.1.5"},
|
||||
Agent: "Claude",
|
||||
CWD: "/work",
|
||||
Status: "waiting",
|
||||
UpdatedAt: stale,
|
||||
}
|
||||
if err := s.Upsert(report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "offline" {
|
||||
t.Fatalf("expected offline status, got %q", rows[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMergePreservesLastUser(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
base := remote.StatusReport{
|
||||
Hostname: "host-c",
|
||||
IPs: []string{"10.0.0.2"},
|
||||
Agent: "Cursor",
|
||||
CWD: "/proj",
|
||||
Status: "waiting",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
r1 := base
|
||||
r1.LastUser = "keep me"
|
||||
if err := s.Upsert(r1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r2 := base
|
||||
r2.LastUser = ""
|
||||
r2.Status = "running"
|
||||
if err := s.Upsert(r2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].LastUser != "keep me" {
|
||||
t.Fatalf("last_user: got %q want %q", rows[0].LastUser, "keep me")
|
||||
}
|
||||
if rows[0].Status != "running" {
|
||||
t.Fatalf("expected status running, got %q", rows[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/longbin/agent-notify/web"
|
||||
)
|
||||
|
||||
func webHandler() http.Handler {
|
||||
return web.Handler()
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"role": "user", "content": "q"}
|
||||
{"role": "assistant", "content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"role":"user","content":"hello"}
|
||||
{"role":"assistant","content":"world"}
|
||||
{"role":"user","content":"second question"}
|
||||
{"role":"assistant","content":"final answer"}
|
||||
@@ -0,0 +1,93 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const tailWindow = 256 * 1024
|
||||
|
||||
// LastMessages reads a JSONL transcript and returns the last user and assistant
|
||||
// messages, each truncated to maxLen runes.
|
||||
func LastMessages(path string, maxLen int) (lastUser, lastAgent string, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
size := info.Size()
|
||||
|
||||
var r io.Reader = f
|
||||
if size > tailWindow {
|
||||
start := size - tailWindow
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tail := make([]byte, tailWindow)
|
||||
n, err := io.ReadFull(f, tail)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return "", "", err
|
||||
}
|
||||
tail = tail[:n]
|
||||
if i := bytes.IndexByte(tail, '\n'); i >= 0 {
|
||||
tail = tail[i+1:]
|
||||
}
|
||||
r = bytes.NewReader(tail)
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row struct {
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
continue
|
||||
}
|
||||
role := row.Role
|
||||
if role == "" {
|
||||
role = row.Type
|
||||
}
|
||||
switch role {
|
||||
case "user":
|
||||
lastUser = row.Content
|
||||
case "assistant":
|
||||
lastAgent = row.Content
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
lastUser = truncateRunes(lastUser, maxLen)
|
||||
lastAgent = truncateRunes(lastAgent, maxLen)
|
||||
return lastUser, lastAgent, nil
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxLen int) string {
|
||||
if maxLen <= 0 || utf8.RuneCountInString(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package transcript
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLastMessages(t *testing.T) {
|
||||
u, a, err := LastMessages("testdata/sample.jsonl", 2000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u != "second question" || a != "final answer" {
|
||||
t.Fatalf("got user=%q agent=%q", u, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate2000(t *testing.T) {
|
||||
u, a, err := LastMessages("testdata/long.jsonl", 2000)
|
||||
if err != nil || len(a) != 2000 {
|
||||
t.Fatalf("len=%d err=%v", len(a), err)
|
||||
}
|
||||
_ = u
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
(function () {
|
||||
const STORAGE_KEY = 'agent_notify_token';
|
||||
const POLL_MS = 3000;
|
||||
|
||||
const elHost = document.getElementById('filter-host');
|
||||
const elAgent = document.getElementById('filter-agent');
|
||||
const elStatus = document.getElementById('filter-status');
|
||||
const elCwd = document.getElementById('filter-cwd');
|
||||
const elSessions = document.getElementById('sessions');
|
||||
const elError = document.getElementById('error');
|
||||
const btnToken = document.getElementById('btn-token');
|
||||
|
||||
let expanded = new Set();
|
||||
let pollTimer = null;
|
||||
|
||||
function getToken() {
|
||||
return sessionStorage.getItem(STORAGE_KEY) || '';
|
||||
}
|
||||
|
||||
function promptToken() {
|
||||
const current = getToken();
|
||||
const t = window.prompt('API token (Bearer):', current);
|
||||
if (t === null) return false;
|
||||
const trimmed = t.trim();
|
||||
if (!trimmed) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return false;
|
||||
}
|
||||
sessionStorage.setItem(STORAGE_KEY, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureToken() {
|
||||
if (getToken()) return true;
|
||||
return promptToken();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!msg) {
|
||||
elError.textContent = '';
|
||||
elError.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
elError.textContent = msg;
|
||||
elError.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) return '';
|
||||
const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||||
return i >= 0 ? path.slice(i + 1) : path;
|
||||
}
|
||||
|
||||
function formatMachine(row) {
|
||||
const ips = (row.ips || []).join(', ');
|
||||
return row.hostname + (ips ? ' (' + ips + ')' : '');
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('No token');
|
||||
const res = await fetch(path, {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
throw new Error('Unauthorized — check token');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(res.status + ' ' + res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function queryParams() {
|
||||
const q = new URLSearchParams();
|
||||
if (elHost.value) q.set('host', elHost.value);
|
||||
if (elAgent.value) q.set('agent', elAgent.value);
|
||||
if (elStatus.value) q.set('status', elStatus.value);
|
||||
if (elCwd.value) q.set('cwd', elCwd.value);
|
||||
const s = q.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
function fillSelect(select, values, keepValue) {
|
||||
const current = keepValue ? select.value : '';
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
for (const v of values || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = v;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if (current && [...select.options].some((o) => o.value === current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const meta = await apiGet('/api/v1/meta');
|
||||
fillSelect(elHost, meta.hosts, true);
|
||||
fillSelect(elAgent, meta.agents, true);
|
||||
fillSelect(elCwd, meta.cwds, true);
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const rows = await apiGet('/api/v1/status' + queryParams());
|
||||
renderTable(Array.isArray(rows) ? rows : []);
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
if (rows.length === 0) {
|
||||
elSessions.innerHTML =
|
||||
'<tr><td colspan="6" class="empty">No sessions</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (const row of rows) {
|
||||
const key = row.session_key || row.hostname + row.cwd + row.agent;
|
||||
const offline = row.status === 'offline';
|
||||
const cls = offline ? ' class="offline"' : '';
|
||||
const open = expanded.has(key);
|
||||
|
||||
parts.push(
|
||||
'<tr' +
|
||||
cls +
|
||||
' data-key="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<td>' +
|
||||
escapeHtml(formatMachine(row)) +
|
||||
'</td>' +
|
||||
'<td title="' +
|
||||
escapeHtml(row.cwd || '') +
|
||||
'">' +
|
||||
escapeHtml(basename(row.cwd)) +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
escapeHtml(row.agent || '') +
|
||||
'</td>' +
|
||||
'<td><span class="status">' +
|
||||
escapeHtml(row.status || '') +
|
||||
'</span></td>' +
|
||||
'<td>' +
|
||||
escapeHtml(formatTime(row.updated_at)) +
|
||||
'</td>' +
|
||||
'<td><button type="button" class="btn-expand" data-key="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
(open ? 'Hide' : 'Details') +
|
||||
'</button></td>' +
|
||||
'</tr>'
|
||||
);
|
||||
|
||||
if (open) {
|
||||
parts.push(
|
||||
'<tr class="details-row"><td colspan="6">' +
|
||||
'<div class="label">last_user</div>' +
|
||||
'<pre>' +
|
||||
escapeHtml(row.last_user || '(empty)') +
|
||||
'</pre>' +
|
||||
'<div class="label">last_agent</div>' +
|
||||
'<pre>' +
|
||||
escapeHtml(row.last_agent || '(empty)') +
|
||||
'</pre>' +
|
||||
'</td></tr>'
|
||||
);
|
||||
}
|
||||
}
|
||||
elSessions.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!ensureToken()) {
|
||||
showError('Token required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
showError('');
|
||||
await Promise.all([loadMeta(), loadStatus()]);
|
||||
} catch (e) {
|
||||
showError(e.message || String(e));
|
||||
if (String(e.message || '').includes('Unauthorized')) {
|
||||
if (promptToken()) refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(refresh, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
[elHost, elAgent, elStatus, elCwd].forEach((el) => {
|
||||
el.addEventListener('change', refresh);
|
||||
});
|
||||
|
||||
btnToken.addEventListener('click', () => {
|
||||
promptToken();
|
||||
refresh();
|
||||
});
|
||||
|
||||
elSessions.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.btn-expand');
|
||||
if (!btn) return;
|
||||
const key = btn.getAttribute('data-key');
|
||||
if (expanded.has(key)) expanded.delete(key);
|
||||
else expanded.add(key);
|
||||
refresh();
|
||||
});
|
||||
|
||||
if (!getToken()) promptToken();
|
||||
refresh();
|
||||
startPoll();
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed index.html app.js style.css
|
||||
var content embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
return http.FileServer(http.FS(content))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>agent-notify</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-notify</h1>
|
||||
<button type="button" id="btn-token" title="Change API token">Token</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<label>Host
|
||||
<select id="filter-host"><option value="">All</option></select>
|
||||
</label>
|
||||
<label>Agent
|
||||
<select id="filter-agent"><option value="">All</option></select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select id="filter-status">
|
||||
<option value="">All</option>
|
||||
<option value="waiting">waiting</option>
|
||||
<option value="running">running</option>
|
||||
<option value="tool">tool</option>
|
||||
<option value="idle">idle</option>
|
||||
<option value="offline">offline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>CWD
|
||||
<select id="filter-cwd"><option value="">All</option></select>
|
||||
</label>
|
||||
</aside>
|
||||
<main>
|
||||
<p id="error" class="error hidden"></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<th>Directory</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions"></tbody>
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
--bg: #0f1114;
|
||||
--surface: #161a20;
|
||||
--border: #2a3038;
|
||||
--text: #e4e6eb;
|
||||
--muted: #8b929a;
|
||||
--accent: #5b9fd4;
|
||||
--error: #e06c75;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#btn-token {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#btn-token:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 49px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sidebar label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sidebar select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.error.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
tr.offline td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
tr.offline .status {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-expand {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-expand:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.details-row td {
|
||||
padding: 0 12px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.details-row pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.details-row .label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.details-row .label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
Reference in New Issue
Block a user