Second-phase brainstorming: centralized HTTP server, Web dashboard, and hook status reporting to replace deprecated local inbox. Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
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
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:
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
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:
{"role":"user","content":"hello"}
{"role":"assistant","content":"world"}
{"role":"user","content":"second question"}
{"role":"assistant","content":"final answer"}
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==userandrole==assistant(also accepttypefield 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
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
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
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/sqliteto go.mod
Run: go get modernc.org/sqlite
- Step 2: Failing store tests
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=Cursorfilters -
GET
/healthzwithout token → 200 -
Step 2: Implement
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.gousage 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
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
serverline
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
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
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/metaand/api/v1/statusevery 3s with Bearer -
Sidebar filters (host, agent, status); table columns per spec §7
-
Expand row for
last_user/last_agentpre 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— printwarning: inbox is deprecated; use remote serverto stderr on any subcommand -
Modify:
internal/install/install.go— default config includes[remote]commented template -
Modify:
internal/config/config.go—WriteDefaultincludes 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.