Files
agent-dashboard/docs/superpowers/plans/2026-06-02-agent-notify-remote-dashboard.md
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

432 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.