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>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# CLAUDE.md — working on grok-glance
|
||||
|
||||
grok-glance is the web control plane for grok's `/rc` remote control. Read
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) first — it explains *why* the pieces are shaped the way
|
||||
they are. This file is the operational half: how to build it, how to test it, and the
|
||||
handful of things that will waste an afternoon if you learn them the hard way.
|
||||
|
||||
The Rust half lives in the sibling `newgrok/` tree as patches `0003`/`0004`. **The two are
|
||||
one feature.** A change to the wire format is a change to both.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/glance/main.go serve | apikey add·list·rm | bootstrap | version
|
||||
cmd/fakeagent/main.go a fake grok, for UI work without rebuilding Rust
|
||||
internal/
|
||||
acp/ JSON-RPC framing + the ACP subset glance needs ~280 lines
|
||||
hub/ agents, browsers, ring buffer, interaction table the live system
|
||||
auth/ TOTP, bootstrap gate, signed cookies
|
||||
state/ ~/.grok/glance/state.json (0600)
|
||||
httpapi/ chi router, both WS upgrades, embedded SPA
|
||||
web/src/
|
||||
App.tsx status gate: Setup / Login / Console
|
||||
pages/ Setup, Login, Sessions, Session
|
||||
components/ Transcript, ToolCall, PermissionDialog, PromptBox, TurnStatus
|
||||
lib/ api.ts (REST), ws.ts (GlanceSocket), acp.ts (frames → transcript)
|
||||
```
|
||||
|
||||
If you are looking for where a decision lives:
|
||||
|
||||
| Question | File |
|
||||
|---|---|
|
||||
| What does the browser send/receive? | `internal/hub/browser.go` |
|
||||
| What happens when two people click Allow? | `internal/hub/agent.go` → `Answer`, `retract` |
|
||||
| Which methods does glance refuse? | `internal/hub/agent.go` → `handleFrame` |
|
||||
| Is this frame part of the transcript? | `internal/acp/jsonrpc.go` → `IsTranscript` |
|
||||
| Why is `/setup` a 404? | `internal/auth/auth.go` → `BootstrapToken` |
|
||||
| How does a frame become a bubble? | `web/src/lib/acp.ts` → `applyFrame` |
|
||||
|
||||
---
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
make build # frontend, then binary → bin/glance
|
||||
make server # binary only, keeping the last frontend build (fast server loop)
|
||||
make web # frontend only
|
||||
make check # go vet + go test + tsc --noEmit
|
||||
make dev # Go server + Vite with hot reload → http://localhost:5173
|
||||
```
|
||||
|
||||
`//go:embed all:dist` resolves at compile time, so **`go build` ships whatever `make web`
|
||||
last produced**. If a UI change does not appear in `bin/glance`, that is why.
|
||||
|
||||
Use `make dev` for frontend work: Vite proxies `/api` to the Go server, which is what keeps
|
||||
the `__Host-glance` cookie working — it is `SameSite=Strict` and would never survive a
|
||||
cross-origin request. Hitting the Go port directly on 7717 with the Vite UI will look like
|
||||
a mysterious auth failure.
|
||||
|
||||
### First run
|
||||
|
||||
```sh
|
||||
make build
|
||||
bin/glance serve --addr 127.0.0.1:7717 --insecure-cookie
|
||||
# → prints a bootstrap URL; open it, scan the QR, enter one code
|
||||
bin/glance apikey add dev
|
||||
# → prints glance_sk_… once, plus a [remote_control] block for ~/.grok/config.toml
|
||||
```
|
||||
|
||||
`--insecure-cookie` drops the `Secure` attribute so a plain-HTTP localhost session works.
|
||||
It is refused on a non-loopback address, deliberately. In production put glance behind a
|
||||
TLS-terminating proxy and leave the flag off.
|
||||
|
||||
Lost the authenticator? Delete `~/.grok/glance/state.json` and start over. That is the
|
||||
whole recovery story, on purpose — see ARCHITECTURE.md. **`~/.grok/glance/` also contains
|
||||
`secret.key` and `hook.secret` that belong to something else entirely. Do not touch them.**
|
||||
|
||||
---
|
||||
|
||||
## Testing without rebuilding grok
|
||||
|
||||
`cmd/fakeagent` dials the agent socket exactly as the real bridge does and plays a scripted
|
||||
turn: streamed text, a thought, a plan, a tool call, and a **real**
|
||||
`session/request_permission` that waits for a real answer.
|
||||
|
||||
```sh
|
||||
bin/glance serve --insecure-cookie &
|
||||
make fakeagent KEY=glance_sk_… # or: go run ./cmd/fakeagent --key …
|
||||
```
|
||||
|
||||
Then in the browser: prompt it, watch it stream, answer the permission. Useful flags:
|
||||
|
||||
- `--terminal-after 3s` — the "terminal" answers the permission first, so you can watch the
|
||||
browser's card retract by itself. This is the path a browser alone cannot exercise.
|
||||
- `--speed 1s` — slow the stream down to catch layout problems mid-turn.
|
||||
- Send the prompt and hit **Stop**: the fake agent honours `session/cancel` and finishes the
|
||||
turn with `stopReason: cancelled`.
|
||||
|
||||
Run several at once with different `--title` to test the session list.
|
||||
|
||||
It emits `turn_completed` on the **xAI rail** (`x.ai/session_notification`) on purpose: if
|
||||
the turn stops showing as finished in the UI, rail mirroring has regressed. That is the
|
||||
most likely thing to break silently after an upstream sync.
|
||||
|
||||
What fakeagent does *not* prove is that the real bridge speaks this dialect. Only the
|
||||
end-to-end checklist does.
|
||||
|
||||
---
|
||||
|
||||
## Where the ACP types come from
|
||||
|
||||
There is no Go SDK for ACP. `internal/acp` is hand-written against
|
||||
[`agentclientprotocol/agent-client-protocol`](https://github.com/agentclientprotocol/agent-client-protocol)
|
||||
(`schema/v1/schema.json`), and it is thin on purpose — glance correlates ids, recognises a
|
||||
dozen methods, and passes payloads through to the browser as `json.RawMessage`.
|
||||
|
||||
**Do not "finish" it by modelling every update variant.** The stable `session/update` rail
|
||||
is a small closed set; the xAI rail has ~60 grok-internal variants that drift with every
|
||||
upstream sync. Typed structs for those would be a large amount of code whose only effect is
|
||||
to turn an upstream rename into a parse error that kills a live connection. The contract is:
|
||||
|
||||
- **The stable rail carries correctness.** Turn state comes from `update.sessionUpdate`,
|
||||
read by `classifyUpdate` in `internal/hub/agent.go` — one field, both rails.
|
||||
- **The xAI rail is presentation.** Unrecognised variants are stored, forwarded, and
|
||||
skipped by the renderer. Never an error.
|
||||
- **`_meta` is forwarded byte-for-byte.** `eventId`, `promptId`, `chunkId` and `isReplay`
|
||||
are how a viewer dedups and orders; rewriting the envelope would break replay.
|
||||
|
||||
To regenerate anything, clone the spec repo and read `schema/v1/schema.json`. There is no
|
||||
codegen step and adding one would be a mistake at this size.
|
||||
|
||||
### Response shapes are pinned to grok's Rust types, not to the spec
|
||||
|
||||
The three interaction replies are built in `web/src/components/PermissionDialog.tsx`, and
|
||||
their exact shapes were read off grok's source, not guessed:
|
||||
|
||||
| Interaction | Reply |
|
||||
|---|---|
|
||||
| `session/request_permission` | `{"outcome":{"outcome":"selected","optionId":"…"}}`, or `{"outcome":{"outcome":"cancelled"}}` |
|
||||
| `x.ai/exit_plan_mode` | `{"outcome":"approved"}`, `{"outcome":"cancelled","feedback":"…"}`, `{"outcome":"abandoned"}` |
|
||||
| `x.ai/ask_user_question` | `{"outcome":"accepted","answers":{"<question text>":["<label>"]}}`, plus optional `annotations` |
|
||||
|
||||
`ask_user_question` is the fiddly one, and `buildAccepted` reproduces
|
||||
`xai-grok-pager/src/views/question_view.rs` rule for rule: values are **arrays** of labels;
|
||||
the map is keyed by question **text** in the original order; unanswered questions are
|
||||
**omitted** rather than sent empty; a freeform-only answer is the literal `["Other"]` with
|
||||
the typed text in `annotations[q].notes`; `preview` rides along only for single-select.
|
||||
|
||||
Getting these wrong does not fail loudly. It produces an agent that acts on an answer to a
|
||||
question nobody asked. If you change one, change it in `newgrok/` too and re-run the
|
||||
end-to-end checklist.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
**Go.** Standard library plus chi, coder/websocket, pquerna/otp — that is the whole
|
||||
dependency list and it should stay that way. `slog` for logging, never `fmt.Println`.
|
||||
Comments explain *why*; the code already says what. Errors reaching a browser go through
|
||||
`Event{Type: EventError}` so the UI can show them rather than the socket dying quietly.
|
||||
|
||||
**One writer per socket.** `coder/websocket` allows a single concurrent writer, so every
|
||||
connection has exactly one writer goroutine draining a bounded queue. If you need to send
|
||||
from a new place, push to `outbound`/`send`; do not call `conn.Write` directly.
|
||||
|
||||
**Backpressure is a disconnect.** A full queue drops the connection and the client
|
||||
reconnects into a fresh snapshot. Do not "fix" this by growing the buffer — the snapshot is
|
||||
cheaper than the backlog it would replay.
|
||||
|
||||
**Nothing blocks the reader goroutine.** Anything slow (a prompt, a cancel) runs detached
|
||||
under `context.WithoutCancel`. A prompt can take minutes; waiting inline would freeze the
|
||||
Stop button that is meant to end it.
|
||||
|
||||
**Frontend.** HeroUI 3 sits on react-aria-components: buttons take `onPress`, not
|
||||
`onClick`, and there is no provider component to wrap. Tailwind 4 is CSS-first — there is
|
||||
**no `tailwind.config.js`**; theme tokens and `@source "./"` live in `web/src/index.css`.
|
||||
Use the semantic token classes already in use rather than arbitrary `bg-[var(--x)]` values.
|
||||
|
||||
**Never render agent output as markup.** Plans, tool output and model text are printed as
|
||||
text. The CSP is `default-src 'self'` with no `unsafe-inline` for scripts; keep it that way.
|
||||
|
||||
**`useTheme` is called exactly once,** at the root in `App.tsx`. Each call owns its own
|
||||
state, so a second call silently desyncs the toggle.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
go test ./... # all five internal packages
|
||||
go test -race ./internal/hub -run TestOnlyTheFirst -count=3
|
||||
```
|
||||
|
||||
`internal/hub/hub_test.go` drives the hub over **real WebSocket connections** with a fake
|
||||
grok and fake browsers, because the behaviour under test is the interleaving of three
|
||||
goroutines and a mocked transport would test the mock. If you add a command or an event,
|
||||
add it there.
|
||||
|
||||
Two things to know before you write a hub test:
|
||||
|
||||
- `interaction_resolved` is **broadcast to every browser**. A test with two browsers must
|
||||
consume the broadcast on the second one before asserting anything about its own reply, or
|
||||
it will read the first browser's resolution and report a confusing failure.
|
||||
- Agent-list churn arrives whenever a connection comes or goes. `fakeBrowser.expect` skips
|
||||
it; assert on the event kind you care about, not on message order.
|
||||
|
||||
`internal/httpapi/server_test.go` covers the auth boundary: the bootstrap 404, enrollment,
|
||||
rate limiting, cookie forgery, both upgrades, and the SPA fallback's refusal to swallow
|
||||
`/api/*`.
|
||||
|
||||
---
|
||||
|
||||
## End-to-end checklist
|
||||
|
||||
Unit tests cannot cover the race against a real terminal. Run this against a real grok with
|
||||
`/rc` on before shipping any change to the bridge, the interaction path, or the wire format.
|
||||
|
||||
1. `glance serve` → bootstrap → enroll TOTP → `glance apikey add laptop`.
|
||||
2. Put the printed `[remote_control]` block in `~/.grok/config.toml`.
|
||||
3. Start grok, type `/rc` → a system block confirms connected, **and the TUI stays fully
|
||||
usable**. This is the whole premise of the feature.
|
||||
4. Send a prompt from the browser → it appears in the terminal and streams to both.
|
||||
5. Trigger a tool needing approval → approve in the **browser** → the terminal's modal
|
||||
closes by itself.
|
||||
6. Same again, approving in the **terminal** → the browser's card closes by itself.
|
||||
7. Repeat 5–6 for `x.ai/ask_user_question` and `x.ai/exit_plan_mode`. All three race through
|
||||
the same path, but only permissions get exercised by accident.
|
||||
8. Start a long turn, press **Stop** in the browser → the turn aborts, and the session log
|
||||
attributes it to `client:glance`, not to Esc.
|
||||
9. Confirm tool-call deltas and `turn_completed` reach the browser — proof the xAI rail is
|
||||
mirrored and not just `session/update`.
|
||||
10. **Kill glance mid-turn.** The TUI must keep working; the bridge reconnects with backoff
|
||||
and replays. Remote control must never be able to take the local session down with it.
|
||||
|
||||
Step 10 is the one that matters most.
|
||||
|
||||
---
|
||||
|
||||
## Working on the Rust half
|
||||
|
||||
In `newgrok/`, and read its `CLAUDE.md` first. The short version:
|
||||
|
||||
- `patches/` is the source of truth and is tracked; `work/` is disposable build output.
|
||||
**Edit in `work/`, commit there, then `make rebuild` to export patches.** Never edit a
|
||||
`.patch` by hand.
|
||||
- **`make apply` does `git reset --hard` + `git clean -fdx` in `work/`.** Running it with
|
||||
uncommitted work destroys that work with no warning.
|
||||
- The root `Cargo.toml` is generated. Treat it as read-only; per-crate manifests take
|
||||
`workspace = true` deps.
|
||||
- Cargo on this box is I/O-heavy enough to disturb the machine. Build throttled and in the
|
||||
foreground:
|
||||
```sh
|
||||
nice -n 19 env CARGO_CMD=test PKG=xai-grok-pager ./scripts/build.sh --lib -j 2
|
||||
```
|
||||
`cargo` is only on `PATH` via `scripts/lib.sh`, so go through `scripts/build.sh`. Use
|
||||
module-qualified test filters (`rc::`, `slash::commands::rc`) — a bare `rc` matches
|
||||
hundreds of unrelated tests.
|
||||
- `doctor_cmd::tests::fake_standalone_facts_compose_through_shared_view` fails on a clean
|
||||
upstream tree. It is not yours.
|
||||
- Adding a pager slash command requires listing it in `xai-grok-shell`'s
|
||||
`PAGER_COMMAND_KEYS`, or `pager_builtin_triggers_are_reserved_in_shell` fails.
|
||||
|
||||
The RC code is `xai-grok-pager/src/rc/` (`mod`, `protocol`, `ring`, `tee`, `transport`) plus
|
||||
`slash/commands/rc.rs` and `app/dispatch/rc.rs`. `tee.rs` is the interesting one: it is a
|
||||
single interception point on the pager's ACP channel, and keeping it that way is what keeps
|
||||
upstream conflicts survivable.
|
||||
|
||||
---
|
||||
|
||||
## Things that will bite you
|
||||
|
||||
- **`vite build` empties `web/dist/`,** taking `.gitkeep` with it — and `web/embed.go` needs
|
||||
a file there or `go build` fails on a clean clone with an unhelpful embed error. The
|
||||
`web` and `clean` targets restore it; a bare `npm run build` does not.
|
||||
- **`__Host-` is a browser-enforced contract**: Secure, `Path=/`, no `Domain`. Change any of
|
||||
those and the browser silently discards the cookie, which looks exactly like a broken
|
||||
login.
|
||||
- **A used TOTP step is burned.** Logging in twice inside one 30-second window fails the
|
||||
second time. That is the replay defence, not a bug.
|
||||
- **The rate limiter is global, not per-user** — there are no users. Eight failures in five
|
||||
minutes locks out *everyone*, including a correct code. Tests must account for it.
|
||||
- **Reconnecting with the same API key replaces the previous connection.** Two grok
|
||||
instances sharing one key will fight over the slot; give each its own.
|
||||
- **A `false` return from `Answer`/`Decline` is not an error.** It is the ordinary outcome
|
||||
of losing the race, and it must produce `by:"elsewhere"`, not a 500.
|
||||
Reference in New Issue
Block a user