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>
295 lines
9.2 KiB
Go
295 lines
9.2 KiB
Go
// Package acp speaks the Agent Client Protocol dialect that grok's `/rc` bridge
|
|
// exposes.
|
|
//
|
|
// There is no Go SDK for ACP upstream, so this is hand-written -- but
|
|
// deliberately thin. Glance is a control plane, not a second agent: it needs to
|
|
// correlate requests with responses, recognise the handful of methods it acts
|
|
// on, and pass everything else through to the browser untouched. Modelling all
|
|
// ~60 xAI notification variants as Go structs would be a large amount of code
|
|
// that breaks on every upstream sync and buys nothing, since the browser renders
|
|
// from the JSON either way.
|
|
//
|
|
// The roles are inverted relative to the terminal: over this link *grok is the
|
|
// Agent* and glance is the Client. That is what lets glance drive a session it
|
|
// did not create -- it sends `session/prompt` and `session/cancel`, and receives
|
|
// `session/update` plus permission requests.
|
|
package acp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Methods glance sends to grok.
|
|
const (
|
|
MethodInitialize = "initialize"
|
|
MethodSessionList = "session/list"
|
|
MethodSessionPrompt = "session/prompt"
|
|
MethodSessionCancel = "session/cancel"
|
|
MethodRCReplay = "x.ai/rc/replay"
|
|
MethodRCViewers = "x.ai/rc/viewers"
|
|
)
|
|
|
|
// Methods grok sends to glance.
|
|
const (
|
|
MethodSessionUpdate = "session/update"
|
|
// The xAI rail: tool-call deltas, subagent activity, turn boundaries. Not
|
|
// in the upstream schema and not stable -- treated as opaque presentation
|
|
// data, never as something correctness depends on.
|
|
MethodXAINotification = "x.ai/session_notification"
|
|
|
|
MethodRequestPermission = "session/request_permission"
|
|
MethodAskUserQuestion = "x.ai/ask_user_question"
|
|
MethodExitPlanMode = "x.ai/exit_plan_mode"
|
|
|
|
MethodRCStatus = "x.ai/rc/status"
|
|
// The terminal answered an interaction first: retract the browser's dialog.
|
|
MethodRCInteractionCancelled = "x.ai/rc/interaction_cancelled"
|
|
)
|
|
|
|
// JSON-RPC error codes used on this link.
|
|
const (
|
|
CodeMethodNotFound = -32601
|
|
CodeInvalidParams = -32602
|
|
CodeInternal = -32603
|
|
)
|
|
|
|
// Frame is one JSON-RPC 2.0 message in either direction.
|
|
//
|
|
// Every field is optional because the same struct decodes requests,
|
|
// notifications, and responses; which one it is follows from which fields are
|
|
// set, per Kind.
|
|
type Frame struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *Error `json:"error,omitempty"`
|
|
}
|
|
|
|
// Error is a JSON-RPC error object.
|
|
type Error struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
if e == nil {
|
|
return "<nil>"
|
|
}
|
|
return fmt.Sprintf("jsonrpc %d: %s", e.Code, e.Message)
|
|
}
|
|
|
|
// Kind classifies a decoded frame.
|
|
type Kind int
|
|
|
|
const (
|
|
// KindRequest expects a response: it has both a method and an id.
|
|
KindRequest Kind = iota
|
|
// KindNotification is fire-and-forget: method, no id.
|
|
KindNotification
|
|
// KindResponse answers a request we sent: id, no method.
|
|
KindResponse
|
|
// KindInvalid is none of the above.
|
|
KindInvalid
|
|
)
|
|
|
|
// Kind reports what f is.
|
|
func (f *Frame) Kind() Kind {
|
|
hasID := len(f.ID) > 0 && string(f.ID) != "null"
|
|
switch {
|
|
case f.Method != "" && hasID:
|
|
return KindRequest
|
|
case f.Method != "":
|
|
return KindNotification
|
|
case hasID && (len(f.Result) > 0 || f.Error != nil):
|
|
return KindResponse
|
|
default:
|
|
return KindInvalid
|
|
}
|
|
}
|
|
|
|
// IsInteraction reports whether a request from grok is one the user must answer.
|
|
//
|
|
// These three are the set grok's own leader broadcasts for first-answer-wins
|
|
// arbitration. Handling only permissions would strand a browser user the moment
|
|
// the agent asked a question instead of requesting a tool.
|
|
func IsInteraction(method string) bool {
|
|
switch method {
|
|
case MethodRequestPermission, MethodAskUserQuestion, MethodExitPlanMode:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsTranscript reports whether a notification from grok belongs in the
|
|
// transcript ring and should be forwarded to browsers.
|
|
//
|
|
// Both rails qualify: the stable `session/update` carries correctness, and the
|
|
// xAI rail carries the streaming detail that makes the transcript readable.
|
|
func IsTranscript(method string) bool {
|
|
switch method {
|
|
case MethodSessionUpdate, MethodXAINotification:
|
|
return true
|
|
}
|
|
// grok's own predicate accepts an `x.ai/session/update` spelling too;
|
|
// mirroring that keeps glance working if the bridge starts emitting it.
|
|
return method == "x.ai/session/update"
|
|
}
|
|
|
|
// NewRequest builds a request frame.
|
|
func NewRequest(id uint64, method string, params any) (Frame, error) {
|
|
raw, err := marshalParams(params)
|
|
if err != nil {
|
|
return Frame{}, err
|
|
}
|
|
return Frame{JSONRPC: "2.0", ID: encodeID(id), Method: method, Params: raw}, nil
|
|
}
|
|
|
|
// NewNotification builds a notification frame.
|
|
func NewNotification(method string, params any) (Frame, error) {
|
|
raw, err := marshalParams(params)
|
|
if err != nil {
|
|
return Frame{}, err
|
|
}
|
|
return Frame{JSONRPC: "2.0", Method: method, Params: raw}, nil
|
|
}
|
|
|
|
// NewResponse builds a success response to id.
|
|
func NewResponse(id json.RawMessage, result any) (Frame, error) {
|
|
raw, err := json.Marshal(result)
|
|
if err != nil {
|
|
return Frame{}, err
|
|
}
|
|
return Frame{JSONRPC: "2.0", ID: id, Result: raw}, nil
|
|
}
|
|
|
|
// NewErrorResponse builds a failure response to id.
|
|
func NewErrorResponse(id json.RawMessage, code int, message string) Frame {
|
|
return Frame{JSONRPC: "2.0", ID: id, Error: &Error{Code: code, Message: message}}
|
|
}
|
|
|
|
func encodeID(id uint64) json.RawMessage {
|
|
return json.RawMessage(fmt.Sprintf("%d", id))
|
|
}
|
|
|
|
// marshalParams keeps `params` absent rather than null when there is nothing to
|
|
// send: some JSON-RPC peers distinguish the two, and absent is the safer of the
|
|
// two to emit.
|
|
func marshalParams(params any) (json.RawMessage, error) {
|
|
if params == nil {
|
|
return nil, nil
|
|
}
|
|
if raw, ok := params.(json.RawMessage); ok {
|
|
return raw, nil
|
|
}
|
|
return json.Marshal(params)
|
|
}
|
|
|
|
// SessionMeta labels a mirrored session in the UI. grok sends it in the
|
|
// `initialize` result and again in every `x.ai/rc/status` notification, so a
|
|
// reconnecting browser can label the session without another round trip.
|
|
type SessionMeta struct {
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
CWD string `json:"cwd,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
}
|
|
|
|
// Label is the best human-readable name available for this session.
|
|
func (m SessionMeta) Label() string {
|
|
switch {
|
|
case m.Title != "":
|
|
return m.Title
|
|
case m.CWD != "":
|
|
return m.CWD
|
|
case m.SessionID != "":
|
|
return m.SessionID
|
|
default:
|
|
return "session"
|
|
}
|
|
}
|
|
|
|
// InitializeResult is grok's reply to `initialize`.
|
|
type InitializeResult struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
Meta *InitializeMeta `json:"_meta,omitempty"`
|
|
}
|
|
|
|
// InitializeMeta carries the session identity in `initialize`'s `_meta`.
|
|
type InitializeMeta struct {
|
|
Session SessionMeta `json:"session"`
|
|
RemoteControl *RemoteControlStatus `json:"remoteControl,omitempty"`
|
|
}
|
|
|
|
// RemoteControlStatus describes the bridge's replay ring.
|
|
type RemoteControlStatus struct {
|
|
ReplayBuffer int `json:"replayBuffer"`
|
|
Frames int `json:"frames"`
|
|
Dropped int `json:"dropped"`
|
|
}
|
|
|
|
// StatusParams is the payload of `x.ai/rc/status`.
|
|
type StatusParams struct {
|
|
Session SessionMeta `json:"session"`
|
|
Replay struct {
|
|
Frames int `json:"frames"`
|
|
Dropped int `json:"dropped"`
|
|
} `json:"replay"`
|
|
}
|
|
|
|
// InteractionCancelledParams is the payload of `x.ai/rc/interaction_cancelled`:
|
|
// the terminal answered first, so the browser's dialog must close.
|
|
type InteractionCancelledParams struct {
|
|
ID uint64 `json:"id"`
|
|
ToolCallID string `json:"toolCallId,omitempty"`
|
|
}
|
|
|
|
// PromptParams asks grok to run a turn. The bridge accepts either ACP content
|
|
// blocks or a plain string; glance sends the string, which is all a browser
|
|
// prompt box produces.
|
|
type PromptParams struct {
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
// CancelParams interrupts the running turn.
|
|
type CancelParams struct {
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
}
|
|
|
|
// ViewersParams tells the bridge how many browsers are watching, so `/rc status`
|
|
// in the terminal can say so. Cosmetic.
|
|
type ViewersParams struct {
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// ToolCallID digs the tool call id out of an interaction's params.
|
|
//
|
|
// It is what both sides key a retraction by: when one side answers, the other's
|
|
// dialog is closed by tool call id rather than by JSON-RPC id, because the
|
|
// browser never sees the terminal's ids. The three interaction shapes spell it
|
|
// differently, hence the two probes.
|
|
func ToolCallID(params json.RawMessage) string {
|
|
if len(params) == 0 {
|
|
return ""
|
|
}
|
|
var probe struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
ToolCall struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
} `json:"toolCall"`
|
|
}
|
|
if err := json.Unmarshal(params, &probe); err != nil {
|
|
return ""
|
|
}
|
|
if probe.ToolCallID != "" {
|
|
return probe.ToolCallID
|
|
}
|
|
return probe.ToolCall.ToolCallID
|
|
}
|