add readme
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.
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,161 @@
|
||||
# grok-glance
|
||||
|
||||
给 [grok](https://github.com/xai-org/grok-build) 终端会话加一个浏览器遥控台:镜像正在跑的 TUI,从网页里发 prompt、打断 turn、批准工具调用。终端不会交出控制权,两边同时可用。
|
||||
|
||||
一个 Go 二进制,前端编进去,一个端口,没有数据库。
|
||||
|
||||
```
|
||||
你的机器 任何能打开浏览器的地方
|
||||
┌─────────────────────────┐ ┌──────────────────┐
|
||||
│ grok TUI(newgrok) │ │ │
|
||||
│ │ │ │ │
|
||||
│ └── /rc ══ WSS ═╪══▶ glance ══ WSS ══▶ 浏览器 │
|
||||
└─────────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
grok 主动连出来。glance 可以跑在你能访问的地方,跑 grok 的那台机器不必开端口、不必做内网穿透。
|
||||
|
||||
## 必须搭配 newgrok
|
||||
|
||||
glance 自己不会跑 agent。`/rc` 桥在 [iceBear67/newgrok](https://github.com/iceBear67/newgrok) 里:那是 `xai-org/grok-build` 的补丁工作流,远程控制是其中的 `0003` / `0004` 两个补丁。
|
||||
|
||||
**这两边是同一个功能。** 官方上游没有 `/rc`,单独用本仓库或单独用未打补丁的 grok 都连不上。线格式改一边,另一边也要改。
|
||||
|
||||
| 仓库 | 职责 |
|
||||
|---|---|
|
||||
| [iceBear67/newgrok](https://github.com/iceBear67/newgrok) | 打过补丁的 grok TUI;在会话里敲 `/rc`,作为 ACP Agent 拨出 |
|
||||
| 本仓库 | 自托管控制面:鉴权、会话列表、把 ACP 转成网页 UI |
|
||||
|
||||
## 依赖
|
||||
|
||||
- Go 1.24+
|
||||
- Node.js(构建嵌入的前端;`make build` 会跑 `npm`)
|
||||
- 一台已经按 [newgrok 的 README](https://github.com/iceBear67/newgrok#快速开始) 编译好的 grok(`make setup && make apply && make build`)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 编译并启动 glance
|
||||
|
||||
```sh
|
||||
make build
|
||||
bin/glance serve --addr 127.0.0.1:7717 --insecure-cookie
|
||||
```
|
||||
|
||||
首次启动会在 stderr 打出一条一次性 bootstrap URL(同时写到 `~/.grok/glance/bootstrap.token`)。打开它,扫二维码,输入一个 TOTP 验证码。登记成功后这个 token 立刻作废。
|
||||
|
||||
`--insecure-cookie` 去掉 cookie 的 `Secure`,好让纯 HTTP 的 localhost 能登录。非回环地址会直接拒绝这个 flag。生产环境把 glance 放在终止 TLS 的反代后面,不要加这个 flag。
|
||||
|
||||
### 2. 给这台 grok 发一把 API key
|
||||
|
||||
另开一个终端:
|
||||
|
||||
```sh
|
||||
bin/glance apikey add laptop
|
||||
```
|
||||
|
||||
明文 `glance_sk_…` 只显示这一次,磁盘上只留 SHA-256。命令还会打出一段给 `~/.grok/config.toml` 用的配置。
|
||||
|
||||
### 3. 在跑 grok 的机器上写配置
|
||||
|
||||
把上一步打印的内容写进 `~/.grok/config.toml`:
|
||||
|
||||
```toml
|
||||
[remote_control]
|
||||
url = "ws://127.0.0.1:7717/api/acp/agent"
|
||||
api_key = "glance_sk_…"
|
||||
```
|
||||
|
||||
glance 不在本机时,把 `url` 换成它的地址(TLS 后面用 `wss://…`)。key 也可以不写进文件,改用环境变量:
|
||||
|
||||
| 配置项 | 环境变量 | 说明 |
|
||||
|---|---|---|
|
||||
| `url` | `GROK_RC_URL` | glance 的 agent WebSocket |
|
||||
| `api_key` | `GROK_RC_API_KEY` | `glance apikey add` 打出来的那把 |
|
||||
| `auto_start` | `GROK_RC_AUTO_START` | `true` 则会话一开始就连,不必敲 `/rc` |
|
||||
| `replay_buffer` | — | 重连时回放的帧数,默认 2048 |
|
||||
|
||||
环境变量优先于配置文件;空字符串当作没设置。
|
||||
|
||||
### 4. 在 grok 里打开遥控
|
||||
|
||||
按 newgrok 的方式启动 TUI(一般是仓库里的 `make run`),进入一个会话后:
|
||||
|
||||
```
|
||||
/rc
|
||||
```
|
||||
|
||||
也可以 `/rc on`、`/rc off`、`/rc status`。终端里应出现已连接的系统提示,**TUI 本身继续能用**——这是整件事情的前提。
|
||||
|
||||
然后打开 glance 的网页(默认 `http://127.0.0.1:7717`),用验证器里的 6 位码登录。会话列表里出现刚连上的 grok 即可遥控。
|
||||
|
||||
## 用的时候会怎样
|
||||
|
||||
- 浏览器发的 prompt 会出现在终端里,两边一起流式输出。
|
||||
- 需要批准的工具调用、`ask_user_question`、退出 plan mode:终端和浏览器同时弹出。谁先答谁算数,另一边的卡片自己收起来。
|
||||
- 浏览器里的 **Stop** 是真正的 `session/cancel`,会话日志会记成 `client:glance`,不是模拟按 Esc。
|
||||
- glance 挂了、网络断了、进程被杀,终端不受影响。`/rc` 桥在自己的任务里退避重连;遥控不能把本地会话一起带走。
|
||||
|
||||
同一把 API key 重连会顶掉上一条连接。两台 grok 共用一把 key 会互相抢槽位,每台各发一把。
|
||||
|
||||
## 命令
|
||||
|
||||
```
|
||||
glance serve [flags] 跑服务器
|
||||
glance apikey add <name> 给一台 grok 签发 key
|
||||
glance apikey list
|
||||
glance apikey rm <id-or-name>
|
||||
glance bootstrap 再打一次 setup token(仅未登记时)
|
||||
glance version
|
||||
```
|
||||
|
||||
`serve` 的 flag:
|
||||
|
||||
| Flag | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--addr` | `127.0.0.1:7717` | 监听地址。默认只绑回环,对外暴露必须是有意识的 |
|
||||
| `--dir` | `~/.grok/glance` | 状态目录 |
|
||||
| `--insecure-cookie` | 关 | 纯 HTTP localhost 用;非回环地址禁止 |
|
||||
|
||||
## 状态与恢复
|
||||
|
||||
持久化的只有无法重算的东西,写在 `~/.grok/glance/state.json`(`0600`):TOTP 密钥、cookie 签名密钥、API key 的哈希。对话历史是每条 agent 内存里的环形缓冲(4096 帧),刷新页面会回放,**重启服务器不会**。
|
||||
|
||||
验证器丢了:删掉 `state.json`,重新登记。没有别的找回方式。这会同时作废所有 API key 和已签发的 cookie。`glance bootstrap` 在已经登记过后会拒绝再发 token。
|
||||
|
||||
`~/.grok/glance/` 里还有 `secret.key` 和 `hook.secret`,那是别的东西用的,不要动。
|
||||
|
||||
登录失败全局限流(5 分钟 8 次)。用过的 TOTP 步进会烧掉,同一个 30 秒窗口里登两次,第二次会失败——这是防重放,不是故障。
|
||||
|
||||
## 部署注意
|
||||
|
||||
glance 是带壳 agent 的遥控台。能通过鉴权的人可以批准任意工具调用。
|
||||
|
||||
- 默认只听 `127.0.0.1`。要暴露出去,放在终止 TLS 的反代后面。
|
||||
- 自己不终止 TLS。公网上开 `--insecure-cookie` 等于把 session cookie 明文送出去。
|
||||
- 没有多用户、没有角色、没有审计日志、没有按 key 分权限。
|
||||
- Cookie 名是 `__Host-glance`:浏览器强制 `Secure` + `Path=/` + 不许设 `Domain`。改其中任何一项,浏览器会静默丢掉 cookie,看起来像登录坏了。
|
||||
|
||||
## 开发
|
||||
|
||||
```sh
|
||||
make build # 前端,再编二进制 → bin/glance
|
||||
make server # 只编 Go,沿用上次的前端
|
||||
make web # 只编前端
|
||||
make check # go vet + go test + tsc --noEmit
|
||||
make dev # Go + Vite 热更新 → http://localhost:5173
|
||||
```
|
||||
|
||||
`//go:embed` 在编译期解析,所以 **`go build` 打进去的永远是上次 `make web` 的产物**。改了 UI 却没出现在 `bin/glance` 里,就是这个原因。`vite build` 会清空 `web/dist/`,连 `.gitkeep` 一起删;`make web` / `make clean` 会补回来,裸跑 `npm run build` 不会。干净 clone 缺这个文件时,`go build` 会报一个难看的 embed 错误。
|
||||
|
||||
前端开发用 `make dev`:Vite 把 `/api` 代理到 Go。`__Host-glance` 是 `SameSite=Strict`,跨源发不出去。拿 Vite UI 直接打 Go 的 7717 端口,会表现为莫名其妙的鉴权失败。
|
||||
|
||||
不编 Rust、只调 UI 时,用 `cmd/fakeagent`:它按真桥一样拨 agent socket,演一段带真实 `session/request_permission` 的回合。
|
||||
|
||||
```sh
|
||||
bin/glance serve --insecure-cookie &
|
||||
make fakeagent KEY=glance_sk_…
|
||||
```
|
||||
|
||||
这证明不了真桥说的是同一种方言。动过桥、交互路径或线格式之后,按 [ARCHITECTURE.md](ARCHITECTURE.md) 的设计说明和仓库里的端到端清单,对着一台真的 newgrok 跑一遍。
|
||||
|
||||
设计上为什么是这个形状——grok 当拨出方、角色对终端是反的、浏览器不直接讲 ACP、交互两边抢答——见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
Reference in New Issue
Block a user