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>
65 lines
1.7 KiB
Go
65 lines
1.7 KiB
Go
package hub
|
|
|
|
import "encoding/json"
|
|
|
|
// ring is a bounded transcript buffer.
|
|
//
|
|
// The whole persistence story of glance is this type: a browser that attaches
|
|
// mid-session, or reloads, sees the last `capacity` frames and nothing older.
|
|
// Frames are kept as the raw bytes that arrived, so replay is byte-identical to
|
|
// what the terminal saw and costs no re-encoding.
|
|
//
|
|
// Bounded and in-memory is a deliberate choice, not a shortcut. A control plane
|
|
// that durably recorded everything an agent ever said -- file contents, diffs,
|
|
// command output -- would be a far larger secret to keep than the one this
|
|
// server is built to keep.
|
|
type ring struct {
|
|
frames []json.RawMessage
|
|
start int
|
|
size int
|
|
dropped int
|
|
}
|
|
|
|
func newRing(capacity int) *ring {
|
|
if capacity < 1 {
|
|
capacity = 1
|
|
}
|
|
return &ring{frames: make([]json.RawMessage, capacity)}
|
|
}
|
|
|
|
func (r *ring) push(frame json.RawMessage) {
|
|
n := len(r.frames)
|
|
if r.size < n {
|
|
r.frames[(r.start+r.size)%n] = frame
|
|
r.size++
|
|
return
|
|
}
|
|
// Full: overwrite the oldest and remember that history was lost, so the UI
|
|
// can say "history truncated" rather than implying the session began here.
|
|
r.frames[r.start] = frame
|
|
r.start = (r.start + 1) % n
|
|
r.dropped++
|
|
}
|
|
|
|
// snapshot returns the buffered frames oldest-first.
|
|
//
|
|
// The slice is fresh but the frames are shared: they are never mutated after
|
|
// being pushed, so readers may hold them without copying.
|
|
func (r *ring) snapshot() []json.RawMessage {
|
|
out := make([]json.RawMessage, 0, r.size)
|
|
n := len(r.frames)
|
|
for i := 0; i < r.size; i++ {
|
|
out = append(out, r.frames[(r.start+i)%n])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *ring) reset() {
|
|
r.start = 0
|
|
r.size = 0
|
|
r.dropped = 0
|
|
for i := range r.frames {
|
|
r.frames[i] = nil
|
|
}
|
|
}
|