# 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.