12 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
laily 1e33bfa4bc feat: add remote config and host metadata
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:27:39 +08:00
laily 1e7efd9c3b docs: add remote dashboard design spec and implementation plan
Second-phase brainstorming: centralized HTTP server, Web dashboard,
and hook status reporting to replace deprecated local inbox.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:24:06 +08:00
33 changed files with 2507 additions and 42 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
contents: read
env:
GO_VERSION: "1.22.2"
GO_VERSION: "1.25.0"
jobs:
test:
+21 -26
View File
@@ -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 # 已弃用:见下方 Inboxdeprecated
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 配置位置
+1
View File
@@ -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>")
}
+17
View File
@@ -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
`)
+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)
}
@@ -0,0 +1,431 @@
# Agent Notify Remote Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace local inbox aggregation with a self-hosted HTTP server and Web dashboard that shows real-time Agent status across all machines, keyed by hostname+IP × cwd × agent type, with last user/agent message details from transcripts (≤2000 chars each).
**Architecture:** Add `internal/remote`, `internal/transcript`, `internal/hostmeta`, and `internal/server` (SQLite + REST + Bearer auth). Extend CLI with `agent-notify server` and embed `web/`. Hooks call `reportRemote` instead of `recordInbox`. Deprecate inbox commands with stderr warnings; default `[inbox] enabled = false`, new `[remote]` config section.
**Tech Stack:** Go stdlib, `modernc.org/sqlite` (pure Go, no CGO), `embed` for static web, existing `config`, `hook`, `logx`.
---
## File map
| Path | Responsibility |
|------|----------------|
| `internal/config/config.go` | `[remote]` struct; inbox default `enabled=false` |
| `internal/hostmeta/hostmeta.go` | hostname + IP collection |
| `internal/transcript/transcript.go` | parse jsonl tail, last user/agent, truncate |
| `internal/remote/report.go` | `StatusReport`, `SessionKey`, HTTP client |
| `internal/server/store.go` | SQLite upsert/list/meta, offline |
| `internal/server/http.go` | handlers + Bearer middleware |
| `internal/hook/remote.go` | `reportRemote` from cursor/claude hooks |
| `internal/hook/cursor.go` | extend payload; call reportRemote |
| `cmd/agent-notify/server.go` | `server` subcommand |
| `web/index.html`, `web/app.js`, `web/style.css` | dashboard |
| `README.md` | remote server + migration from inbox |
---
### Task 1: Remote config and host metadata
**Files:**
- Modify: `internal/config/config.go`
- Modify: `internal/config/config_test.go`
- Create: `internal/hostmeta/hostmeta.go`
- Create: `internal/hostmeta/hostmeta_test.go`
- [ ] **Step 1: Add failing config tests**
```go
func TestDefaultRemoteDisabledInbox(t *testing.T) {
cfg := Default()
if cfg.Inbox.Enabled {
t.Fatal("inbox should default disabled")
}
if cfg.Remote.TimeoutMS != 2000 {
t.Fatalf("remote timeout: got %d", cfg.Remote.TimeoutMS)
}
}
```
- [ ] **Step 2: Run test — expect FAIL**
Run: `go test ./internal/config -run TestDefaultRemote -v`
- [ ] **Step 3: Implement**
Add to `config.go`:
```go
type Remote struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
Token string `toml:"token"`
TimeoutMS int `toml:"timeout_ms"`
}
```
`Default()`: `Inbox.Enabled: false`, `Remote: {Enabled: false, TimeoutMS: 2000}`.
- [ ] **Step 4: hostmeta tests**
```go
func TestHostnameNonEmpty(t *testing.T) {
h, err := Hostname()
if err != nil || h == "" {
t.Fatalf("hostname: %q err=%v", h, err)
}
}
func TestIPsSkipsLoopback(t *testing.T) {
ips := IPs()
for _, ip := range ips {
if ip == "127.0.0.1" || ip == "::1" {
t.Fatalf("loopback in ips: %v", ips)
}
}
}
```
Implement `Hostname()` via `os.Hostname()`, `IPs()` via `net.Interfaces()` collecting non-loopback addresses.
- [ ] **Step 5: Run tests**
Run: `go test ./internal/config ./internal/hostmeta -v`
Expected: PASS
---
### Task 2: Transcript parser
**Files:**
- Create: `internal/transcript/transcript.go`
- Create: `internal/transcript/transcript_test.go`
- [ ] **Step 1: Failing tests with fixture jsonl**
Create `internal/transcript/testdata/sample.jsonl`:
```jsonl
{"role":"user","content":"hello"}
{"role":"assistant","content":"world"}
{"role":"user","content":"second question"}
{"role":"assistant","content":"final answer"}
```
```go
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) {
long := strings.Repeat("x", 3000)
// fixture with one long assistant line
u, a, err := LastMessages("testdata/long.jsonl", 2000)
if err != nil || len(a) != 2000 {
t.Fatalf("len=%d err=%v", len(a), err)
}
_ = u
}
```
- [ ] **Step 2: Run — expect FAIL**
Run: `go test ./internal/transcript -v`
- [ ] **Step 3: Implement**
- `LastMessages(path string, maxLen int) (lastUser, lastAgent string, err error)`
- Read file; if size > 256*1024, seek to tail only
- Decode line-by-line JSON; track last `role==user` and `role==assistant` (also accept `type` field aliases if present in samples)
- `truncate(s, maxLen)` with rune-safe cut
- [ ] **Step 4: Run — PASS**
---
### Task 3: Remote report model and client
**Files:**
- Create: `internal/remote/report.go`
- Create: `internal/remote/client.go`
- Create: `internal/remote/report_test.go`
- [ ] **Step 1: Failing session key test**
```go
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", k1)
}
}
```
- [ ] **Step 2: Failing client test with httptest**
```go
func TestClientPostStatus(t *testing.T) {
var got remote.StatusReport
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
http.Error(w, "auth", 401)
return
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := remote.NewClient(remote.ClientConfig{URL: srv.URL, Token: "secret", Timeout: time.Second})
err := c.Report(context.Background(), remote.StatusReport{Hostname: "h", Agent: "Cursor", CWD: "/p", Status: "waiting"})
if err != nil || got.Hostname != "h" {
t.Fatalf("err=%v got=%+v", err, got)
}
}
```
- [ ] **Step 3: Implement**
```go
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 { ... sha256 hex first 32 ... }
```
`Client.Report` POST `{url}/api/v1/status` with Bearer.
- [ ] **Step 4: PASS**
Run: `go test ./internal/remote -v`
---
### Task 4: SQLite server store
**Files:**
- Create: `internal/server/store.go`
- Create: `internal/server/store_test.go`
- [ ] **Step 1: Add `modernc.org/sqlite` to go.mod**
Run: `go get modernc.org/sqlite`
- [ ] **Step 2: Failing store tests**
```go
func TestUpsertAndList(t *testing.T) {
db := filepath.Join(t.TempDir(), "test.db")
s, err := Open(db)
// upsert twice same key, different status
// list returns 1 row with latest status
}
func TestOfflineAfter5Min(t *testing.T) {
// insert row with updated_at 10 minutes ago
// List applies offline when now-updated > 5min
}
```
- [ ] **Step 3: Implement Open, Upsert(report), List(filters), Meta()**
Merge rule for `last_user`/`last_agent`: empty incoming does not overwrite stored non-empty.
`List` computes `offline` when `time.Since(updated_at) > 5*time.Minute`.
- [ ] **Step 4: PASS**
Run: `go test ./internal/server -run Store -v`
---
### Task 5: HTTP API and auth
**Files:**
- Create: `internal/server/http.go`
- Create: `internal/server/http_test.go`
- [ ] **Step 1: Failing handler tests**
- POST without token → 401
- POST valid → 200, row in DB
- GET with `?agent=Cursor` filters
- GET `/healthz` without token → 200
- [ ] **Step 2: Implement**
```go
func NewHandler(store *Store, token string) http.Handler
```
Middleware: skip auth for `/healthz` and static `/` assets; require `Authorization: Bearer ` + constant-time compare.
Handlers: `handleStatusPost`, `handleStatusList`, `handleMeta`.
- [ ] **Step 3: PASS**
Run: `go test ./internal/server -v`
---
### Task 6: `agent-notify server` command
**Files:**
- Create: `cmd/agent-notify/server.go`
- Modify: `cmd/agent-notify/main.go`
- Modify: `cmd/agent-notify/version.go` usage string if needed
- [ ] **Step 1: Implement cmdServer**
Flags: `--listen` (default `:8080`), `--db` (default `./agent-notify.db`), `--token` (fallback env `AGENT_NOTIFY_TOKEN`). Error if token empty.
Wire `server.Open`, `server.NewHandler`, `http.ListenAndServe`.
- [ ] **Step 2: Manual smoke**
```bash
AGENT_NOTIFY_TOKEN=test go run ./cmd/agent-notify server --listen 127.0.0.1:18080 &
curl -s http://127.0.0.1:18080/healthz
curl -s -H "Authorization: Bearer test" -H "Content-Type: application/json" \
-d '{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"2026-06-02T00:00:00Z"}' \
http://127.0.0.1:18080/api/v1/status
```
Expected: `{"ok":true}`
- [ ] **Step 3: Update printUsage** — add `server` line
---
### Task 7: Hook integration
**Files:**
- Create: `internal/hook/remote.go`
- Modify: `internal/hook/cursor.go`
- Modify: `internal/hook/claude.go`
- Modify: `internal/hook/inbox.go` (leave but unused path)
- Modify: `internal/hook/hook_test.go`
- [ ] **Step 1: Extend cursorPayload**
```go
type cursorPayload struct {
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"`
}
```
- [ ] **Step 2: Implement reportRemote**
```go
func reportRemote(cfg config.Config, agent, event, cwd string, payload transcriptInput) {
if !cfg.Remote.Enabled || cfg.Remote.URL == "" {
return
}
// map event -> status
// hostmeta Hostname, IPs
// if transcript path: LastMessages
// client.Report with timeout
}
```
Replace `recordInbox(...)` calls in `RunCursor` / `RunClaude` with `reportRemote`.
- [ ] **Step 3: Hook tests**
Inject mock client; assert Report called on stop when remote enabled; assert NOT called when disabled; assert hook still returns nil when Report fails.
Run: `go test ./internal/hook -v`
---
### Task 8: Embedded Web UI
**Files:**
- Create: `web/index.html`
- Create: `web/app.js`
- Create: `web/style.css`
- Create: `internal/server/web.go` (embed + FileServer)
- Modify: `internal/server/http.go` — serve `/` from embed
- [ ] **Step 1: Minimal dashboard**
- Token prompt → sessionStorage
- Fetch `/api/v1/meta` and `/api/v1/status` every 3s with Bearer
- Sidebar filters (host, agent, status); table columns per spec §7
- Expand row for `last_user` / `last_agent` pre blocks
- [ ] **Step 2: Manual check**
Open `http://127.0.0.1:18080/`, enter token, see seeded row.
---
### Task 9: Deprecate inbox + docs
**Files:**
- Modify: `cmd/agent-notify/inbox.go` — print `warning: inbox is deprecated; use remote server` to stderr on any subcommand
- Modify: `internal/install/install.go` — default config includes `[remote]` commented template
- Modify: `internal/config/config.go``WriteDefault` includes remote section
- Modify: `README.md` — new Remote Dashboard section; inbox marked deprecated
- Modify: `internal/hook/inbox.go` — add comment deprecated
- [ ] **Step 1: doctor checks remote**
When `remote.enabled`, warn if `url` or `token` empty.
- [ ] **Step 2: Full test suite**
Run: `go test ./...`
Expected: all PASS (existing inbox tests still pass; inbox code remains)
---
### Task 10: Final verification
- [ ] **Step 1: Cross-build**
Run: `make test && make cross VERSION=v0.3.0`
- [ ] **Step 2: README example end-to-end**
Document server start + client config + hook stop updates dashboard.
---
## Plan self-review (spec coverage)
| Spec § | Task |
|--------|------|
| §3 architecture | Tasks 48 |
| §4 session_key + merge | Task 3, 4 |
| §5 API + Bearer | Task 5, 6 |
| §6 hook + transcript | Task 2, 7 |
| §7 Web UI | Task 8 |
| §8 config + deprecate inbox | Task 1, 9 |
| §9 errors/tests | Tasks 27, 10 |
No TBD placeholders in task steps.
@@ -0,0 +1,238 @@
# agent-notify 第二期:远端状态仪表盘 设计规格
**日期:** 2026-06-02
**状态:** 已批准(brainstorming
**目标:** 各开发机 Hook 将 Agent 实时状态上报到自托管 HTTP Server,Web 页面按机器、工作目录、Agent 类型分类查看;详情含最后一次用户与 Agent 消息(各 ≤2000 字符)。
---
## 1. 背景与目标
第一期(v0.2 inbox)在本地聚合通知记录(`inbox serve` + SSH `RemoteForward` + CLI/TUI)。第二期改为**中心化远端 Server + Web 仪表盘**,满足:
- **实时状态**(非纯事件流):每台机器、每个工作目录、每种 Agent 类型一行当前状态
- 上报包含 **hostname + IP**、**工作目录**
- 详情:从 hook 提供的 **transcript** 解析最后 user/assistant 各一条,**单条最多 2000 字符**
- Web 支持按机器 / 目录 / Agent / 状态 **分类筛选**
- **单机自托管**,共享 **Bearer Token**;网络暴露由用户自行配置
- **弃用本地 inbox**Hook 默认只报远端;`inbox` 子命令标记 deprecated,代码下个大版本再删
### 成功标准
- `agent-notify server` 可启动 HTTP 服务(API + 内嵌 Web
- 各机器配置 `[remote]` 后,Cursor/Claude hook 在发通知后上报状态,失败不影响 hook 退出码
- Web 展示所有机器的合并视图,默认按 `updated_at` 降序;`offline` 由 server 根据 5 分钟无更新判定
- 同一 `hostname + ip + cwd + agent` 仅一条记录,多会话取最近活跃
### 非目标(第二期)
- 历史事件流、告警推送
- 多用户 RBAC、每机器独立 token
- `beforeSubmitPrompt` 实时更新 `last_user`
- 删除 inbox 源码(仅 deprecated
- macOS/Windows server 部署文档(实现保持可交叉编译,优先 Linux 验证)
---
## 2. 方案选择
| 方案 | 描述 | 结论 |
|------|------|------|
| A | 同仓库 `agent-notify server` + SQLite + embed Web | **选用** |
| B | 独立 server 二进制 | 版本对齐成本高,YAGNI |
| C | 内存态 + JSON 快照 | 查询与持久化弱 |
---
## 3. 架构
```
┌─────────────────┐ hook ┌──────────────────┐
│ Cursor / Claude │ ────────────► │ agent-notify hook │
└─────────────────┘ └────────┬─────────┘
│ OSC 777(不变)
│ POST /api/v1/status
┌──────────────────────┐
│ agent-notify server │
│ SQLite + HTTP API │
│ embed Web (轮询 3s) │
└──────────────────────┘
```
### 组件
1. **`internal/remote`** — 状态模型、`session_key`、HTTP 上报客户端
2. **`internal/transcript`** — 从 `transcript_path` jsonl 解析最后 user/assistant,截断 2000
3. **`internal/hostmeta`** — `hostname` + 非 loopback IP 列表
4. **`internal/server`** — SQLite store、Bearer 中间件、REST handlers、`offline` 计算
5. **`web/`** — 静态仪表盘(embed
6. **Hook 改动**`recordInbox``reportRemote``[inbox]` 默认关闭并 deprecated
---
## 4. 数据模型
### Session 键(合并规则 C
```
session_key = SHA256(hostname + "\0" + primary_ip + "\0" + cwd + "\0" + agent)[:32] hex
```
- `primary_ip`:上报 `ips` 中第一个 IPv4,无则空字符串
- 同一键 **upsert** 覆盖,不保留历史行
- `conversation_id` 仅存字段,不拆行
### 状态枚举
| status | 含义 | 典型 hook |
|--------|------|-----------|
| `running` | Agent 刚产出回复 | `response` |
| `waiting` | 等待用户输入 | `stop` |
| `tool` | 工具执行中 | `tool` |
| `idle` | 长时间空闲 | `idle` |
| `offline` | 5 分钟无更新 | server 计算 |
### 上报 JSON`POST /api/v1/status`
```json
{
"hostname": "dev-box",
"ips": ["192.168.1.10"],
"agent": "Cursor",
"cwd": "/home/user/proj",
"status": "waiting",
"event": "stop",
"conversation_id": "uuid",
"last_user": "…",
"last_agent": "…",
"updated_at": "2026-06-02T12:00:00Z"
}
```
- `last_user` / `last_agent`:可选;有 transcript 时更新,截断 2000;读失败时不覆盖已有字段(server merge:空字符串不覆盖非空列)
- `updated_at`:客户端 UTC;server 亦可写入 `received_at`
### SQLite 表 `sessions`
| 列 | 类型 | 说明 |
|----|------|------|
| session_key | TEXT PK | |
| hostname | TEXT | |
| ips | TEXT | JSON 数组 |
| agent | TEXT | |
| cwd | TEXT | |
| status | TEXT | |
| event | TEXT | 最近触发事件名 |
| conversation_id | TEXT | |
| last_user | TEXT | |
| last_agent | TEXT | |
| updated_at | TEXT | ISO8601 |
| received_at | TEXT | server 写入 |
索引:`hostname`, `cwd`, `agent`, `updated_at`
---
## 5. API
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/api/v1/status` | Bearer | Upsert |
| GET | `/api/v1/status` | Bearer | 列表;query: `host`, `cwd`, `agent`, `status` |
| GET | `/api/v1/meta` | Bearer | distinct hosts / cwds / agents |
| GET | `/healthz` | 无 | 健康检查 |
| GET | `/` | 无 | Web UI |
- Tokenserver `--token``AGENT_NOTIFY_TOKEN`(启动必填)
- 401:缺失或错误 token
---
## 6. Hook 与 Transcript
### 流程
```
hook → 通知(不变)→ build report → 解析 transcript(可选)→ POST remote
```
- 失败仅 `logx.Append`,返回码仍为 0
- 替换 `recordInbox``cfg.Remote.Enabled` 为 false 时跳过上报
### 事件 → status
| event | status |
|-------|--------|
| stop | waiting |
| response | running |
| tool | tool |
| idle | idle |
### Transcript
- Cursor stop payload`transcript_path` → 读 jsonl 尾部窗口(最大 256KB)→ 最后 user/assistant
- Claude:有 path 则同逻辑;无则跳过文本
- 字段映射:支持 `role`+`content` 或 Cursor transcript 常见行格式(实现时以实测样本为准)
### 机器标识
- `hostname``os.Hostname()`
- `ips`:网卡非 loopback IPv4(可含 IPv6),去重排序
---
## 7. Web UI
- 内嵌静态页,`GET /api/v1/status` 每 3s 轮询
- 侧栏筛选:机器(hostname + ip)、Agent、状态;工作目录可侧栏或下拉
- 主表列:机器 | 目录(basename,完整路径 title| Agent | 状态 | 更新时间 | 详情展开(last_user / last_agent
- Token:首次输入存 `sessionStorage`,请求带 `Authorization`
- `offline` 行样式变灰(由 API 返回 `status=offline`
---
## 8. 配置
```toml
[remote]
enabled = true
url = "http://your-server:8080"
token = "shared-secret"
timeout_ms = 2000
[inbox]
enabled = false # deprecated
```
- `install` 默认写入 `[remote]` 占位与 `[inbox] enabled = false`
- `doctor` 检查 `remote.url``remote.token` 非空(enabled 时)
### 命令
```bash
agent-notify server --listen :8080 --db ./agent-notify.db --token "$AGENT_NOTIFY_TOKEN"
```
`inbox` 子命令保留,执行时打印 deprecation 警告。
---
## 9. 错误处理与测试
| 场景 | 行为 |
|------|------|
| 远端不可达 | log,通知照常 |
| Token 错误 | 401 |
| Transcript 过大 | 只读尾部 256KB |
| 同键多次上报 | upsert |
测试:`session_key` 稳定、截断 2000、Bearer、offline 5min、hook 失败不影响 RunCursor/RunClaude。
---
## 10. 迁移说明
1. 在自托管机启动 `agent-notify server`
2. 各开发机 `config.toml` 配置 `[remote]`
3. 停止依赖 `inbox serve` / SSH RemoteForward(可选保留至下版本删除)
+12 -3
View File
@@ -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
)
+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/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=
+15 -1
View File
@@ -12,6 +12,7 @@ type Config struct {
Events Events `toml:"events"`
Notify Notify `toml:"notify"`
Inbox Inbox `toml:"inbox"`
Remote Remote `toml:"remote"`
}
type Events struct {
@@ -38,6 +39,13 @@ type Inbox struct {
TimeoutMS int `toml:"timeout_ms"`
}
type Remote struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
Token string `toml:"token"`
TimeoutMS int `toml:"timeout_ms"`
}
func Default() Config {
return Config{
Events: Events{Stop: true, Response: true, Idle: false, Tool: false},
@@ -49,13 +57,19 @@ func Default() Config {
BodyTool: "工具执行完成",
},
Inbox: Inbox{
Enabled: true,
Enabled: false,
Socket: DefaultInboxSocket(),
RemoteSocket: DefaultInboxRemoteSocket(),
Addr: "127.0.0.1:17777",
FallbackLocal: true,
TimeoutMS: 500,
},
Remote: Remote{
Enabled: false,
URL: "",
Token: "",
TimeoutMS: 2000,
},
}
}
+12 -2
View File
@@ -18,8 +18,8 @@ func TestDefaultConfig(t *testing.T) {
if cfg.Notify.Protocol != "osc777" {
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
}
if !cfg.Inbox.Enabled {
t.Fatal("expected inbox enabled by default")
if cfg.Inbox.Enabled {
t.Fatal("expected inbox disabled by default")
}
if cfg.Inbox.Addr != "127.0.0.1:17777" {
t.Fatalf("expected default inbox addr, got %q", cfg.Inbox.Addr)
@@ -104,6 +104,16 @@ func TestDefaultInboxSocketPrefersXDGRuntimeDir(t *testing.T) {
}
}
func TestDefaultRemoteDisabledInbox(t *testing.T) {
cfg := Default()
if cfg.Inbox.Enabled {
t.Fatal("inbox should default disabled")
}
if cfg.Remote.TimeoutMS != 2000 {
t.Fatalf("remote timeout: got %d", cfg.Remote.TimeoutMS)
}
}
func TestEventEnabled(t *testing.T) {
cfg := Default()
if !cfg.EventEnabled("stop") {
+5 -1
View File
@@ -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
View File
@@ -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
}
+72
View File
@@ -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)
}
}
+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 hostmeta
import (
"net"
"os"
"sort"
)
func Hostname() (string, error) {
return os.Hostname()
}
func IPs() []string {
seen := make(map[string]struct{})
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
if ip == nil || ip.IsLoopback() {
continue
}
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
s := ip.String()
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
ips = append(ips, s)
}
}
sort.Strings(ips)
return ips
}
+19
View File
@@ -0,0 +1,19 @@
package hostmeta
import "testing"
func TestHostnameNonEmpty(t *testing.T) {
h, err := Hostname()
if err != nil || h == "" {
t.Fatalf("hostname: %q err=%v", h, err)
}
}
func TestIPsSkipsLoopback(t *testing.T) {
ips := IPs()
for _, ip := range ips {
if ip == "127.0.0.1" || ip == "::1" {
t.Fatalf("loopback in ips: %v", ips)
}
}
}
+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;
}