Compare commits

...

10 Commits

Author SHA1 Message Date
laily 3a5102117c ci: bump Go to 1.25 for modernc sqlite dependency
Pin modernc.org/sqlite v1.34.5; align release workflow with go.mod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:39:34 +08:00
laily 9000eeeaa7 docs: deprecate inbox and document remote dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:37:40 +08:00
laily df433a9179 feat: add embedded web dashboard for remote server
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:36:41 +08:00
laily 8c43a96817 feat: report agent status to remote server from hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:35:30 +08:00
laily 6802a432d7 feat: add agent-notify server command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:34:39 +08:00
laily e95b3315b0 feat: add remote server HTTP API with bearer auth
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:32:14 +08:00
laily 034738fa3f feat: add sqlite session store for remote server
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:30:40 +08:00
laily d1abd2b15d feat: add remote status report client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:29:12 +08:00
laily cc502c7173 feat: add transcript parser for last user/agent messages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:28:26 +08:00
laily c04c9e6cf2 test: enable inbox explicitly in inbox hook tests
After inbox defaults to disabled, inbox upload tests must opt in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:27:56 +08:00
28 changed files with 1739 additions and 39 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
contents: read contents: read
env: env:
GO_VERSION: "1.22.2" GO_VERSION: "1.25.0"
jobs: jobs:
test: test:
+21 -26
View File
@@ -50,12 +50,13 @@ title_template = "{agent} — {context}" # context = 工作目录名
body_stop = "等待输入" body_stop = "等待输入"
[inbox] [inbox]
enabled = false
[remote]
enabled = true enabled = true
socket = "/run/user/1000/agent-notify.sock" url = "http://your-server:8080"
remote_socket = "/tmp/agent-notify-longbin.sock" token = "shared-secret"
addr = "127.0.0.1:17777" timeout_ms = 2000
fallback_local = true
timeout_ms = 500
``` ```
## 命令 ## 命令
@@ -64,44 +65,38 @@ timeout_ms = 500
agent-notify send --event stop agent-notify send --event stop
agent-notify hook cursor stop # Cursor CLI stop hook agent-notify hook cursor stop # Cursor CLI stop hook
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON) agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON)
agent-notify inbox serve # 本地接收远程通知记录 agent-notify server --listen :8080 # 远程汇总服务(见下方 Remote Dashboard)
agent-notify inbox list # 列出未处理通知 agent-notify inbox serve # 已弃用:见下方 Inbox(deprecated)
agent-notify inbox show <id>
agent-notify inbox done <id>
agent-notify inbox tui # Bubble Tea TUI
agent-notify inbox ssh-config install # 自动写 ~/.ssh/config RemoteForward
agent-notify test cursor [-v] agent-notify test cursor [-v]
agent-notify test claude [--apply] agent-notify test claude [--apply]
agent-notify doctor agent-notify doctor
agent-notify install --all [--force] agent-notify install --all [--force]
``` ```
## 本地汇总 Inbox ## Remote Dashboard
在本地 Ghostty 所在机器启动接收服务: 在可访问的机器上启动汇总服务(需设置与客户端相同的 token):
```bash ```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 ```toml
agent-notify inbox ssh-config install [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 旧流程:在本地 Ghostty 机器运行 `agent-notify inbox serve`,用 `agent-notify inbox ssh-config install` 配置 SSH `RemoteForward`,通过 Unix socket 把远程通知写回本地 `inbox.jsonl`,并用 `inbox list` / `tui` 等处理。
agent-notify inbox list
agent-notify inbox show <id>
agent-notify inbox done <id>
agent-notify inbox tui
```
## Hook 配置位置 ## Hook 配置位置
+1
View File
@@ -17,6 +17,7 @@ import (
) )
func cmdInbox(args []string, stdout, stderr io.Writer) error { 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 { if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>") return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
} }
+17
View File
@@ -41,6 +41,8 @@ func run(cmd string, args []string) error {
return cmdLogs(args) return cmdLogs(args)
case "inbox": case "inbox":
return cmdInbox(args, os.Stdout, os.Stderr) return cmdInbox(args, os.Stdout, os.Stderr)
case "server":
return cmdServer(args)
case "version", "-V": case "version", "-V":
return cmdVersion() return cmdVersion()
case "help", "-h", "--help": case "help", "-h", "--help":
@@ -190,6 +192,20 @@ func cmdDoctor() error {
} }
cfgPath := config.DefaultPath() cfgPath := config.DefaultPath()
fmt.Printf(" config=%s\n", cfgPath) 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()) fmt.Printf(" hook_log=%s\n", logx.Path())
return nil return nil
} }
@@ -205,6 +221,7 @@ Commands:
test claude [--apply] test claude [--apply]
logs [--tail 30] logs [--tail 30]
inbox <list|show|done|rm|clear|serve|ssh-config> inbox <list|show|done|rm|clear|serve|ssh-config>
server [--listen ADDR] [--db PATH] [--token TOKEN]
version version
doctor doctor
`) `)
+36
View File
@@ -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)
}
+12 -3
View File
@@ -1,10 +1,11 @@
module github.com/longbin/agent-notify module github.com/longbin/agent-notify
go 1.22.2 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbletea v0.26.6 github.com/charmbracelet/bubbletea v0.26.6
modernc.org/sqlite v1.34.5
) )
require ( require (
@@ -12,13 +13,21 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // 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/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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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 github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // 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
) )
+51 -4
View File
@@ -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/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 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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=
+2
View File
@@ -66,6 +66,8 @@ func Default() Config {
}, },
Remote: Remote{ Remote: Remote{
Enabled: false, Enabled: false,
URL: "",
Token: "",
TimeoutMS: 2000, TimeoutMS: 2000,
}, },
} }
+5 -1
View File
@@ -53,6 +53,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
return err return err
} }
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title) 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 return nil
} }
+10 -4
View File
@@ -12,9 +12,11 @@ import (
) )
type cursorPayload struct { type cursorPayload struct {
WorkspaceRoots []string `json:"workspace_roots"` WorkspaceRoots []string `json:"workspace_roots"`
HookEventName string `json:"hook_event_name"` HookEventName string `json:"hook_event_name"`
Status string `json:"status"` Status string `json:"status"`
ConversationID string `json:"conversation_id"`
TranscriptPath string `json:"transcript_path"`
} }
var ( var (
@@ -64,7 +66,11 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
return err return err
} }
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title) 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 return nil
} }
+72
View File
@@ -4,11 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/longbin/agent-notify/internal/config" "github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox" "github.com/longbin/agent-notify/internal/inbox"
"github.com/longbin/agent-notify/internal/remote"
) )
func TestCursorStopHookDisabled(t *testing.T) { func TestCursorStopHookDisabled(t *testing.T) {
@@ -64,6 +67,7 @@ func TestCursorHookUploadsInboxRecord(t *testing.T) {
) )
cfg := config.Default() cfg := config.Default()
cfg.Inbox.Enabled = true
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -87,6 +91,7 @@ func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
) )
cfg := config.Default() cfg := config.Default()
cfg.Inbox.Enabled = true
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -130,6 +135,7 @@ func TestClaudeHookUploadsInboxRecord(t *testing.T) {
) )
cfg := config.Default() cfg := config.Default()
cfg.Inbox.Enabled = true
var out bytes.Buffer var out bytes.Buffer
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil { if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -150,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app
appendInboxRecord = prevAppend 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)
}
}
+79
View File
@@ -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"
}
}
+55
View File
@@ -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
}
+36
View File
@@ -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 ""
}
+49
View File
@@ -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)
}
}
+109
View File
@@ -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)
}
}
+169
View File
@@ -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)
}
}
+253
View File
@@ -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)
}
+133
View File
@@ -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)
}
}
+11
View File
@@ -0,0 +1,11 @@
package server
import (
"net/http"
"github.com/longbin/agent-notify/web"
)
func webHandler() http.Handler {
return web.Handler()
}
+2
View File
@@ -0,0 +1,2 @@
{"role": "user", "content": "q"}
{"role": "assistant", "content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
+4
View File
@@ -0,0 +1,4 @@
{"role":"user","content":"hello"}
{"role":"assistant","content":"world"}
{"role":"user","content":"second question"}
{"role":"assistant","content":"final answer"}
+93
View File
@@ -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])
}
+21
View File
@@ -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
View File
@@ -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();
})();
+13
View File
@@ -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))
}
+55
View File
@@ -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
View File
@@ -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;
}