Skip to content

Add a local spectator panel to the agent client#13

Merged
Julian-adv merged 2 commits into
Julian-adv:masterfrom
leedoe:agent-watch-panel
Jul 23, 2026
Merged

Add a local spectator panel to the agent client#13
Julian-adv merged 2 commits into
Julian-adv:masterfrom
leedoe:agent-watch-panel

Conversation

@leedoe

@leedoe leedoe commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The agent plays headless — the only way to watch it was logging a second character in next to it. This adds a read-only web panel served by the agent-client itself on 127.0.0.1:8808 (watch_port in config, 0 disables):

  • Live top-down map rendered from real height tiles — terrain colors by elevation (sea/sand/grass/rock), house footprints, players / official NPCs / monsters with name labels and HP bars, aggressive/attack states, self marker with facing arrow, night tint, wheel zoom
  • Status header: HP bar, gold, game clock (day/night), floor, bag count
  • Categorized event feed: chat / combat / trade / system / agent events, plus every LLM turn — the prompt sent (collapsed), the response received (pretty-printed JSON with duration), and errors — captured in LlmScheduler::submit so every backend (claude / codex / openrouter) is covered at one choke point
  • Per-NPC tabs when several NPCs run on one instance; feed ring buffers live outside the session so they survive reconnects; the panel starts before Google sign-in completes, so the page is reachable while the device flow waits

Implementation notes:

  • Game events are captured in SharedState::push_event before the message mutates state, while player names still resolve, reusing driver::prompt::format_event so the panel shows exactly the lines the LLM reads. Movement/time-sync spam stays off the feed (the map shows it live instead).
  • /api/height samples the shared HeightSampler server-side, so the browser needs no tile protocol and the tile cache is reused.
  • No new dependencies — axum and serde_json were already in the tree. Bound to 127.0.0.1, strictly read-only (no command path from the panel to the agent).

Tested: cargo test -p agent-client 20/20 passing, clippy clean for the new code (the 3 pre-existing needless_borrow warnings in orchestrator.rs are untouched), plus a live smoke test — panel served real prod height tiles around (5000, −4000) with a stubbed session snapshot to verify map, feed, filters, and LLM-turn rendering.

🤖 Generated with Claude Code

leedoe and others added 2 commits July 23, 2026 11:07
The agent plays headless, so watching it meant logging a second character
in next to it. The client already holds everything a spectator needs —
nearby entities, houses, the height sampler, chat/combat events, and every
LLM turn — so serve it: an axum server on 127.0.0.1 (watch_port, default
8808) renders a live top-down map from real height tiles plus a
categorized event feed including the prompts and responses the LLM
exchanges, with per-NPC tabs when several run on one instance.

Event capture hooks into SharedState::push_event before it mutates, while
player names still resolve, reusing the prompt formatter so the panel and
the LLM read the same lines. LLM turns are recorded in the scheduler, so
every backend is covered at one choke point. Ring buffers live in the
per-NPC hub and survive reconnects; the panel starts before Google
sign-in so the page is reachable while the device flow waits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
XpGained carries new_level, max_hp and current_hp, but nothing landed in
self_player, so the level shown by the spectator panel — and by the world
state fed to the LLM — stayed at its join-time value forever. Apply it to
the earning player, whether that is us or a nearby player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Julian-adv added a commit that referenced this pull request Jul 23, 2026
…wn map

Review follow-ups to PR #13. The panel worked; what it cost, and what it drew,
did not hold up.

Drawing the world twice. The map sampled `/api/height` and coloured it with
its own elevation ramp, while `data/terrain/minimap/` already holds 1024 baked
region PNGs — 1 px per metre, coloured from `shared/palette.json`, and already
drawn by the web client's WorldMapDialog. Two colour schemes for one world:
re-bake the terrain and only the spectator map drifts. The panel now serves
those PNGs (`/api/minimap/{rx}/{rz}`) using the client's own region math, from
a local terrain tree or proxied from the tile API when the agent runs
elsewhere — the game server sends no CORS headers, so the browser cannot fetch
them itself. That deletes the endpoint, the ramp, and `sample_grid`; zoomed-out
resolution improves from ~10.7 m/sample to 1 m/px.

The panel handle had reached too deep. `LlmScheduler::submit` took a spectator
handle, so a fleet-wide queue knew about a debug UI, and the handle had to be
threaded through `DriverConfig`, `spawn_llm_task`, and a `RollAdvisor` struct
that existed only to dodge an argument-count lint. `build_llm_backend` is
already the one funnel where every backend is constructed; wrapping there
(`WatchedBackend`) covers claude / codex / openrouter and removes all five
threading points. The scheduler no longer knows the panel exists. This does
give up the queue-wait figure, but the bug it was added for — counting queue
time as inference — stays fixed: the decorator times the backend call only.

The feed rebuilt itself every second. `feed_snapshot` returned the last 200 of
a 300-item ring, and the page detected rotation by comparing the first item's
timestamp, which changes on every push once the ring turns. Past 200 events
the list was torn down and rebuilt each poll, snapping shut any LLM prompt you
had opened to read. Items now carry a monotonic seq and `/api/state?since=`
returns only what the page has not drawn — roughly 340 KB/s of re-sent feed at
a typical cadence, and the ring's last 100 items become reachable instead of
being trimmed away unseen. The response is also serialized after the agent's
state mutex is released, not while holding it.

`watch_port = 0` turned off the server but not the instrumentation: the hub
was built unconditionally, so every NPC still formatted feed lines and copied
every prompt for a feed nobody could read. The hub is built only when the
panel is on, and the default is off — it serves full LLM prompts, so it should
be asked for. A `Host` check closes DNS rebinding against those prompts.

Smaller, from the same pass:

- Feeds were keyed by label, but `NpcConfig::label()` falls back to "agent",
  so two Google-auth NPCs without a `character_name` shared one feed and
  clobbered each other's state handle. Sessions key by config index.
- `XpGained`'s `nearby_players` arm was unreachable — the server only ever
  sends it direct to the player it describes.
- `sample_height` kept neighbour-tile loads that cannot fire: `VERTS_PER_SIDE`
  is `TILE_DIM + 1`, so every bilinear corner is in the covering tile. The
  arithmetic feeding them duplicated `sample_cached`.
- `draw()` ran full-canvas at 60 fps forever, re-parsing `ctx.font` and
  measuring text per entity per frame, though data arrives at 1 Hz.
- An unknown `?npc=` returned 200 with an error body the page rendered as a
  live NPC; now 404.

The merge needed one fix of its own: master replaced `RECONNECT_DELAY` with
the `ws::retry_delay` backoff while the PR was open, and the two auto-merged
into code referencing a constant that no longer exists. The reconnect notice
moved to `run_npc_loop`, which knows the real delay.

Verified against the dev server with both NPCs connected: the served PNG for
Karl's region is byte-identical to the baked file and 1024x1024, an unbaked
region 404s, `since` returns an empty feed while still sending state, loopback
Hosts serve while evil.com and 127.0.0.1.evil.com get 403, and `watch_port = 0`
opens no listener while the agents still enter the game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Julian-adv
Julian-adv merged commit aca8c49 into Julian-adv:master Jul 23, 2026
@Julian-adv

Copy link
Copy Markdown
Owner

Thanks for this PR 🙏 Watching a headless agent has been a real gap, and you nailed the spots that matter — capturing every LLM turn at one choke point (LlmScheduler), keeping the feed ring buffer outside the session so it survives reconnects, and starting the server before sign-in. Merged it, with a few follow-ups worth sharing:

  • Map — reuse the server's baked region minimaps (/api/terrain/minimap, same PNGs the web world map draws) instead of a separate elevation ramp, so colours can't drift from the game and resolution improves. Dropped sample_grid + /api/height.
  • Feed rebuild — past 200 events feed_snapshot + the page's timestamp check tore down and rebuilt the whole list every poll, snapping open LLM prompts shut once a second. Now a monotonic seq + /api/state?since= appends only new items.
  • Off means offwatch_port = 0 stopped the server but not the instrumentation (the hub was always built). Now the hub only exists when the panel is on. Code default is 0; the shipped package config turns it on, since that's for users watching their own headless agent (~0.2% of a core when nobody's looking).
  • Smaller fixes — key feeds by config index (labels can collide as "agent"); a Host check on the loopback listener; 404 on unknown ?npc=; a dirty flag so draw() isn't 60 fps for 1 Hz data; and the panel handle moved out of LlmScheduler::submit into a decorator so the scheduler no longer knows the panel exists.

Shipped as agent-client v0.3.0. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants