Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
71db980
feat(mcp): drive ghost serve UI for query visualization & charting
murrayju Jun 18, 2026
6d9a8d7
feat(mcp): always return a chart image from visualization tools
murrayju Jun 18, 2026
559877d
fix(mcp): report chart render failures as chart_error, never fail the…
murrayju Jun 18, 2026
8fed8a4
feat(web): show a disconnected banner when the backend goes away
murrayju Jun 18, 2026
46f615b
feat(serve): add -p/--port and -n/--no-open shorthands; use them in r…
murrayju Jun 18, 2026
f477466
fix(web): disable animation for agent chart screenshots
murrayju Jun 18, 2026
fe793bb
feat(web): add chart config history
murrayju Jun 18, 2026
d874218
feat(mcp): surface chart config editor diagnostics to the agent
murrayju Jun 22, 2026
bca6696
fix(serve): enforce read-only mode, report true row count, harden age…
murrayju Jun 22, 2026
3238413
fix(mcp): correct RowsAffected semantics, join close errors, fix scre…
murrayju Jun 22, 2026
99e83c1
fix(mcp): check API client before opening browser in chart/ui_state t…
murrayju Jun 22, 2026
a1fdf81
fix(web): record the chart config that actually rendered, not the liv…
murrayju Jun 22, 2026
c7a7d2c
fix(serve): propagate MCP request cancellation to browser-run queries
murrayju Jun 22, 2026
6b16739
fix(web): harden agent last-run scoping and panel teardown
murrayju Jun 22, 2026
5c4cdad
fix(serve): return config from loadClient for consistent snapshot
murrayju Jun 22, 2026
6859cf8
fix(mcp): render visualized SQL NULL cells as NULL, not empty string
murrayju Jun 22, 2026
16905d9
fix(mcp): report accurate rows_affected for visualized SQL
murrayju Jun 22, 2026
a338daf
fix(mcp): include structured output in text content for visualized SQL
murrayju Jun 22, 2026
0a7e086
fix(mcp): don't duplicate chart error in visualize prose summary
murrayju Jun 22, 2026
fe034d0
fix(mcp): normalize database name refs to id for visualization UI
murrayju Jun 22, 2026
43c29e7
fix(serve): clear stale agent state on SSE reconnect
murrayju Jun 22, 2026
37c9101
fix(mcp): carry command_tag through visualized query results
murrayju Jun 22, 2026
3ceb25d
fix(mcp): surface chart render errors from ghost_chart gracefully
murrayju Jun 22, 2026
28626cf
fix(web): honor config backgroundColor in chart screenshots
murrayju Jun 22, 2026
7140750
fix(web): wait for ECharts render to finish before capturing screenshot
murrayju Jun 23, 2026
b616bd9
fix(web): chart the full result set, not the agent's row cap
murrayju Jun 23, 2026
1881b4c
fix(mcp): render JSON cell values as JSON, not Go debug format
murrayju Jun 23, 2026
aaa4ea2
fix(mcp): keep rows_affected/command_tag for no-row UI-state runs
murrayju Jun 23, 2026
25e71fa
fix(web): validate chart command before mutating the UI
murrayju Jun 23, 2026
93e0c8e
fix(web): scope agent command cancellation to its own query
murrayju Jun 23, 2026
d0a3b3d
fix(mcp): preserve exact number text in visualized cell values
murrayju Jun 23, 2026
93678b3
fix(mcp): fail fast with friendly error on visualizing a non-ready db
murrayju Jun 23, 2026
5ec1701
fix(web): bound pre-empted cancel tracking to a single slot
murrayju Jun 23, 2026
c79d53e
fix(web): clear diagnostics timeout and clarify torn-down run error
murrayju Jun 23, 2026
9c6375b
fix(serve): clear stale slot when removing a bridge client
murrayju Jun 23, 2026
3a394ab
docs: track review cycle progress (rounds 1-3)
murrayju Jun 23, 2026
5e7306e
fix(mcp): render visualized booleans as Postgres t/f text
murrayju Jun 23, 2026
95a48c0
docs: track review cycle progress (round 4 + convergence)
murrayju Jun 23, 2026
8ce8b00
fix(web): tear down chart screenshot timer/listener on every settle path
murrayju Jun 23, 2026
63b2795
fix(serve): deliver agent command cancels reliably, not best-effort
murrayju Jun 23, 2026
588b126
docs: track review cycle progress (round 5)
murrayju Jun 23, 2026
0c5d31d
fix(web): harden ghost_chart against failed/uncached last runs
murrayju Jun 23, 2026
330b66e
docs: track review cycle progress (round 6)
murrayju Jun 23, 2026
e285c84
fix(web): abort in-flight command on SSE drop; seed chart recorder ba…
murrayju Jun 23, 2026
71b22ae
docs: track review cycle progress (round 7)
murrayju Jun 23, 2026
6ee148a
fix(web): abort ghost_chart before mutating UI if the command is aban…
murrayju Jun 23, 2026
e056ba6
docs: track review cycle progress (round 8)
murrayju Jun 23, 2026
d6927a9
docs: track review cycle progress (round 9 — converged)
murrayju Jun 23, 2026
4a361d0
fix(web): cap chart screenshot long edge under 2000px
murrayju Jun 23, 2026
9207859
fix(serve): persist chartConfigHistory in /api/state
murrayju Jun 23, 2026
0debbcc
fix(web): abort awaitExecutor when the visualize command is canceled
murrayju Jun 23, 2026
fd4cd82
docs(web): correct stale preemptedCommandId race comment
murrayju Jun 23, 2026
584db73
docs: track review cycle progress (round 10 — opus)
murrayju Jun 23, 2026
bb97feb
improved tool descriptions, test coverage
murrayju Jun 24, 2026
9dee074
don't return unrequested chart image
murrayju Jun 24, 2026
52d4554
remove review md file
murrayju Jun 24, 2026
f37f6e8
docs(web): correct stale 'now' memoization comment in ChartHistoryModal
murrayju Jun 25, 2026
4a4489a
fix(mcp): reject invalid visualize value with explicit error
murrayju Jun 25, 2026
95389e0
docs(web): clarify cancellation settles (not rejects) agent runs
murrayju Jun 25, 2026
e1e85f7
analytics: record chart_config in MCP analytics events
murrayju Jun 29, 2026
a3ae042
docs: move MCP web-bridge detail out of Repository Structure
murrayju Jun 29, 2026
0adcb28
refactor(mcp): make browser command type a named enum
murrayju Jun 29, 2026
129a3f6
refactor(mcp): consolidate duplicate ChartDiagnostic type
murrayju Jun 29, 2026
05b1a23
refactor(mcp): drop redundant newline in visualize summary
murrayju Jun 29, 2026
85b96bd
feat(mcp): reference ECharts docs in chart_config tool descriptions
murrayju Jun 29, 2026
cf12ecb
refactor(mcp): hoist browser auth pre-check into ensureStarted
murrayju Jun 29, 2026
8635607
docs(mcp): explain the chartCommand(input) cast
murrayju Jun 29, 2026
d44673f
feat(mcp): return structured output from ghost_chart
murrayju Jun 29, 2026
80b3bd4
feat(mcp): split visualization out of ghost_sql into ghost_visualize
murrayju Jun 29, 2026
860b57b
fix(serve): show chart config type diagnostics in the live editor
murrayju Jun 29, 2026
f0c9b7a
refactor(mcp): drop command tag from browser-backed query results
murrayju Jun 29, 2026
200b004
loosen rows type to avoid noisy diagnostics in chart_config
murrayju Jun 29, 2026
8aa1d91
docs(mcp): clarify when to use ghost_visualize vs ghost_sql
murrayju Jun 29, 2026
08841f6
refactor(mcp): rely on SDK-applied schema defaults for limit/view
murrayju Jun 29, 2026
1d48ff1
refactor(mcp): drop the view parameter from ghost_visualize
murrayju Jun 29, 2026
dbfbc22
wording tweaks
murrayju Jun 29, 2026
b3e2258
refactor(mcp): only render a chart when chart_config is provided
murrayju Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,37 @@
- **`internal/api/`** - API client layer. Includes an OpenAPI-generated REST client (`client.go`, `types.go`), shared HTTP client singleton, and request/response types. **Do not edit `client.go` or `types.go` by hand** — they are generated from `openapi.yaml` (see [Code Generation](#code-generation)). The `mock/` subdirectory contains a generated mock of `ClientWithResponsesInterface` for use in tests.
- **`internal/config/`** - Configuration management. Handles config file loading (via Viper), credential storage (keyring with file fallback), and version checking.
- **`internal/common/`** - Shared business logic used across commands and MCP tools. Includes API client initialization, database connection/schema/query utilities, error handling with exit codes, and version update checks.
- **`internal/mcp/`** - Model Context Protocol (MCP) server. Exposes Ghost database operations as MCP tools for AI/LLM integration, plus a documentation search proxy. Each MCP tool lives in its own file, named to match the tool (e.g. `ghost_usage` → `usage.go`). Helper files like `util.go`, `errors.go`, and `proxy.go` contain shared utilities.
- **`internal/serve/`** - Local web UI for `ghost serve`. Embeds a Vite/React SPA (from `web/dist`) via `//go:embed` (`assets.go`) and runs a small in-process HTTP server (`server.go`). Serves the support endpoints the UI needs — `/api/bootstrap` (config dump), `/api/databases` (read-only database list), `/api/state` (GET/PUT of persisted UI state), `/health`, and the SPA asset handler — plus the query endpoints the SQL query client calls: `/api/createSession`, `/api/sessionEvents`, `/api/closeSession`, `/api/executeQuery`, `/api/executeSessionQuery`, `/api/arrowResults`, `/api/cancelQuery`. Queries run in-process: each request resolves the target database's connection (via the API client + stored password) into a DSN, opens a Postgres session (`session.go`, `driver/`), executes the query, and streams results as newline-delimited JSON status plus an Apache Arrow IPC stream (`writer/`). Sessions and in-progress runs are tracked in an in-memory `Store` (`store.go`). Request/response types live in `api/`.
- **`internal/mcp/`** - Model Context Protocol (MCP) server. Exposes Ghost database operations as MCP tools for AI/LLM integration, plus a documentation search proxy. Each MCP tool lives in its own file, named to match the tool (e.g. `ghost_usage` → `usage.go`). Helper files like `util.go`, `errors.go`, and `proxy.go` contain shared utilities. In local (stdio) mode the server can also drive a live web UI for visualization via the browser controller (`browser.go`) and the `ghost_visualize` (`visualize.go`) and `ghost_ui_state` (`ui_state.go`) tools (see [Web UI & MCP Agent Bridge](#web-ui--mcp-agent-bridge)).
- **`internal/serve/`** - Local web UI for `ghost serve` (and the MCP server's in-process visualization UI). Embeds a Vite/React SPA (from `web/dist`) via `//go:embed` (`assets.go`) and runs a small in-process HTTP server (`server.go`). Serves the support endpoints the UI needs — `/api/bootstrap` (config dump), `/api/databases` (read-only database list), `/api/state` (GET/PUT of persisted UI state), `/health`, and the SPA asset handler — plus the query endpoints the SQL query client calls: `/api/createSession`, `/api/sessionEvents`, `/api/closeSession`, `/api/executeQuery`, `/api/executeSessionQuery`, `/api/arrowResults`, `/api/cancelQuery`. Queries run in-process: each request resolves the target database's connection (via the API client + stored password) into a DSN, opens a Postgres session (`session.go`, `driver/`), executes the query, and streams results as newline-delimited JSON status plus an Apache Arrow IPC stream (`writer/`). The DSN honors the `read_only` config option (the immutable read-only connection GUC), so queries run through this server — including MCP-driven visualizations — can't bypass read-only mode; `/api/bootstrap` reports `readOnly` so the UI can surface a read-only indicator. Sessions and in-progress runs are tracked in an in-memory `Store` (`store.go`). Request/response types live in `api/`. The agent channel that lets MCP tools drive the UI (`agent.go`, `agent_handler.go`) is described in [Web UI & MCP Agent Bridge](#web-ui--mcp-agent-bridge).
- **`internal/analytics/`** - Analytics event tracking with sensitive data redaction for flags, positional arguments, and MCP inputs.
- **`internal/log/`** - Small `slog` helper package for the long-running backend commands (`ghost serve`, `ghost mcp`): `log.New` builds the stderr logger, and `NewContext`/`FromContext` thread a request-scoped logger through the context. `ErrLevel` picks debug vs error level based on context cancellation.
- **`internal/util/`** - General utilities: type conversion, duration formatting, path helpers, context-aware stdin reading, JSON/YAML serialization, and terminal detection.
- **`docs/`** - Documentation. `docs/cli/` contains generated Markdown CLI reference docs (produced by `cmd/generate-docs`).
- **`web/`** - Vite + React workspace for the `ghost serve` browser UI. Built via `scripts/build-web.sh` (which uses the self-bootstrapping `web/bun` wrapper) into `web/dist/`, then synced into `internal/serve/web/` for the Go binary's `//go:embed` directive. Uses React 18 (the widget calls `findDOMNode` which was removed in React 19), Tailwind v3 (matches the widget's pinned version), TanStack Query for `/api/databases`/`/api/bootstrap` polling, and `vite-plugin-node-polyfills` for the widget's Buffer/crypto/process/stream shims (same list web-cloud uses). The widget's worker + wasm sidecars are emitted into `assets/` via a custom Vite plugin (ported from web-cloud) because Vite's static analysis misses the `new URL(<variable>, import.meta.url)` references inside the widget's worker chunk.
- **`web/`** - Vite + React workspace for the `ghost serve` browser UI. Built via `scripts/build-web.sh` (which uses the self-bootstrapping `web/bun` wrapper) into `web/dist/`, then synced into `internal/serve/web/` for the Go binary's `//go:embed` directive. Uses React 18 (the widget calls `findDOMNode` which was removed in React 19), Tailwind v3 (matches the widget's pinned version), TanStack Query for `/api/databases`/`/api/bootstrap` polling, and `vite-plugin-node-polyfills` for the widget's Buffer/crypto/process/stream shims (same list web-cloud uses). The widget's worker + wasm sidecars are emitted into `assets/` via a custom Vite plugin (ported from web-cloud) because Vite's static analysis misses the `new URL(<variable>, import.meta.url)` references inside the widget's worker chunk. The `src/agent/` MCP integration is described in [Web UI & MCP Agent Bridge](#web-ui--mcp-agent-bridge).
- **`scripts/`** - Build and installation scripts (install.sh, install.ps1, completions generation).
- **`openapi.yaml`** - OpenAPI spec used to generate the API client. Should be kept in sync with the canonical spec in the `ghost-api` repo (see [Code Generation](#code-generation)).
- **`.github/`** - GitHub Actions CI/CD workflows for testing and releases.

## Web UI & MCP Agent Bridge

In local (stdio) mode, the MCP server can drive the live `ghost serve` web UI so an agent's queries and charts appear in a browser a human can watch. This spans three packages — `internal/mcp/` (server side), `internal/serve/` (the bridge + HTTP endpoints), and `web/src/agent/` (the browser orchestrator).

**Lazy startup (`internal/mcp/browser.go`).** The browser controller lazily starts an in-process `ghost serve` server (loopback, ephemeral port) and opens a browser on first use; it's torn down on MCP shutdown. The browser-facing tools are gated to local mode (`NewServerWithOptions` with `Options{Local: true}`), since opening a browser only makes sense locally. The Go command/response shapes live in `browser_types.go`, kept in sync with `web/src/agent/types.ts`.

**Tools.** Two browser-backed tools dispatch commands to the browser over the agent bridge: `ghost_visualize` (`visualize.go`) runs a SQL query and/or (re-)renders a chart in the live UI, returning the result rows and a chart image; `ghost_ui_state` (`ui_state.go`) reads the current UI state. They're registered only in local mode, so the always-available `ghost_sql` stays a plain server-side query tool (no visualization surface leaks into remote/HTTP mode).

**The agent bridge (`internal/serve/agent.go`, `agent_handler.go`).** When the serve handler is constructed with a `Bridge`, it serves the agent channel that lets MCP tools control the UI:

- `/api/agent/events` — an SSE stream the browser subscribes to, receiving active-tab status events and dispatched commands.
- `/api/agent/respond` — the browser posts heartbeats, results, and errors keyed by request ID.
- `/api/agent/activate` — a tab claims active control ("take over").

The bridge tracks all connected tabs with exactly one active: the first tab to connect is active, the incumbent stays active when new tabs connect (they come up inactive), and on disconnect the most-recently-connected tab is promoted. A single command is in flight at a time; a request fails if the active client disconnects, another tab takes over, or no message arrives within 60s.

**Browser orchestrator (`web/src/agent/`).** `useAgentBridge` subscribes to the `/api/agent/events` SSE stream at the app level (so it survives database switches), dispatching commands (`dispatch.ts`) that drive the live store — selecting a database, setting editor SQL, running the query via the widget's imperative `apiRef`, and rendering a chart screenshot off-screen with ECharts `getDataURL` (`screenshot.ts`). It also collects chart-config type/syntax diagnostics headlessly via Monaco's TS worker (`diagnostics.ts`, reusing the editor's `configureMonacoForCharts` types) so the agent gets the same red-squiggle feedback a human sees — these surface as `chart_diagnostics` even when the chart renders (many type errors don't throw at runtime). Because `QueryPanel` remounts per database, it registers an `Executor` (its `apiRef` + results-cache client) into a module-level registry (`executor.ts`) that the app-level dispatcher awaits. The active-tab status is shown by `AgentStatusBanner` with a "take over" button.

**Read-only enforcement.** Visualized queries run in-process through the serve handler, whose DSN honors the `read_only` config option (the immutable read-only connection GUC), so MCP-driven visualizations can't bypass read-only mode. `/api/bootstrap` reports `readOnly` so the UI can surface a read-only indicator.

## Build & Test

After editing Go code, run `./check` from the repo root. It runs `go install`, `go fmt`, `go mod tidy`, `go fix`, `go vet`, `staticcheck`, and `go test` in one shot.
Expand Down
4 changes: 2 additions & 2 deletions docs/cli/ghost_serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ ghost serve [flags]
-h, --help help for serve
--host string interface to bind (loopback by default) (default "127.0.0.1")
--log-level log level: debug, info, warn, or error (default INFO)
--no-open do not open the browser
--port int TCP port to listen on (0 = auto)
-n, --no-open do not open the browser
-p, --port int TCP port to listen on (0 = auto)
```

### Options inherited from parent commands
Expand Down
5 changes: 3 additions & 2 deletions internal/cmd/mcp_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ func buildMCPHTTPCmd(app *common.App) *cobra.Command {
// startStdioServer starts the MCP server with stdio transport
func startStdioServer(cmd *cobra.Command, app *common.App) error {
ctx := cmd.Context()
// Create MCP server
server, err := mcp.NewServer(ctx, app, log.New(cmd.ErrOrStderr()))
// Create MCP server. Local (stdio) mode enables the browser-backed
// visualization tools, since we can open a browser on the user's machine.
server, err := mcp.NewServerWithOptions(ctx, app, log.New(cmd.ErrOrStderr()), mcp.Options{Local: true})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ of this command — press Ctrl+C to stop it.`,
},
}

cmd.Flags().IntVar(&port, "port", 0, "TCP port to listen on (0 = auto)")
cmd.Flags().IntVarP(&port, "port", "p", 0, "TCP port to listen on (0 = auto)")
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "interface to bind (loopback by default)")
cmd.Flags().BoolVar(&noOpen, "no-open", false, "do not open the browser")
cmd.Flags().BoolVarP(&noOpen, "no-open", "n", false, "do not open the browser")
cmd.Flags().TextVar(&logLevel, "log-level", slog.LevelInfo, "log level: debug, info, warn, or error")

if err := cmd.RegisterFlagCompletionFunc("log-level", logLevelCompletion); err != nil {
Expand Down
197 changes: 197 additions & 0 deletions internal/mcp/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package mcp

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/timescale/ghost/internal/common"
"github.com/timescale/ghost/internal/serve"
)

// decodeImageDataURL parses a data URL (e.g. "data:image/png;base64,iVBOR...")
// into an MCP [mcp.ImageContent]. Returns nil if the string is empty or not a
// base64 data URL.
func decodeImageDataURL(dataURL string) (*mcp.ImageContent, error) {
if dataURL == "" {
return nil, nil
}
const prefix = "data:"
if !strings.HasPrefix(dataURL, prefix) {
return nil, errors.New("chart image is not a data URL")
}
comma := strings.IndexByte(dataURL, ',')
if comma < 0 {
return nil, errors.New("malformed image data URL")
}
meta := dataURL[len(prefix):comma]
mimeType, isBase64, _ := strings.Cut(meta, ";")
if isBase64 != "base64" {
return nil, errors.New("image data URL is not base64-encoded")
}
data, err := base64.StdEncoding.DecodeString(dataURL[comma+1:])
if err != nil {
return nil, fmt.Errorf("failed to decode image data: %w", err)
}
return &mcp.ImageContent{Data: data, MIMEType: mimeType}, nil
}

// browserController lazily starts an in-process `ghost serve` web server and
// drives it via the agent [serve.Bridge]. It is owned by the MCP [Server] and
// only used by the local (stdio) transport — opening a browser from a remote
// HTTP server is meaningless, so the visualize/chart/ui_state tools are gated
// to local mode.
//
// The server is started on first use (first visualize/chart/ui_state tool
// call), the browser is opened when no client is connected, and everything is
// torn down when the MCP server shuts down.
type browserController struct {
app *common.App
logger *slog.Logger

mu sync.Mutex
bridge *serve.Bridge
store *serve.Store
server *serve.Server
}

func newBrowserController(app *common.App, logger *slog.Logger) *browserController {
return &browserController{
app: app,
logger: ensureLogger(logger),
}
}

// ensureStarted lazily starts the in-process serve server (bound to an
// ephemeral loopback port) and returns the bridge. Subsequent calls return the
// already-running instance.
func (c *browserController) ensureStarted(ctx context.Context) (*serve.Bridge, error) {
// Verify the API client is available before starting the server or opening
// the browser. Without this, a logged-out user gets an opaque "no browser
// connected" timeout: the web app fails /api/bootstrap and never connects an
// active client, instead of the real auth/config error surfacing here. This
// runs on every call (before the already-started early-return) so expired
// credentials are caught too.
if _, _, err := c.app.GetClient(); err != nil {
return nil, err
}

c.mu.Lock()
defer c.mu.Unlock()

if c.bridge != nil {
return c.bridge, nil
}

bridge := serve.NewBridge()
configDir := c.app.GetConfig().ConfigDir
store := serve.NewStore(configDir, c.logger)
handler := serve.NewHandler(serve.HandlerConfig{
App: c.app,
Store: store,
Logger: c.logger,
Bridge: bridge,
})
server := serve.NewServer("127.0.0.1", 0, handler.Handler(), c.logger)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a reason we use 127.0.0.1 instead of just localhost here (and in internal/cmd/serve.go)? The MCP server itself uses localhost when using the HTTP transport, so we're being somewhat inconsistent. I'm not sure of the pros/cons of either (maybe localhost can use IPv6 addresses too, or something like that?), but I feel like I see localhost more often.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, there is a difference. localhost can mean IPv4 and/or IPv6. Claude tells me that go will only bind to one of the two addresses (order is platform dependent).

I think if you use localhost on both ends, it should work. But if we bind localhost and the user tries 127.0.0.1 in the browser, it might not. Whereas if we bind 127.0.0.1, both should work in the browser. The counterpoint is that then http://::1 doesn't work... but I don't think many people use that.

I agree with Claude that it is safer to just use 127.0.0.1 in both the bind and url that we display/open. But also, if you feel strongly about consistency, I don't think this is super important.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have a slight preference for localhost, but mostly just because I think it looks better in the URL bar lol. Your call.

if err := server.Start(ctx); err != nil {
store.Close()
return nil, fmt.Errorf("failed to start web server: %w", err)
}

c.bridge = bridge
c.store = store
c.server = server
c.logger.Info("Started in-process web UI for agent visualization", slog.String("url", server.URL()))
return bridge, nil
}

// url returns the URL of the running server, or "" if it hasn't been started.
func (c *browserController) url() string {
c.mu.Lock()
defer c.mu.Unlock()
if c.server == nil {
return ""
}
return c.server.URL()
}

// ensureClient starts the server (if needed), opens a browser when no client is
// connected, and waits for an active client to be ready to receive commands.
func (c *browserController) ensureClient(ctx context.Context) (*serve.Bridge, error) {
bridge, err := c.ensureStarted(ctx)
if err != nil {
return nil, err
}

if !bridge.HasActiveClient() {
url := c.url()
c.logger.Info("Opening browser for agent visualization", slog.String("url", url))
if err := common.OpenBrowser(url); err != nil {
c.logger.Warn("Failed to open browser; open it manually",
slog.String("url", url),
slog.Any("error", err),
)
}
if err := bridge.WaitForActiveClient(ctx); err != nil {
if errors.Is(err, serve.ErrNoActiveClient) {
return nil, fmt.Errorf("no browser connected to %s; open it to enable visualization", url)
}
return nil, err
}
}

return bridge, nil
}

// request dispatches a command to the active browser client and unmarshals the
// JSON response into out (which may be nil to ignore the response body).
func (c *browserController) request(ctx context.Context, commandType browserCommand, payload any, out any) error {
bridge, err := c.ensureClient(ctx)
if err != nil {
return err
}

data, err := bridge.Request(ctx, string(commandType), payload)
if err != nil {
return err
}
if out != nil && len(data) > 0 {
// Decode numbers as json.Number, not float64, so cell values keep their
// exact literal text. Plain float64 decoding would re-render large or
// whole numbers in exponent form (e.g. 10000000 -> "1e+07") when
// stringified, diverging from the server-side query path's text output.
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(out); err != nil {
return fmt.Errorf("failed to parse browser response: %w", err)
}
}
return nil
}

// Close tears down the running server and store. Safe to call when nothing was
// started.
func (c *browserController) Close() error {
c.mu.Lock()
defer c.mu.Unlock()

if c.server == nil {
return nil
}
err := c.server.Close()
// Store.Close() returns no error — it logs any session-teardown failures
// internally.
c.store.Close()
c.server = nil
c.store = nil
c.bridge = nil
return err
}
Loading