288 lines
14 KiB
Markdown
288 lines
14 KiB
Markdown
# 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.
|