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>
586 lines
16 KiB
Go
586 lines
16 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/user/grok-glance/internal/acp"
|
|
)
|
|
|
|
const (
|
|
// One turn of a busy session produces thousands of streaming deltas. This
|
|
// holds a few minutes of that -- enough that a browser opened mid-turn sees
|
|
// the turn, not so much that an idle server holds megabytes per agent.
|
|
defaultRingCapacity = 4096
|
|
|
|
// Depth of the per-agent outbound queue. Large enough to absorb a burst of
|
|
// browser input; a full queue means the socket is wedged, and dropping the
|
|
// connection is better than blocking a request handler on it.
|
|
agentSendQueue = 64
|
|
|
|
// How long a call to grok waits before giving up. `session/prompt` is the
|
|
// exception: it blocks for the whole turn and gets no deadline of its own.
|
|
callTimeout = 30 * time.Second
|
|
)
|
|
|
|
// ErrAgentGone means the grok instance disconnected before answering.
|
|
var ErrAgentGone = errors.New("agent disconnected")
|
|
|
|
// Interaction is a reverse-request from grok that a human must answer:
|
|
// a tool permission, a question, or a plan approval.
|
|
//
|
|
// Both the terminal and any browser can answer. Whoever answers first wins and
|
|
// the other side's dialog is retracted -- from glance's perspective that means
|
|
// either a browser answers (and glance replies to grok), or grok sends
|
|
// `x.ai/rc/interaction_cancelled` because the terminal got there first.
|
|
type Interaction struct {
|
|
// ID is grok's JSON-RPC id, echoed back in the response.
|
|
ID json.RawMessage `json:"id"`
|
|
// Method is the ACP method, so the UI knows which dialog to render.
|
|
Method string `json:"method"`
|
|
// Params is passed through untouched: the browser renders from it, and
|
|
// glance has no reason to understand its every field.
|
|
Params json.RawMessage `json:"params"`
|
|
// ToolCallID keys the retraction on both sides.
|
|
ToolCallID string `json:"toolCallId,omitempty"`
|
|
OpenedAt time.Time `json:"openedAt"`
|
|
}
|
|
|
|
// Agent is one connected grok instance and the single session it mirrors.
|
|
type Agent struct {
|
|
ID string
|
|
KeyName string
|
|
|
|
hub *Hub
|
|
conn *websocket.Conn
|
|
log *slog.Logger
|
|
|
|
// outbound is drained by the writer goroutine. coder/websocket permits one
|
|
// concurrent writer, and prompts, replies and heartbeats all originate on
|
|
// different goroutines.
|
|
outbound chan acp.Frame
|
|
|
|
// done closes when the connection is torn down, unblocking everything
|
|
// waiting on this agent.
|
|
done chan struct{}
|
|
closeOne sync.Once
|
|
|
|
nextID atomic.Uint64
|
|
|
|
mu sync.RWMutex
|
|
meta acp.SessionMeta
|
|
connectedAt time.Time
|
|
lastActivity time.Time
|
|
turnActive bool
|
|
ring *ring
|
|
interactions map[string]*Interaction
|
|
// calls correlates responses to requests glance sent. Each channel is
|
|
// buffered so a reply never blocks the reader goroutine, even if the caller
|
|
// timed out and stopped listening.
|
|
calls map[uint64]chan acp.Frame
|
|
}
|
|
|
|
// AgentSummary is the JSON view of an agent for the session list.
|
|
type AgentSummary struct {
|
|
ID string `json:"id"`
|
|
KeyName string `json:"keyName"`
|
|
Session acp.SessionMeta `json:"session"`
|
|
Label string `json:"label"`
|
|
ConnectedAt time.Time `json:"connectedAt"`
|
|
LastActivity time.Time `json:"lastActivity"`
|
|
TurnActive bool `json:"turnActive"`
|
|
Pending int `json:"pending"`
|
|
Frames int `json:"frames"`
|
|
Dropped int `json:"dropped"`
|
|
}
|
|
|
|
// Summary snapshots the agent for the UI.
|
|
func (a *Agent) Summary() AgentSummary {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return AgentSummary{
|
|
ID: a.ID,
|
|
KeyName: a.KeyName,
|
|
Session: a.meta,
|
|
Label: a.meta.Label(),
|
|
ConnectedAt: a.connectedAt,
|
|
LastActivity: a.lastActivity,
|
|
TurnActive: a.turnActive,
|
|
Pending: len(a.interactions),
|
|
Frames: a.ring.size,
|
|
Dropped: a.ring.dropped,
|
|
}
|
|
}
|
|
|
|
// Transcript returns the buffered frames, oldest first, plus how many were
|
|
// dropped off the front.
|
|
func (a *Agent) Transcript() ([]json.RawMessage, int) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return a.ring.snapshot(), a.ring.dropped
|
|
}
|
|
|
|
// OpenInteractions lists what is currently waiting on a human.
|
|
func (a *Agent) OpenInteractions() []*Interaction {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
out := make([]*Interaction, 0, len(a.interactions))
|
|
for _, in := range a.interactions {
|
|
out = append(out, in)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Prompt runs a turn. It returns when the turn ends, which can be minutes --
|
|
// callers that only want the turn *started* should not wait on it.
|
|
func (a *Agent) Prompt(ctx context.Context, text string) (json.RawMessage, error) {
|
|
a.mu.RLock()
|
|
sessionID := a.meta.SessionID
|
|
a.mu.RUnlock()
|
|
|
|
a.markTurn(true)
|
|
frame, err := a.call(ctx, acp.MethodSessionPrompt, acp.PromptParams{
|
|
SessionID: sessionID,
|
|
Text: text,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Error != nil {
|
|
return nil, frame.Error
|
|
}
|
|
return frame.Result, nil
|
|
}
|
|
|
|
// Cancel interrupts the running turn.
|
|
//
|
|
// grok classifies this the same way it classifies Esc, but attributes it to
|
|
// glance rather than to the terminal, so the session log records who stopped it.
|
|
func (a *Agent) Cancel(ctx context.Context) error {
|
|
a.mu.RLock()
|
|
sessionID := a.meta.SessionID
|
|
a.mu.RUnlock()
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
|
defer cancel()
|
|
frame, err := a.call(ctx, acp.MethodSessionCancel, acp.CancelParams{SessionID: sessionID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if frame.Error != nil {
|
|
return frame.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Answer resolves an open interaction with a result from the browser.
|
|
//
|
|
// It reports whether the interaction was still open: a false return is the
|
|
// normal outcome of losing the race to the terminal, not an error, and the UI
|
|
// shows "already handled elsewhere" rather than a failure.
|
|
func (a *Agent) Answer(id string, result json.RawMessage) bool {
|
|
a.mu.Lock()
|
|
interaction, ok := a.interactions[id]
|
|
if ok {
|
|
delete(a.interactions, id)
|
|
}
|
|
a.mu.Unlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
frame, err := acp.NewResponse(interaction.ID, json.RawMessage(result))
|
|
if err != nil {
|
|
a.log.Warn("could not encode interaction answer", "err", err)
|
|
return false
|
|
}
|
|
a.send(frame)
|
|
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "browser"))
|
|
return true
|
|
}
|
|
|
|
// Decline hands an interaction back to the terminal without answering it.
|
|
//
|
|
// grok treats a JSON-RPC error on an interaction as "glance is not answering
|
|
// this", leaves the terminal's dialog up, and the turn proceeds normally once
|
|
// the user answers there.
|
|
func (a *Agent) Decline(id, reason string) bool {
|
|
a.mu.Lock()
|
|
interaction, ok := a.interactions[id]
|
|
if ok {
|
|
delete(a.interactions, id)
|
|
}
|
|
a.mu.Unlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
a.send(acp.NewErrorResponse(interaction.ID, acp.CodeInternal, reason))
|
|
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "declined"))
|
|
return true
|
|
}
|
|
|
|
// NotifyViewers tells the bridge how many browsers are watching, so the
|
|
// terminal's `/rc status` can say so. Cosmetic and best-effort.
|
|
func (a *Agent) NotifyViewers(n int) {
|
|
frame, err := acp.NewNotification(acp.MethodRCViewers, acp.ViewersParams{Count: n})
|
|
if err != nil {
|
|
return
|
|
}
|
|
a.send(frame)
|
|
}
|
|
|
|
// call sends a request and waits for its response.
|
|
func (a *Agent) call(ctx context.Context, method string, params any) (acp.Frame, error) {
|
|
id := a.nextID.Add(1)
|
|
frame, err := acp.NewRequest(id, method, params)
|
|
if err != nil {
|
|
return acp.Frame{}, err
|
|
}
|
|
|
|
reply := make(chan acp.Frame, 1)
|
|
a.mu.Lock()
|
|
a.calls[id] = reply
|
|
a.mu.Unlock()
|
|
defer func() {
|
|
a.mu.Lock()
|
|
delete(a.calls, id)
|
|
a.mu.Unlock()
|
|
}()
|
|
|
|
if !a.trySend(frame) {
|
|
return acp.Frame{}, ErrAgentGone
|
|
}
|
|
|
|
select {
|
|
case f := <-reply:
|
|
return f, nil
|
|
case <-a.done:
|
|
return acp.Frame{}, ErrAgentGone
|
|
case <-ctx.Done():
|
|
return acp.Frame{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (a *Agent) send(frame acp.Frame) {
|
|
a.trySend(frame)
|
|
}
|
|
|
|
// trySend queues a frame, reporting whether it was accepted. A full queue means
|
|
// the socket is not draining; killing the connection turns a silent stall into a
|
|
// reconnect, which the bridge handles by design.
|
|
func (a *Agent) trySend(frame acp.Frame) bool {
|
|
select {
|
|
case a.outbound <- frame:
|
|
return true
|
|
case <-a.done:
|
|
return false
|
|
default:
|
|
a.log.Warn("agent send queue full; dropping connection", "agent", a.ID)
|
|
a.close()
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (a *Agent) close() {
|
|
a.closeOne.Do(func() {
|
|
close(a.done)
|
|
a.conn.CloseNow()
|
|
})
|
|
}
|
|
|
|
func (a *Agent) markTurn(active bool) {
|
|
a.mu.Lock()
|
|
a.turnActive = active
|
|
a.lastActivity = time.Now()
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
// serve runs the agent's read and write loops until the socket dies.
|
|
func (a *Agent) serve(ctx context.Context) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
go a.writeLoop(ctx)
|
|
|
|
for {
|
|
typ, data, err := a.conn.Read(ctx)
|
|
if err != nil {
|
|
a.log.Info("agent disconnected", "agent", a.ID, "err", err)
|
|
return
|
|
}
|
|
if typ != websocket.MessageText {
|
|
continue
|
|
}
|
|
a.handleFrame(data)
|
|
}
|
|
}
|
|
|
|
func (a *Agent) writeLoop(ctx context.Context) {
|
|
// A periodic ping is what turns a silently dead TCP connection (laptop
|
|
// asleep, NAT entry evicted) into a normal disconnect the bridge reconnects
|
|
// from, instead of an agent that shows as connected forever.
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-a.done:
|
|
return
|
|
case <-ticker.C:
|
|
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := a.conn.Ping(pingCtx)
|
|
cancel()
|
|
if err != nil {
|
|
a.close()
|
|
return
|
|
}
|
|
case frame := <-a.outbound:
|
|
raw, err := json.Marshal(frame)
|
|
if err != nil {
|
|
a.log.Warn("could not encode frame for agent", "err", err)
|
|
continue
|
|
}
|
|
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
err = a.conn.Write(writeCtx, websocket.MessageText, raw)
|
|
cancel()
|
|
if err != nil {
|
|
a.log.Info("agent write failed", "agent", a.ID, "err", err)
|
|
a.close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleFrame dispatches one inbound frame from grok.
|
|
func (a *Agent) handleFrame(raw []byte) {
|
|
var frame acp.Frame
|
|
if err := json.Unmarshal(raw, &frame); err != nil {
|
|
a.log.Warn("unparseable frame from agent", "agent", a.ID, "err", err)
|
|
return
|
|
}
|
|
|
|
switch frame.Kind() {
|
|
case acp.KindResponse:
|
|
a.resolveCall(frame)
|
|
|
|
case acp.KindRequest:
|
|
if acp.IsInteraction(frame.Method) {
|
|
a.openInteraction(frame)
|
|
return
|
|
}
|
|
// grok drives nothing else on this link; saying so beats a fabricated
|
|
// success that would leave it waiting for behaviour glance does not have.
|
|
a.send(acp.NewErrorResponse(frame.ID, acp.CodeMethodNotFound,
|
|
fmt.Sprintf("Method not found: %s", frame.Method)))
|
|
|
|
case acp.KindNotification:
|
|
a.handleNotification(frame, raw)
|
|
|
|
default:
|
|
a.log.Warn("frame from agent is neither request, response nor notification", "agent", a.ID)
|
|
}
|
|
}
|
|
|
|
func (a *Agent) resolveCall(frame acp.Frame) {
|
|
var id uint64
|
|
if err := json.Unmarshal(frame.ID, &id); err != nil {
|
|
a.log.Warn("response from agent with unusable id", "agent", a.ID)
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
reply, ok := a.calls[id]
|
|
delete(a.calls, id)
|
|
a.mu.Unlock()
|
|
if !ok {
|
|
// The caller timed out, or this is a duplicate. Neither is worth more
|
|
// than a debug line.
|
|
a.log.Debug("response for an unknown call", "agent", a.ID, "id", id)
|
|
return
|
|
}
|
|
reply <- frame
|
|
}
|
|
|
|
func (a *Agent) openInteraction(frame acp.Frame) {
|
|
interaction := &Interaction{
|
|
ID: frame.ID,
|
|
Method: frame.Method,
|
|
Params: frame.Params,
|
|
ToolCallID: acp.ToolCallID(frame.Params),
|
|
OpenedAt: time.Now(),
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.interactions[string(frame.ID)] = interaction
|
|
a.lastActivity = interaction.OpenedAt
|
|
a.mu.Unlock()
|
|
|
|
a.hub.broadcast(Event{
|
|
Type: EventInteraction,
|
|
Agent: a.ID,
|
|
Interaction: interaction,
|
|
})
|
|
}
|
|
|
|
func (a *Agent) handleNotification(frame acp.Frame, raw []byte) {
|
|
switch {
|
|
case frame.Method == acp.MethodRCStatus:
|
|
var params acp.StatusParams
|
|
if err := json.Unmarshal(frame.Params, ¶ms); err == nil {
|
|
a.mu.Lock()
|
|
a.meta = params.Session
|
|
a.mu.Unlock()
|
|
a.hub.broadcast(Event{Type: EventAgents, Agents: a.hub.Summaries()})
|
|
}
|
|
|
|
case frame.Method == acp.MethodRCInteractionCancelled:
|
|
// The terminal answered first. Close the browser's dialog with the same
|
|
// wording it would get if another browser had answered.
|
|
var params acp.InteractionCancelledParams
|
|
if err := json.Unmarshal(frame.Params, ¶ms); err != nil {
|
|
return
|
|
}
|
|
a.retract(fmt.Sprintf("%d", params.ID), params.ToolCallID, "terminal")
|
|
|
|
case acp.IsTranscript(frame.Method):
|
|
a.recordTranscript(frame, raw)
|
|
|
|
default:
|
|
a.log.Debug("unhandled notification from agent", "agent", a.ID, "method", frame.Method)
|
|
}
|
|
}
|
|
|
|
// recordTranscript rings the frame and fans it out.
|
|
//
|
|
// The frame is stored and forwarded exactly as it arrived: `_meta` carries
|
|
// `eventId`, `promptId`, `chunkId` and `isReplay`, which is what lets a viewer
|
|
// dedup and order the stream the same way the terminal does. Rewriting it here
|
|
// would quietly break that.
|
|
func (a *Agent) recordTranscript(frame acp.Frame, raw []byte) {
|
|
kind, toolCallID := classifyUpdate(frame.Params)
|
|
|
|
a.mu.Lock()
|
|
a.ring.push(json.RawMessage(raw))
|
|
a.lastActivity = time.Now()
|
|
switch kind {
|
|
case updateTurnEnd:
|
|
a.turnActive = false
|
|
case updateInTurn:
|
|
a.turnActive = true
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
// `interaction_resolved` is grok's own signal that a reverse-request was
|
|
// answered somewhere. It reaches glance for interactions the bridge never
|
|
// raced, so it is handled alongside `x.ai/rc/interaction_cancelled` rather
|
|
// than instead of it.
|
|
if kind == updateInteractionResolved && toolCallID != "" {
|
|
a.retractByToolCall(toolCallID, "terminal")
|
|
}
|
|
|
|
a.hub.broadcast(Event{Type: EventFrame, Agent: a.ID, Frame: json.RawMessage(raw)})
|
|
}
|
|
|
|
// retract closes an interaction that was answered elsewhere.
|
|
func (a *Agent) retract(id, toolCallID, by string) {
|
|
a.mu.Lock()
|
|
interaction, ok := a.interactions[id]
|
|
if ok {
|
|
delete(a.interactions, id)
|
|
}
|
|
a.mu.Unlock()
|
|
if !ok {
|
|
if toolCallID != "" {
|
|
a.retractByToolCall(toolCallID, by)
|
|
}
|
|
return
|
|
}
|
|
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, by))
|
|
}
|
|
|
|
func (a *Agent) retractByToolCall(toolCallID, by string) {
|
|
a.mu.Lock()
|
|
var found *Interaction
|
|
for key, in := range a.interactions {
|
|
if in.ToolCallID == toolCallID {
|
|
found = in
|
|
delete(a.interactions, key)
|
|
break
|
|
}
|
|
}
|
|
a.mu.Unlock()
|
|
if found == nil {
|
|
return
|
|
}
|
|
a.hub.broadcast(interactionResolvedEvent(a.ID, found, by))
|
|
}
|
|
|
|
func interactionResolvedEvent(agentID string, in *Interaction, by string) Event {
|
|
return Event{
|
|
Type: EventInteractionResolved,
|
|
Agent: agentID,
|
|
ID: string(in.ID),
|
|
ToolCallID: in.ToolCallID,
|
|
By: by,
|
|
}
|
|
}
|
|
|
|
type updateKind int
|
|
|
|
const (
|
|
updateOther updateKind = iota
|
|
updateInTurn
|
|
updateTurnEnd
|
|
updateInteractionResolved
|
|
)
|
|
|
|
// classifyUpdate reads just enough of a notification to keep the UI's turn
|
|
// indicator honest.
|
|
//
|
|
// Both rails share the `{sessionId, update: {sessionUpdate: "...", ...}}` shape,
|
|
// so one probe covers them. Everything else in the payload stays opaque: the
|
|
// xAI rail's ~60 variants are internal to grok and drift with every upstream
|
|
// sync, so nothing load-bearing is keyed off them.
|
|
func classifyUpdate(params json.RawMessage) (updateKind, string) {
|
|
if len(params) == 0 {
|
|
return updateOther, ""
|
|
}
|
|
var probe struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
// The stable rail spells it camelCase; the xAI rail keeps Rust's
|
|
// snake_case field names. Accept both rather than guess.
|
|
ToolCallIDCamel string `json:"toolCallId"`
|
|
ToolCallIDSnake string `json:"tool_call_id"`
|
|
} `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(params, &probe); err != nil {
|
|
return updateOther, ""
|
|
}
|
|
toolCallID := probe.Update.ToolCallIDCamel
|
|
if toolCallID == "" {
|
|
toolCallID = probe.Update.ToolCallIDSnake
|
|
}
|
|
|
|
switch probe.Update.SessionUpdate {
|
|
case "turn_completed":
|
|
return updateTurnEnd, toolCallID
|
|
case "interaction_resolved":
|
|
return updateInteractionResolved, toolCallID
|
|
case "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update",
|
|
"user_message_chunk", "plan", "pending_interaction":
|
|
return updateInTurn, toolCallID
|
|
}
|
|
return updateOther, toolCallID
|
|
}
|