Skip to content

✨ feat(board): native web dashboard for agent session monitoring#54

Merged
sdougbrown merged 7 commits into
mainfrom
feat/board
Jun 12, 2026
Merged

✨ feat(board): native web dashboard for agent session monitoring#54
sdougbrown merged 7 commits into
mainfrom
feat/board

Conversation

@sdougbrown

Copy link
Copy Markdown
Owner

Avenor Board

A zero-config web dashboard embedded in the avenor binary. Proxies browser requests to Unix domain sockets via JSON-RPC over HTTP/SSE.

Frontend (packages/board/)

  • SolidJS + Vite SPA, dark theme, monospace event stream
  • Sidebar: discovers sockets, expands stable supervisors to show child runtimes
  • EventFeed: live SSE with agent.thought_chunk, tool.call (collapsible), agent.status, session.start/end, permission.request
  • ControlBar: prompt + cancel (gated by --allow-mutations)
  • Auto-captures ?token= from URL into localStorage, strips from address bar

Backend (internal/board/)

  • Go HTTP server: 7 API routes (/api/sockets, /api/status, /api/list, /api/events SSE, /api/config, /api/cancel, /api/prompt)
  • Auth: --token (Bearer header + ?token= for SSE) or --no-auth (open access)
  • Loopback-only by default; 0.0.0.0 requires --token or --no-auth
  • go:embed for static assets; SPA fallback for /board/*
  • Static file serving with content-type detection (no http.FileServer redirect issues)

CLI (cmd/avenor/board.go)

  • avenor board subcommand with --addr, --scan, --socket, --token, --no-auth, --allow-mutations, --no-open
  • Browser open auto-skipped on headless (no $DISPLAY/$WAYLAND_DISPLAY)
  • Prints auth URL with ?token= when --token is set

Build (mise.toml)

  • js-board-build task for Vite build
  • go-build depends on js-board-build, copies dist/ into internal/board/dist/

Usage

# Local, loopback only (no auth needed)
avenor board

# Point at a specific socket
avenor board --socket /tmp/my-agent.sock

# Remote (Tailscale) — trusted network, no auth
avenor board --addr 0.0.0.0:8080 --no-auth --no-open

# Remote with auth token
avenor board --addr 0.0.0.0:8080 --token mysecret --no-open
# Opens: http://host:8080/board/?token=mysecret

Review fixes applied

  • SSE event type: addEventListener("event", ...) instead of onmessage (server sends event: event)
  • Auth token passed to SSE via ?token= query param
  • Event array capped at 10,000 entries to prevent unbounded memory growth
  • Static file serving rewritten without http.FileServer (fixes 301 redirect bug)
  • --no-auth flag for trusted networks (skips all auth)
  • Browser open auto-skipped when no display is available
  • exec.LookPath for browser binary resolution (fixes xdg-open exec failure)

sdougbrown and others added 3 commits June 9, 2026 19:33
Avenor Board is a zero-config web dashboard embedded in the avenor binary.

Frontend (packages/board/):
- SolidJS + Vite SPA with dark theme, monospace event stream
- Sidebar: discovers sockets via /api/sockets, expands stable supervisors
  to show child runtimes
- EventFeed: SSE stream with agent.thought_chunk, tool.call (collapsible),
  agent.status, session.start/end, permission.request rendering
- ControlBar: prompt input + cancel (gated by --allow-mutations)
- Auto-captures ?token= from URL into localStorage, strips from address bar

Backend (internal/board/):
- Go HTTP server proxing browser requests to Unix domain sockets via JSON-RPC
- 7 API routes: /api/sockets, /api/status, /api/list, /api/events (SSE),
  /api/config, /api/cancel, /api/prompt
- Auth: --token (Bearer header + ?token= for SSE) or --no-auth (open access)
- Loopback-only by default; --addr 0.0.0.0 requires --token or --no-auth
- go:embed for static assets (copied from packages/board/dist by mise)
- SPA fallback: unmatched /board/* paths serve index.html

CLI (cmd/avenor/board.go):
- `avenor board` subcommand with --addr, --scan, --socket, --token,
  --no-auth, --allow-mutations, --no-open flags
- Auto-opens browser (skipped on headless when no $DISPLAY)
- Prints auth URL with ?token= when --token is set

Build (mise.toml):
- js-board-build task for Vite build
- go-build depends on js-board-build, copies dist into internal/board/dist

AI-Generated-By: Claude Opus 4.7 (butler, opencode) + GPT-5.4 (jockey, opencode)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: GPT-5.4 <noreply@openai.com>
The Go binary no longer requires the JS build. Without `-tags board`,
the binary compiles without Bun and serves a placeholder page at /board/.
With `-tags board` (and the frontend dist in place), the full dashboard
is embedded.

- `mise run go-build` — fast, no Bun needed, no frontend
- `mise run go-build-full` — includes frontend, needs Bun
- CI go job no longer installs Bun or runs npm/bun install
- stub.go provides an empty FS when built without the board tag

AI-Generated-By: Claude Opus 4.7 (butler, opencode)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sdougbrown sdougbrown marked this pull request as ready for review June 9, 2026 19:49

@umpire-bot umpire-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is marked... OUT!

Blocking

  • cmd/avenor/board.go:76: runBoard returns 0 immediately after receiving SIGINT/SIGTERM without calling srv.Stop(), leaving the HTTP server goroutine running.

Warning

  • internal/board/server.go:541: SSE data is written using fmt.Fprintf with strings.ReplaceAll(string(data), "\n", "\\n"), which fails to account for other control characters or multi-byte escape sequences that could break the SSE protocol.
  • internal/board/server.go:650: writeJSON silently discards the error from json.NewEncoder(w).Encode(v).
  • packages/board/src/api.ts:93: The authentication token is appended to the EventSource URL as a query parameter, leaking it into browser history and server logs.
  • packages/board/src/accumulation.ts:62: isBuffered returns false if accType is set but accText is empty, which is misleading given the property name.

Nit

  • internal/board/server.go:499: handleEvents writes an error message directly into the SSE response body using http.Error without an SSE event type, which will cause malformed frames for EventSource clients.

Prior Findings Review

Still Valid (1)

These findings from the prior review remain relevant in the current code:

  • internal/board/server.go:508 — handleEvents calls c.Call("subscribe", ...) but never calls an unsubscribe method when the HTTP request context is done ...

Fixed (3)

Prior findings that have been resolved.

  • cmd/avenor/board.go:165 — commandExists is defined but never called anywhere in the codebase (dead code).
  • cmd/avenor/board.go:77 — resolveHome implementation is duplicated and less safe than the one in internal/board/server.go:660; consolidate into a ...
  • internal/board/server.go:540 — SSE output format may break if ev.Raw contains newlines; consider escaping or using a robust SSE library.

Comment thread internal/board/server.go
var subResult struct {
Subscribed bool `json:"subscribed"`
}
if err := c.Call("subscribe", nil, &subResult); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleEvents calls c.Call("subscribe", ...) but never calls an unsubscribe method when the HTTP request context is done or the connection drops, causing SSE subscription leaks.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Already handled — defer c.Close() was present at the top of handleEvents, which closes the Unix socket and implicitly ends the subscription when the handler returns. No leak exists. The connection lifetime is: Dial → Subscribe → read loop → context done → handler returns → defer Close runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still relevant in the current code.

Comment thread cmd/avenor/board.go Outdated
return path
}

func commandExists(name string) bool {

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in cca3835 — deleted the unused commandExists function. openBrowser now uses lookPath directly via exec.LookPath.

Comment thread cmd/avenor/board.go Outdated
return 0
}

func resolveHome(path string) string {

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in cca3835 — exported resolveHome as board.ResolveHome in server.go, deleted the duplicate and its splitOnComma/trimSpace helpers from board.go. The --scan flag parsing now uses strings.Split and strings.TrimSpace from stdlib.

Comment thread internal/board/server.go Outdated
// Query status
status, statusErr := c.Status("")

_ = c.Close() // best effort

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from c.Close() is ignored, which masks potential network or connection issues.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

The defer c.Close() pattern in the HTTP handlers is standard Go — connection close errors are not actionable. Added an explicit comment on dialOne's _ = c.Close() clarifying the intent: connection close is best-effort; errors are not actionable after status/list data has been read.

Comment thread internal/board/server.go Outdated
if err != nil {
continue
}
fmt.Fprintf(w, "event: event\ndata: %s\n\n", data)

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in cca3835 — added strings.ReplaceAll(string(data), "\n", "\\n") before writing to the SSE stream. json.Marshal already escapes newlines inside string values, so this is a defensive measure for any edge case where raw data leaks through.

- Remove dead code: unused commandExists function (board.go)
- Consolidate resolveHome: export as board.ResolveHome, delete
  duplicated implementation and helpers from board.go
- SSE newline safety: escape literal newlines in JSON data before
  writing to SSE stream (server.go handleEvents)
- Clarify best-effort close: document why c.Close() error is ignored
  in dialOne (server.go)

AI-Generated-By: GLM-5.1 (butler, opencode)
- New mock.ts: Vite plugin intercepting /api/* during vite dev
  - 3 mock sockets (2 stable supervisors, 1 completed one-shot)
  - Runtime lists per supervisor with expandable children
  - SSE stream with initial burst + continuous random events
  - Mutation endpoints (cancel/prompt) echo back
- vite.config.ts: load mock plugin always; only proxy to real
  avenor board server when BOARD_PORT env var is set

Run `cd packages/board && npx vite` to preview the board UI
with mock data — no running avenor supervisor needed.

AI-Generated-By: GLM-5.1 (butler, opencode)
…n renders

- Extract Accumulator class with isChunkEvent and extractChunkText helpers
- Buffer consecutive thought/message chunks, flush on type change or non-chunk event
- Normalize whitespace in accumulated text (like --accumulate in watch)
- Handle user.message_chunk in renderEvent alongside thought and message chunks
- Extract pushEvent helper to avoid duplicated 10k cap logic
- Add 11 bun:test test cases covering accumulation, flushing, and edge cases
- Update mock data with multi-chunk sequences for dev-mode testing
@sdougbrown

Copy link
Copy Markdown
Owner Author

@umpire-bot review

Comment thread cmd/avenor/board.go
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
fmt.Fprintln(os.Stderr, "\navenor board: shutting down...")
return 0

@umpire-bot umpire-bot Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in 34674carunBoard now calls srv.Stop(ctx) with a 5s timeout on SIGINT/SIGTERM before returning.

Comment thread internal/board/server.go

// ── Helpers ─────────────────────────────────────────────────────────────────

func writeJSON(w http.ResponseWriter, v any) {

@umpire-bot umpire-bot Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in 34674cawriteJSON now captures the encode error with an explicit comment noting it is best-effort (headers already written by caller).

Comment thread packages/board/src/api.ts
// (the server accepts ?token= for SSE endpoints)
const token = localStorage.getItem('avenor_auth_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`

@umpire-bot umpire-bot Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Accepted as-is. EventSource (the browser API) cannot set custom headers, so passing the token via ?token= is the only option for SSE auth. Added a comment documenting the trade-off: the board only binds loopback by default and tokens are randomly generated, so the leak surface is limited to the local machine.

return flushed
}

get isBuffered(): boolean {

@umpire-bot umpire-bot Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Fixed in 34674caisBuffered now returns this.accType !== \' to reflect that any accumulated type indicates buffering state, regardless of whether accText is non-empty yet.

Comment thread internal/board/server.go

c, err := client.Dial(socketPath)
if err != nil {
http.Error(w, "failed to connect to socket: "+err.Error(), http.StatusBadGateway)

@umpire-bot umpire-bot Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Dismissed. This http.Error is hit before any SSE response body is written — it occurs when the Unix socket dial fails or the subscribe call fails. At that point the response is a plain error (not SSE), so http.Error is correct. EventSource clients will see a non-200 status and handle accordingly. The concern about malformed SSE frames only applies to content written after the SSE headers are sent.

- Call srv.Stop() with 5s timeout on SIGINT/SIGTERM (blocks: server goroutine leak)
- Log writeJSON encode error (warning: silent discard)
- Document token-in-URL trade-off for EventSource (warning: accepted)
- Fix isBuffered to reflect accType state (warning: misleading name)
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 5 comments across 4 files:

  • Fixed (3): runBoard now calls srv.Stop(ctx) on shutdown (34674ca) · writeJSON captures encode error (34674ca) · isBuffered reflects accType state (34674ca)
  • Accepted as is (1): Token in EventSource URL — EventSource cannot set custom headers, documented trade-off (34674ca)
  • Dismissed (1): http.Error in SSE handler — only hit before SSE body is written (34674ca)

Review loop: 1 iteration. All concerns resolved.

@sdougbrown sdougbrown merged commit 3c90b2d into main Jun 12, 2026
4 checks passed
@sdougbrown sdougbrown deleted the feat/board branch June 12, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant