Files
new-grok-glance/internal/hub/browser.go
T
iceBear67andClaude Opus 5 051efe8fec grok-glance: web control plane for grok's /rc remote control
A single Go binary that grok dials out to over a WebSocket, and a HeroUI
web UI for driving the session it is attached to.

The roles are inverted relative to the terminal: over the /rc link grok
is the ACP Agent and glance is the Client. That makes glance a stock ACP
client and the web Stop button a real session/cancel rather than a
bespoke control message.

Both notification rails are mirrored. The stable session/update rail
carries correctness; x.ai/session_notification is presentation only and
degrades rather than erroring, because its ~60 variants are grok
internal and drift with every upstream sync. _meta is forwarded
byte for byte so viewers can dedup and order.

Permissions race: the terminal and any browser may answer, first
responder wins, and the loser's UI retracts by itself. All three
interaction methods go through that path, not just permissions.

Auth is TOTP only, with no accounts to have. A bootstrap token printed
at first start gates /setup, which is a 404 without it; state lives in
one 0600 JSON file and history in an in-memory ring, so there is no
database and no recovery story beyond deleting the file.

ARCHITECTURE.md covers the topology and the limits of that auth model;
CLAUDE.md covers building, the fakeagent loop, and the end-to-end
checklist that unit tests cannot replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:07:33 +00:00

286 lines
6.9 KiB
Go

package hub
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
// browserSendQueue bounds how far behind a viewer may fall.
//
// A turn can emit thousands of frames faster than a slow link drains them. Once
// this fills, the viewer is dropped and reconnects into a fresh snapshot --
// which is strictly better than an unbounded queue that turns one slow browser
// into the server's memory problem.
const browserSendQueue = 256
// Browser is one connected web client.
type Browser struct {
hub *Hub
log *slog.Logger
conn *websocket.Conn
outbound chan []byte
done chan struct{}
closeOne sync.Once
}
// command is what a browser sends.
type command struct {
Type string `json:"type"`
Agent string `json:"agent,omitempty"`
// Prompt.
Text string `json:"text,omitempty"`
// Interaction answer.
ID string `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ServeBrowser runs a browser connection to completion.
func (h *Hub) ServeBrowser(ctx context.Context, conn *websocket.Conn) {
b := &Browser{
hub: h,
log: h.log,
conn: conn,
outbound: make(chan []byte, browserSendQueue),
done: make(chan struct{}),
}
h.addBrowser(b)
defer func() {
h.removeBrowser(b)
b.close()
}()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go b.writeLoop(ctx)
b.send(Event{Type: EventAgents, Agents: h.Summaries()})
for {
typ, data, err := conn.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
var cmd command
if err := json.Unmarshal(data, &cmd); err != nil {
b.send(Event{Type: EventError, Message: "could not parse command"})
continue
}
b.handle(ctx, cmd)
}
}
func (b *Browser) handle(ctx context.Context, cmd command) {
switch cmd.Type {
case "list":
b.send(Event{Type: EventAgents, Agents: b.hub.Summaries()})
case "subscribe":
b.subscribe(cmd.Agent)
case "prompt":
b.prompt(ctx, cmd)
case "cancel":
b.cancel(ctx, cmd)
case "answer":
b.answer(cmd)
case "decline":
b.decline(cmd)
default:
b.send(Event{Type: EventError, Message: "unknown command: " + cmd.Type})
}
}
// subscribe sends the full current state of one agent.
//
// Every browser receives every agent's frames regardless -- fan-out is cheap at
// this scale and per-browser filtering would be one more thing to get wrong.
// Subscribing is how a browser gets *history*: the ring, the open interactions
// and the session metadata, in one message, so a reload or a mid-session open
// renders immediately instead of waiting for the next frame.
func (b *Browser) subscribe(agentID string) {
agent, ok := b.hub.Agent(agentID)
if !ok {
b.send(Event{Type: EventError, Agent: agentID, Message: "no such agent connected"})
return
}
frames, dropped := agent.Transcript()
summary := agent.Summary()
session := summary.Session
b.send(Event{
Type: EventSnapshot,
Agent: agentID,
Agents: b.hub.Summaries(),
Frames: frames,
Dropped: dropped,
Open: agent.OpenInteractions(),
Session: &session,
TurnActive: summary.TurnActive,
})
}
func (b *Browser) prompt(ctx context.Context, cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
// Whitespace counts as empty: the UI trims before sending, but the server is
// the boundary, and a stray Enter in a box holding a space should not start a
// turn. The text itself goes on unmodified -- refusing an accident is the
// server's business, editing someone's prompt is not.
if strings.TrimSpace(cmd.Text) == "" {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "prompt is empty"})
return
}
// A prompt does not return until the turn ends, which can be many minutes.
// Waiting here would stall this browser's whole command stream -- including
// the Stop button it might need next -- so the turn runs detached and its
// progress arrives as mirrored frames like any other.
go func() {
if _, err := agent.Prompt(context.WithoutCancel(ctx), cmd.Text); err != nil {
if errors.Is(err, ErrAgentGone) {
b.hub.Notice(cmd.Agent, "the session disconnected before the turn finished")
return
}
b.hub.Notice(cmd.Agent, "prompt failed: "+err.Error())
}
}()
}
func (b *Browser) cancel(ctx context.Context, cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
go func() {
if err := agent.Cancel(context.WithoutCancel(ctx)); err != nil {
b.hub.Notice(cmd.Agent, "interrupt failed: "+err.Error())
return
}
b.hub.Notice(cmd.Agent, "turn interrupted from the web UI")
}()
}
func (b *Browser) answer(cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
if len(cmd.Result) == 0 {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "answer has no result"})
return
}
if !agent.Answer(cmd.ID, cmd.Result) {
// Losing the race is the expected outcome half the time, not an error:
// the terminal answered first, or another browser did.
b.send(Event{
Type: EventInteractionResolved,
Agent: cmd.Agent,
ID: cmd.ID,
By: "elsewhere",
Message: "already handled elsewhere",
})
}
}
func (b *Browser) decline(cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
reason := cmd.Reason
if reason == "" {
reason = "declined in the web UI; answer in the terminal"
}
if !agent.Decline(cmd.ID, reason) {
b.send(Event{
Type: EventInteractionResolved,
Agent: cmd.Agent,
ID: cmd.ID,
By: "elsewhere",
Message: "already handled elsewhere",
})
}
}
func (b *Browser) send(ev Event) {
raw, err := json.Marshal(ev)
if err != nil {
b.log.Warn("could not encode event for browser", "err", err)
return
}
b.deliver(raw)
}
func (b *Browser) deliver(raw []byte) {
select {
case b.outbound <- raw:
case <-b.done:
default:
b.log.Info("browser too slow; dropping it to reconnect")
b.close()
}
}
func (b *Browser) writeLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-b.done:
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := b.conn.Ping(pingCtx)
cancel()
if err != nil {
b.close()
return
}
case raw := <-b.outbound:
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
err := b.conn.Write(writeCtx, websocket.MessageText, raw)
cancel()
if err != nil {
b.close()
return
}
}
}
}
func (b *Browser) close() {
b.closeOne.Do(func() {
close(b.done)
b.conn.CloseNow()
})
}