add readme

This commit is contained in:
iceBear67
2026-08-16 02:42:22 +00:00
parent 051efe8fec
commit a158d6bfe8
3 changed files with 449 additions and 287 deletions
+287
View File
@@ -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 56 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.
-287
View File
@@ -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 56 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.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+161
View File
@@ -0,0 +1,161 @@
# grok-glance
给 [grok](https://github.com/xai-org/grok-build) 终端会话加一个浏览器遥控台:镜像正在跑的 TUI,从网页里发 prompt、打断 turn、批准工具调用。终端不会交出控制权,两边同时可用。
一个 Go 二进制,前端编进去,一个端口,没有数据库。
```
你的机器 任何能打开浏览器的地方
┌─────────────────────────┐ ┌──────────────────┐
│ grok TUInewgrok │ │ │
│ │ │ │ │
│ └── /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)。