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>
This commit is contained in:
@@ -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 4–8 |
|
||||||
|
| §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 2–7, 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 |
|
||||||
|
|
||||||
|
- Token:server `--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(可选保留至下版本删除)
|
||||||
Reference in New Issue
Block a user