✨ feat(board): native web dashboard for agent session monitoring#54
Conversation
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>
There was a problem hiding this comment.
This PR is marked... OUT! ✊
Blocking
cmd/avenor/board.go:76:runBoardreturns 0 immediately after receivingSIGINT/SIGTERMwithout callingsrv.Stop(), leaving the HTTP server goroutine running.
Warning
internal/board/server.go:541: SSE data is written usingfmt.Fprintfwithstrings.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:writeJSONsilently discards the error fromjson.NewEncoder(w).Encode(v).packages/board/src/api.ts:93: The authentication token is appended to theEventSourceURL as a query parameter, leaking it into browser history and server logs.packages/board/src/accumulation.ts:62:isBufferedreturnsfalseifaccTypeis set butaccTextis empty, which is misleading given the property name.
Nit
internal/board/server.go:499:handleEventswrites an error message directly into the SSE response body usinghttp.Errorwithout an SSE event type, which will cause malformed frames forEventSourceclients.
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.
| var subResult struct { | ||
| Subscribed bool `json:"subscribed"` | ||
| } | ||
| if err := c.Call("subscribe", nil, &subResult); err != nil { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
👾 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.
There was a problem hiding this comment.
Still relevant in the current code.
| return path | ||
| } | ||
|
|
||
| func commandExists(name string) bool { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
👾 AI Agent
Fixed in cca3835 — deleted the unused commandExists function. openBrowser now uses lookPath directly via exec.LookPath.
| return 0 | ||
| } | ||
|
|
||
| func resolveHome(path string) string { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
👾 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.
| // Query status | ||
| status, statusErr := c.Status("") | ||
|
|
||
| _ = c.Close() // best effort |
There was a problem hiding this comment.
The error from c.Close() is ignored, which masks potential network or connection issues.
There was a problem hiding this comment.
👾 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.
| if err != nil { | ||
| continue | ||
| } | ||
| fmt.Fprintf(w, "event: event\ndata: %s\n\n", data) |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
👾 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
|
@umpire-bot review |
| signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) | ||
| <-sig | ||
| fmt.Fprintln(os.Stderr, "\navenor board: shutting down...") | ||
| return 0 |
There was a problem hiding this comment.
👾 AI Agent
Fixed in 34674ca — runBoard now calls srv.Stop(ctx) with a 5s timeout on SIGINT/SIGTERM before returning.
|
|
||
| // ── Helpers ───────────────────────────────────────────────────────────────── | ||
|
|
||
| func writeJSON(w http.ResponseWriter, v any) { |
There was a problem hiding this comment.
👾 AI Agent
Fixed in 34674ca — writeJSON now captures the encode error with an explicit comment noting it is best-effort (headers already written by caller).
| // (the server accepts ?token= for SSE endpoints) | ||
| const token = localStorage.getItem('avenor_auth_token') | ||
| if (token) { | ||
| url += `&token=${encodeURIComponent(token)}` |
There was a problem hiding this comment.
👾 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 { |
There was a problem hiding this comment.
👾 AI Agent
Fixed in 34674ca — isBuffered now returns this.accType !== \' to reflect that any accumulated type indicates buffering state, regardless of whether accText is non-empty yet.
|
|
||
| c, err := client.Dial(socketPath) | ||
| if err != nil { | ||
| http.Error(w, "failed to connect to socket: "+err.Error(), http.StatusBadGateway) |
There was a problem hiding this comment.
👾 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)
|
Addressed 5 comments across 4 files:
Review loop: 1 iteration. All concerns resolved. |
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/)agent.thought_chunk,tool.call(collapsible),agent.status,session.start/end,permission.request--allow-mutations)?token=from URL into localStorage, strips from address barBackend (
internal/board/)/api/sockets,/api/status,/api/list,/api/eventsSSE,/api/config,/api/cancel,/api/prompt)--token(Bearer header +?token=for SSE) or--no-auth(open access)0.0.0.0requires--tokenor--no-authgo:embedfor static assets; SPA fallback for/board/*http.FileServerredirect issues)CLI (
cmd/avenor/board.go)avenor boardsubcommand with--addr,--scan,--socket,--token,--no-auth,--allow-mutations,--no-open$DISPLAY/$WAYLAND_DISPLAY)?token=when--tokenis setBuild (
mise.toml)js-board-buildtask for Vite buildgo-builddepends onjs-board-build, copiesdist/intointernal/board/dist/Usage
Review fixes applied
addEventListener("event", ...)instead ofonmessage(server sendsevent: event)?token=query paramhttp.FileServer(fixes 301 redirect bug)--no-authflag for trusted networks (skips all auth)exec.LookPathfor browser binary resolution (fixesxdg-openexec failure)