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

12 KiB
Raw Blame History

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==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

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/sqlite to 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=Cursor filters

  • GET /healthz without 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.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
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

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/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.goWriteDefault 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.