Remote Console MCP + human-is-playing guard — design
Remote Console MCP + human-is-playing guard — design
Section titled “Remote Console MCP + human-is-playing guard — design”Approved by Chris 2026-10-08. Agents currently drive the Remote Playtest Console with throwaway
curl helpers (GET /status, POST /cmd, POST /watch). On 2026-10-08 a FunZo perf re-check
nearly took over a run Chris was playing on BUILD 286; it was only noticed because HP dropped
from 96 to 24 between two status reads. Perf A/Bs were also inconclusive: status fps is one
instantaneous sample, swinging by about ±20. This design gives agents a proper tool surface, and
puts a structural guard on the device’s run that does not depend on every agent remembering to
check.
Decisions (Chris, 2026-10-08)
Section titled “Decisions (Chris, 2026-10-08)”- The MCP is local stdio, in
tools/console_mcp/and registered in the repo.mcp.json. It is not a remote/mcproute on the Worker. Only local sessions (Chris’s and Toby’s machines) can use it. - The guard is enforced in both the MCP and the Worker
/cmd, so an agent’s raw curl hits the same wall. - The guard is an allowlist. Every command kind is guarded unless it is on the safe list, so a command added later is guarded by default.
- A human is playing if their last input was ≤ 120 s ago.
ab_testlives in the MCP, not in a separate skill.
1. Game side
Section titled “1. Game side”Human input timestamp
Section titled “Human input timestamp”-
InputRouter.last_human_input_ms: int = -1is stamped in_inputby any real input:- a joypad button (any pad, the Siri Remote included);
- a joypad stick push past
PAD_WAKE; - a key press;
- a touch or a screen drag;
- a mouse button.
Mouse motion alone does not count, because a cursor resting on a Mac window must not hold the guard. The timestamp uses the same
clock_fninjection aslast_pad_activity_ms. -
last_pad_activity_msis unchanged. PadLiveness keeps its pad-only semantics. -
The rule for what counts is a pure static function,
is_human_input(event, joy_name) -> bool, so GUT can test it without the scene tree.
/status additions (main.build_status, both the run branch and the menu branch)
Section titled “/status additions (main.build_status, both the run branch and the menu branch)”human: {input_age_ms: int, autopilot: bool}input_age_msis-1when there has been no input since boot. Otherwise it is now minuslast_human_input_ms.autopilotismain._auto; it is false at the menu. Attract-mode AI play is not human.
perf: {draws, proc_ms, funzones, webs}, run branch only:drawsisRenderingServer.get_rendering_info(RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME);proc_msisPerformance.TIME_PROCESS × 1000;funzonesissim.funzones.size();websissim.webs.size().
qm: {auto, tier}, the QualityManager’s live auto flag and tier.
qm_auto command
Section titled “qm_auto command”No command can currently turn the QualityManager’s adaptive mode back on remotely: graphics, a
Debug Settings row press and QualityManager itself all flip qm.auto to false one-way, so
ab_test cannot honour “never leave qm.auto flipped”. The fix is a new dev command,
{kind:"qm_auto", on:bool}, which sets quality_manager.auto; it is on the safe list.
Seeded graphics flags
Section titled “Seeded graphics flags”debug_settings.graphics_state() stays empty until something seeds it, so flags.gfx:* cannot
tell agents a toggle’s live default (for example, webs_lite defaults ON). The state is seeded
at boot from the live values, so every graphics effect key appears in flags from the first
status post on.
2. Worker guard (control/src/guard.js, imported by worker.js and the MCP)
Section titled “2. Worker guard (control/src/guard.js, imported by worker.js and the MCP)”SAFE_KINDS:graphics,background,web_look,weapon_look,set_tunables,audio_bus,haptic_play,qm_auto. Every othercmd.kindis guarded.HUMAN_WINDOW_MS = 120000,STATUS_FRESH_MS = 30000.guardVerdict(cmd, statusRow, now, body) -> {ok, reason}is a pure function. It refuses a guarded command when any of these holds:human.input_age_msis between 0 andHUMAN_WINDOW_MS, inclusive ("human_playing");- the status row is older than
STATUS_FRESH_MS, or missing ("status_unknown"); - the status blob has no
humankey, which covers older builds ("status_unknown").
- It passes whenever
body.override === trueorbody.source === "panel". The console panel’s two/cmdfetches (panel/client.js,panel/live.js) sendsource: "panel", because that is Chris at the keyboard. /cmdanswers a refusal with 409,{ok:false, error:"guarded", reason, input_age_ms}.- The guard exists to catch accidents, not attacks: anyone holding the key can send
override.
3. MCP server (tools/console_mcp/)
Section titled “3. MCP server (tools/console_mcp/)”Node, using @modelcontextprotocol/sdk over stdio. It reads BULLET_HEAVEN_CONTROL_KEY from the
environment; the .mcp.json command sources ~/.secrets, so the key never appears in a file. The
base URL comes from CONSOLE_URL, defaulting to the deployed Worker. The HTTP layer is injectable
so tests can run against a fake Worker.
| Tool | Behaviour |
|---|---|
sessions |
/sessions, plus human_playing per device (needs one /status read per device). |
status({code, full?}) |
/status. It is trimmed by default: config.published and weapons detail are dropped, and flags is filtered to gfx:*/dev:*. full: true returns the raw blob. |
send_command({code, cmd, override?, reason?}) |
Checks guardVerdict locally first, then sends /cmd. override: true requires a reason and is logged to stderr and returned in the result. |
telemetry_query({sql}) |
Runs wrangler d1 execute bullet-heaven-telemetry --remote --json from telemetry/, with CLOUDFLARE_ACCOUNT_ID set. It accepts a single SELECT or WITH … SELECT statement only; any other statement, or more than one, is rejected before anything runs. Results are capped at 200 rows. |
ab_test({code, toggle, rounds=3, seconds_per_side=30, override?}) |
The device perf bisect (below). |
ab_test procedure
Section titled “ab_test procedure”- Refuse when any of these holds:
- a human is present (unless
override); - the device is not in a run;
rounds < 3;- the toggle is not in
flags(an unknown live default).
- a human is present (unless
- Keep the device watched:
POST /watchevery 20 s for the whole test, so status arrives at 1 Hz. - Read the baseline: the toggle’s live value A and
qm.auto. - Run the rounds. For each round, run the sides in ABBA order (A, B, B, A). For each side:
- send the
graphicscommand; - wait 3 s to settle;
- sample
/statusfpsevery 1 s forseconds_per_side; - keep the mean.
- send the
- Restore, in a
finallyblock: set the toggle back to A. Ifqm.autowas true, sendqm_auto {on:true}. Then re-read status and verify both, and report a failed restore loudly in the result. - Report:
- the per-side mean ± stddev and n;
- the delta;
conclusive: falsewhen the two sides’ ±1 stddev ranges overlap;- a
scene_driftwarning whenalive/funzones/webschanged by more than 25% between the first and last rounds.
Deploy order
Section titled “Deploy order”- Game build with the
humanfield. - Dev install on the devices.
- Worker guard deploy.
Deploying the guard first would refuse every guarded command sent by agents to devices still
running older builds. The panel is not affected, because it sends source: "panel". A console
Worker redeploy also ships the pending main changes (FZ_TILE_PX and its What’s new item).
Testing
Section titled “Testing”- GUT:
is_human_inputacross event types (touch, key, pad, the Siri Remote, drift belowPAD_WAKE, mouse motion);- the
last_human_input_msstamp; build_statuscarrieshuman/perf/qmin both branches;- the boot-seeded
gfx:*keys; qm_autosetsquality_manager.autoboth ways.
node --testincontrol/:guardVerdictcases (safe kind, guarded kind with a fresh human, a stale status, a missinghumankey, override, panel) and a 409 from/cmd.node --testintools/console_mcp/, against a fake Worker:- the ABBA order and averaging;
- restore on success and on an injected error;
- refusal while a human is present;
- the SELECT-only filter;
- the trim.
- On a real device, after the install: one live
ab_testof a known no-op toggle (expect inconclusive) and one of Zone Detail in a FunZo scene (expect a conclusive delta).