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>
271 lines
7.7 KiB
Go
271 lines
7.7 KiB
Go
// Package hub is the live half of grok-glance: connected grok instances,
|
|
// connected browsers, and the routing between them.
|
|
//
|
|
// Nothing here is persisted. A restart drops every connection; the bridges
|
|
// reconnect on their own and browsers reconnect on their own, so the cost of a
|
|
// restart is the transcript history and nothing else.
|
|
//
|
|
// # Two protocols, deliberately
|
|
//
|
|
// grok speaks ACP over `/api/acp/agent`. Browsers speak a small glance envelope
|
|
// over `/api/ws`. Making the browser a real ACP peer was possible and rejected:
|
|
// it would push JSON-RPC correlation, request ids and the agent/client role
|
|
// inversion into the frontend, in exchange for nothing the UI actually needs.
|
|
// So the hub translates, and the frontend sees events with a `type`.
|
|
package hub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/user/grok-glance/internal/acp"
|
|
)
|
|
|
|
// EventType tags a message from glance to a browser.
|
|
type EventType string
|
|
|
|
const (
|
|
// EventSnapshot is the first message on a browser socket: everything needed
|
|
// to render without further round trips.
|
|
EventSnapshot EventType = "snapshot"
|
|
// EventAgents means the set of connected agents (or their metadata) changed.
|
|
EventAgents EventType = "agents"
|
|
// EventFrame is one mirrored ACP notification, passed through verbatim.
|
|
EventFrame EventType = "frame"
|
|
// EventInteraction is a request awaiting a human answer.
|
|
EventInteraction EventType = "interaction"
|
|
// EventInteractionResolved means it was answered -- possibly elsewhere.
|
|
EventInteractionResolved EventType = "interaction_resolved"
|
|
// EventNotice is a human-readable line for the UI to surface.
|
|
EventNotice EventType = "notice"
|
|
// EventError reports that a browser's own action failed.
|
|
EventError EventType = "error"
|
|
)
|
|
|
|
// Event is what a browser receives.
|
|
type Event struct {
|
|
Type EventType `json:"type"`
|
|
Agent string `json:"agent,omitempty"`
|
|
|
|
// Snapshot payload.
|
|
Agents []AgentSummary `json:"agents,omitempty"`
|
|
Frames []json.RawMessage `json:"frames,omitempty"`
|
|
Dropped int `json:"dropped,omitempty"`
|
|
Open []*Interaction `json:"open,omitempty"`
|
|
Session *acp.SessionMeta `json:"session,omitempty"`
|
|
TurnActive bool `json:"turnActive,omitempty"`
|
|
|
|
// Streaming payload.
|
|
Frame json.RawMessage `json:"frame,omitempty"`
|
|
Interaction *Interaction `json:"interaction,omitempty"`
|
|
|
|
// Resolution payload.
|
|
ID string `json:"id,omitempty"`
|
|
ToolCallID string `json:"toolCallId,omitempty"`
|
|
// By is "browser", "terminal", or "declined" -- what the UI shows when a
|
|
// dialog closes without this user having answered it.
|
|
By string `json:"by,omitempty"`
|
|
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// Hub owns every live connection.
|
|
type Hub struct {
|
|
log *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
agents map[string]*Agent
|
|
browsers map[*Browser]struct{}
|
|
}
|
|
|
|
// New builds an empty hub.
|
|
func New(log *slog.Logger) *Hub {
|
|
return &Hub{
|
|
log: log,
|
|
agents: make(map[string]*Agent),
|
|
browsers: make(map[*Browser]struct{}),
|
|
}
|
|
}
|
|
|
|
// ServeAgent runs a grok connection to completion.
|
|
//
|
|
// agentID identifies the connection for the lifetime of the socket; keyName is
|
|
// the API key's label, shown in the UI so several machines can be told apart.
|
|
func (h *Hub) ServeAgent(ctx context.Context, conn *websocket.Conn, agentID, keyName string) {
|
|
now := time.Now()
|
|
agent := &Agent{
|
|
ID: agentID,
|
|
KeyName: keyName,
|
|
hub: h,
|
|
conn: conn,
|
|
log: h.log.With("agent", agentID),
|
|
outbound: make(chan acp.Frame, agentSendQueue),
|
|
done: make(chan struct{}),
|
|
connectedAt: now,
|
|
lastActivity: now,
|
|
ring: newRing(defaultRingCapacity),
|
|
interactions: make(map[string]*Interaction),
|
|
calls: make(map[uint64]chan acp.Frame),
|
|
}
|
|
|
|
h.mu.Lock()
|
|
// A reconnect from the same key replaces the old connection rather than
|
|
// accumulating a ghost: the bridge reconnects after every network blip, and
|
|
// a stale entry would show as a second session that never updates.
|
|
if old, ok := h.agents[agentID]; ok {
|
|
go old.close()
|
|
}
|
|
h.agents[agentID] = agent
|
|
h.mu.Unlock()
|
|
|
|
h.log.Info("agent connected", "agent", agentID, "key", keyName)
|
|
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
|
|
|
// Ask who this is. The bridge also volunteers it in `x.ai/rc/status` on
|
|
// connect, but asking means the UI is correct even if that frame is missed.
|
|
go h.initialize(ctx, agent)
|
|
|
|
agent.serve(ctx)
|
|
|
|
agent.close()
|
|
h.mu.Lock()
|
|
if h.agents[agentID] == agent {
|
|
delete(h.agents, agentID)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
h.log.Info("agent gone", "agent", agentID)
|
|
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
|
}
|
|
|
|
func (h *Hub) initialize(ctx context.Context, agent *Agent) {
|
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
|
defer cancel()
|
|
|
|
frame, err := agent.call(ctx, acp.MethodInitialize, map[string]any{
|
|
"protocolVersion": 1,
|
|
"clientCapabilities": map[string]any{
|
|
// glance is a viewer and a controller, not a workspace: it does not
|
|
// offer grok a filesystem or a terminal, and says so up front.
|
|
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
|
|
"terminal": false,
|
|
},
|
|
})
|
|
if err != nil {
|
|
agent.log.Info("initialize failed", "err", err)
|
|
return
|
|
}
|
|
if frame.Error != nil {
|
|
agent.log.Warn("agent refused initialize", "err", frame.Error)
|
|
return
|
|
}
|
|
|
|
var result acp.InitializeResult
|
|
if err := json.Unmarshal(frame.Result, &result); err != nil {
|
|
agent.log.Warn("could not read initialize result", "err", err)
|
|
return
|
|
}
|
|
if result.Meta != nil {
|
|
agent.mu.Lock()
|
|
agent.meta = result.Meta.Session
|
|
agent.mu.Unlock()
|
|
}
|
|
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
|
h.syncViewers(agent)
|
|
}
|
|
|
|
// Agent looks up a connected instance.
|
|
func (h *Hub) Agent(id string) (*Agent, bool) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
agent, ok := h.agents[id]
|
|
return agent, ok
|
|
}
|
|
|
|
// Summaries lists connected agents, for the sessions page.
|
|
func (h *Hub) Summaries() []AgentSummary {
|
|
h.mu.RLock()
|
|
agents := make([]*Agent, 0, len(h.agents))
|
|
for _, a := range h.agents {
|
|
agents = append(agents, a)
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
out := make([]AgentSummary, 0, len(agents))
|
|
for _, a := range agents {
|
|
out = append(out, a.Summary())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// broadcast fans an event out to every browser.
|
|
//
|
|
// Delivery is best-effort per browser: a viewer that cannot keep up is
|
|
// disconnected and reconnects into a fresh snapshot, which is both simpler and
|
|
// more correct than letting it fall arbitrarily far behind.
|
|
func (h *Hub) broadcast(ev Event) {
|
|
raw, err := json.Marshal(ev)
|
|
if err != nil {
|
|
h.log.Warn("could not encode event", "type", ev.Type, "err", err)
|
|
return
|
|
}
|
|
|
|
h.mu.RLock()
|
|
browsers := make([]*Browser, 0, len(h.browsers))
|
|
for b := range h.browsers {
|
|
browsers = append(browsers, b)
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, b := range browsers {
|
|
b.deliver(raw)
|
|
}
|
|
}
|
|
|
|
// Notice pushes a human-readable line to every browser.
|
|
func (h *Hub) Notice(agentID, message string) {
|
|
h.broadcast(Event{Type: EventNotice, Agent: agentID, Message: message})
|
|
}
|
|
|
|
func (h *Hub) addBrowser(b *Browser) {
|
|
h.mu.Lock()
|
|
h.browsers[b] = struct{}{}
|
|
h.mu.Unlock()
|
|
h.syncAllViewers()
|
|
}
|
|
|
|
func (h *Hub) removeBrowser(b *Browser) {
|
|
h.mu.Lock()
|
|
delete(h.browsers, b)
|
|
h.mu.Unlock()
|
|
h.syncAllViewers()
|
|
}
|
|
|
|
// syncAllViewers tells every bridge how many browsers are attached, so `/rc
|
|
// status` in the terminal reflects reality.
|
|
func (h *Hub) syncAllViewers() {
|
|
h.mu.RLock()
|
|
count := len(h.browsers)
|
|
agents := make([]*Agent, 0, len(h.agents))
|
|
for _, a := range h.agents {
|
|
agents = append(agents, a)
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, a := range agents {
|
|
a.NotifyViewers(count)
|
|
}
|
|
}
|
|
|
|
func (h *Hub) syncViewers(agent *Agent) {
|
|
h.mu.RLock()
|
|
count := len(h.browsers)
|
|
h.mu.RUnlock()
|
|
agent.NotifyViewers(count)
|
|
}
|