Remote Console MCP + human-is-playing guard — implementation plan
Remote Console MCP + human-is-playing guard Implementation Plan
Section titled “Remote Console MCP + human-is-playing guard Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo also consultbh-sdd-concurrentbefore Task 1, and dispatch every implementer into its own worktree (.claude/worktrees/<name>, branched offorigin/main).
Goal: Give agents a local MCP for the Remote Playtest Console (sessions, status, send_command, telemetry_query, ab_test). A structural guard, enforced in both the MCP and the Worker, stops agents hijacking a run a human is playing.
Architecture: The game publishes human / perf / qm on /status, plus live graphics defaults and a qm_auto command. The console Worker’s /cmd runs a pure guardVerdict from control/src/guard.js. A zero-dependency logic layer in tools/console_mcp/lib/ imports the same guard and implements the tools and ab_test. A thin server.mjs wraps that layer with @modelcontextprotocol/sdk over stdio.
Tech Stack: Godot 4.7 typed GDScript (GUT), Cloudflare Worker JS (node –test with the sqlite fake D1), Node 22 ESM, @modelcontextprotocol/sdk@1.32.1.
Spec: docs/superpowers/specs/2026-10-08-console-mcp-design.md
Global Constraints
Section titled “Global Constraints”HUMAN_WINDOW_MS = 120000,STATUS_FRESH_MS = 30000.SAFE_KINDS:graphics,background,web_look,nebula_look,galaxy_look,weapon_look,set_tunables,audio_bus,haptic_play,qm_auto. This is the spec’s list plusnebula_look/galaxy_look, which are render-only look selectors of the same kind asweb_look. Every other kind is guarded.- Refusal is HTTP 409
{ok:false, error:"guarded", reason, input_age_ms}; reasons are"human_playing"and"status_unknown". - A guarded command passes with
override === true(strict boolean) orsource === "panel". ab_test:rounds >= 3, ABBA order per round, 3 s settle per side, 1 Hz samples,/watchevery 20 s, restore infinally.telemetry_query: a singleSELECT/WITH … SELECTstatement only; results capped at 200 rows.- The secret is
BULLET_HEAVEN_CONTROL_KEY, read from the env only. Never write its value to any file. - Deploy order: game build, then Dev install, then Worker deploy.
/simis untouched, so determinism hashes must not move.
Deviations from the spec, decided while planning (from reading the code)
Section titled “Deviations from the spec, decided while planning (from reading the code)”- The human timestamp lives on
main, not onInputRouter.InputRouteris re-created every run (main.gd:1256) and does not exist at the boot menu.main._input(main.gd:3058) sees every event from boot on. The pure ruleInputRouter.is_human_input()stays onInputRouter, next tois_pad_activity(). - Graphics defaults are reported live, without seeding the tracked state. Seeding
_graphics_stateat boot would freeze a tier snapshot that the adaptive ladder later moves past. Instead,DebugSettingsPanel.graphics_report(qm)returns the tracked state once seeded and the tier-derived values before that._seed_graphics_stateis refactored onto the same puregraphics_from_qm(qm). GET /statusadds_ts, the row’s server timestamp, soab_testcan drop the duplicate reads it gets when it polls faster than the game posts.
File map
Section titled “File map”| File | Responsibility |
|---|---|
input/input_router.gd |
+ static func is_human_input(event, joy_name) -> bool |
main.gd |
_last_human_input_ms stamp in _input; human / perf / qm in build_status; qm_auto command; _dev_flag_states uses graphics_report |
ui/debug_settings_panel.gd |
static func graphics_from_qm(qm) -> Dictionary; func graphics_report(qm) -> Dictionary |
tests/test_console_mcp_status.gd |
GUT tests for all of the game side |
tests/test_input_router_pad_activity.gd |
+ is_human_input cases |
control/src/guard.js |
Pure guard: constants + isGuarded(kind) + guardVerdict(...) |
control/src/worker.js |
/cmd runs the guard; GET /status adds _ts |
control/src/panel/client.js, control/src/panel/live.js |
Send source:'panel' |
control/test/guard.test.mjs |
Guard unit + /cmd 409 integration tests |
control/test/live-sessions.test.mjs |
Existing /cmd posts gain source:'panel' |
control/README.md |
Documents the guard |
tools/console_mcp/lib/http.mjs |
consoleClient({baseUrl, key, fetchFn}): sessions / status / cmd / watch |
tools/console_mcp/lib/tools.mjs |
trimStatus, humanState, isSelectOnly, sendCommand, listSessions, telemetryQuery |
tools/console_mcp/lib/ab_test.mjs |
abTest(client, opts, deps) + summarize(samples) |
tools/console_mcp/server.mjs |
MCP stdio wrapper (the only file importing the SDK) |
tools/console_mcp/package.json, package-lock.json, .gitignore |
SDK dependency, node_modules ignored |
tools/console_mcp/test/*.test.mjs |
Logic tests against a fake client |
.mcp.json |
Registers dark-cosmos-console |
scripts/gate.sh |
Adds tools/console_mcp/test/*.test.mjs to the tool tests line |
docs/architecture/dev-tools.md |
Console MCP section |
Task 1: Game side — human input, status fields, qm_auto, live graphics report
Section titled “Task 1: Game side — human input, status fields, qm_auto, live graphics report”Files:
- Modify:
input/input_router.gd(afteris_pad_activity, around line 74) - Modify:
main.gd(_inputat 3058;apply_dev_commandbefore theif sim == nullat ~3536;build_statusat ~3718;_dev_flag_statesat 3820) - Modify:
ui/debug_settings_panel.gd(_seed_graphics_stateat 479) - Test:
tests/test_input_router_pad_activity.gd,tests/test_console_mcp_status.gd(new)
Interfaces:
-
Produces:
/status.human = {input_age_ms: int, autopilot: bool}, in both the menu branch and the run branch;/status.perf = {draws: int, proc_ms: float, funzones: int, webs: int}, run branch only;/status.qm = {auto: bool, tier: int}, both branches;/status.flags["gfx:<effect>"]is present for everyGRAPHICS_TOGGLESeffect from the first status on;- the dev command
{kind:"qm_auto", on:bool}.
-
Step 1: Write the failing
is_human_inputtests. Append totests/test_input_router_pad_activity.gd:
func test_human_input_counts_every_real_device_but_not_drift_or_hover() -> void: var b := InputEventJoypadButton.new() b.pressed = true assert_true(InputRouter.is_human_input(b, "Siri Remote"), "the Siri Remote is a human") assert_true(InputRouter.is_human_input(b, "Xbox Wireless Controller")) assert_true(InputRouter.is_human_input(_motion(0.5), "Siri Remote")) assert_false(InputRouter.is_human_input(_motion(0.1), "Xbox Wireless Controller"), "drift") var k := InputEventKey.new() k.pressed = true assert_true(InputRouter.is_human_input(k, "")) k.pressed = false assert_false(InputRouter.is_human_input(k, ""), "a key release alone is not new activity") var t := InputEventScreenTouch.new() t.pressed = true assert_true(InputRouter.is_human_input(t, "")) assert_true(InputRouter.is_human_input(InputEventScreenDrag.new(), "")) var mb := InputEventMouseButton.new() mb.pressed = true assert_true(InputRouter.is_human_input(mb, "")) assert_false(InputRouter.is_human_input(InputEventMouseMotion.new(), ""), "a resting cursor is not a player")- Step 2: Write the failing main-level tests. Create
tests/test_console_mcp_status.gd:
extends GutTest# Remote Console MCP (spec 2026-10-08-console-mcp-design.md): the status fields agents read to# decide whether a human is playing, the perf readout ab_test samples, and the qm_auto restore.# Headless boot auto-starts a survival run (see test_lifecycle_main.gd).
func _boot() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame assert_not_null(m.sim, "sanity: headless boot is in a run") return m
func test_status_reports_no_human_until_input_then_its_age() -> void: var m = await _boot() var now := [10000] m.pad_clock_override = func() -> int: return now[0] assert_eq(int(m.build_status().human.input_age_ms), -1, "no input since boot") var k := InputEventKey.new() k.pressed = true k.physical_keycode = KEY_F12 # bound to nothing: only the stamp runs m._input(k) now[0] = 13500 assert_eq(int(m.build_status().human.input_age_ms), 3500)
func test_status_reports_autopilot_separately_from_human() -> void: var m = await _boot() m._auto = true assert_true(bool(m.build_status().human.autopilot)) m._auto = false assert_false(bool(m.build_status().human.autopilot))
func test_status_carries_perf_and_qm_blocks() -> void: var m = await _boot() var s: Dictionary = m.build_status() for k: String in ["draws", "proc_ms", "funzones", "webs"]: assert_true(s.perf.has(k), "perf.%s" % k) assert_eq(int(s.perf.funzones), m.sim.funzones.size()) assert_eq(int(s.perf.webs), m.sim.webs.size()) assert_eq(bool(s.qm.auto), m.quality_manager.auto) assert_eq(int(s.qm.tier), m.quality_manager.level)
func test_every_graphics_toggle_has_a_live_flag_before_any_press() -> void: var m = await _boot() var flags: Dictionary = m.build_status().flags for g: Array in DebugSettingsPanel.GRAPHICS_TOGGLES: assert_true(flags.has("gfx:" + String(g[0])), "gfx:%s reported before any console press" % g[0]) assert_eq(bool(flags["gfx:perf_webs_lite"]), PerfCuts.webs_lite, "reports the LIVE default")
func test_reporting_graphics_does_not_pin_the_ladder() -> void: var m = await _boot() m.quality_manager.auto = true m.build_status() assert_true(m.quality_manager.auto, "reading status never flips qm.auto")
func test_qm_auto_command_sets_the_adaptive_flag_both_ways() -> void: var m = await _boot() m.apply_dev_command({"kind": "graphics", "effect": "bloom", "on": false}) assert_false(m.quality_manager.auto, "sanity: a graphics press flips auto off") assert_true(m.apply_dev_command({"kind": "qm_auto", "on": true})) assert_true(m.quality_manager.auto) assert_true(m.apply_dev_command({"kind": "qm_auto", "on": false})) assert_false(m.quality_manager.auto)- Step 3: Run the tests and confirm they fail.
Run: GUT_LOG=$TMPDIR/bh-mcp-t1.log scripts/check-test-count.sh
Expected: FAIL, with the new tests named (is_human_input not found, human key missing).
- Step 4: Implement
is_human_inputininput/input_router.gd, directly afteris_pad_activity:
# Pure: is this event a person touching the game RIGHT NOW (Remote Console MCP guard, spec# 2026-10-08-console-mcp-design.md)? Unlike is_pad_activity, the Siri Remote, keys, touch and mouse# clicks all count. A key/touch release, stick drift and bare mouse motion do not.static func is_human_input(event: InputEvent, joy_name: String) -> bool: if event is InputEventJoypadButton: return true if event is InputEventJoypadMotion: return absf((event as InputEventJoypadMotion).axis_value) > PAD_WAKE if event is InputEventKey: return (event as InputEventKey).pressed if event is InputEventScreenTouch: return (event as InputEventScreenTouch).pressed if event is InputEventScreenDrag: return true if event is InputEventMouseButton: return (event as InputEventMouseButton).pressed return falsejoy_name is intentionally unused for now: every joypad, the Siri Remote included, counts. It is kept so the signature matches is_pad_activity and its call site. Name it _joy_name if warnings-as-errors flags it.
- Step 5: Stamp it in
main._input. Add a var next to the other console state (near_dev_timescale):
var _last_human_input_ms: int = -1 # Remote Console MCP guard: last real input (any device), -1 = none since bootMake these the FIRST lines of func _input(event: InputEvent) -> void:, before the _bay_open() early return, so modals don’t hide a human:
if InputRouter.is_human_input(event, Input.get_joy_name(event.device) if event is InputEventJoypadButton or event is InputEventJoypadMotion else ""): _last_human_input_ms = _pad_now_ms()- Step 6: Add
graphics_from_qm/graphics_reportinui/debug_settings_panel.gd. Convert_seed_graphics_stateso the body builds a dictionary:
# Every graphics row's value as the live QualityManager tier + look statics have it now. Pure# read: seeding (below) and the Remote Console's status (graphics_report) both use it.static func graphics_from_qm(qm: QualityManager) -> Dictionary: var g := {} g["bloom"] = QualityManager.bloom_on(qm.level) # ... every existing `_graphics_state["x"] = <expr>` line from _seed_graphics_state, moved # verbatim with `_graphics_state` renamed to `g` (bloom ... hdr_output, 34 lines) ... return g
func _seed_graphics_state(qm: QualityManager) -> void: # (keep the existing explanatory comment block here) _graphics_state.merge(graphics_from_qm(qm), true) _seeded_from_qm = true
# What the Remote Console reports as each graphics row's live value: the tracked state once# anything has seeded it, else the tier-derived values WITHOUT seeding (seeding freezes a tier# snapshot the adaptive ladder may later move past, and is what console_graphics relies on).func graphics_report(qm: QualityManager) -> Dictionary: if _seeded_from_qm: return graphics_state() return graphics_from_qm(qm) if qm != null else {}The middle of graphics_from_qm is a mechanical move. Each of the 34 existing assignment lines (bloom through hdr_output, lines 488–521 today) moves verbatim, with _graphics_state[ replaced by g[. No expression changes.
- Step 7: Wire main.
- In
_dev_flag_states, replacevar gfx := debug_settings.graphics_state()withvar gfx := debug_settings.graphics_report(quality_manager). - In
apply_dev_command, directly beforeif sim == null:, add:
- In
if String(cmd.get("kind", "")) == "qm_auto": # Remote Console MCP: the only way back to the adaptive ladder after a console graphics # press (console_graphics flips qm.auto off one-way). ab_test restores it with this. if quality_manager == null: return false quality_manager.auto = bool(cmd.get("on", true)) return true- In
build_status, add a helper:
# Remote Console MCP guard (spec 2026-10-08-console-mcp-design.md): agents refuse run-hijacking# commands while input_age_ms is inside the human window. autopilot = the attract AI flying a run.func _human_status() -> Dictionary: return {"input_age_ms": _pad_now_ms() - _last_human_input_ms if _last_human_input_ms >= 0 else -1, "autopilot": _auto and sim != null}
func _qm_status() -> Dictionary: if quality_manager == null: return {} return {"auto": quality_manager.auto, "tier": quality_manager.level}- In the menu-branch dict, add
"human": _human_status(), "qm": _qm_status(), "flags": _dev_flag_states(). Check first that_dev_flag_statesis safe withsim == null: it reads only statics andquality_manager. If it isn’t, leaveflagsout of the menu branch. - In the run-branch dict, add:
"human": _human_status(), "qm": _qm_status(), "perf": {"draws": RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME), "proc_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, "funzones": sim.funzones.size(), "webs": sim.webs.size()},- Step 8: Run the gate.
Run: GUT_LOG=$TMPDIR/bh-mcp-t1.log scripts/gate.sh
Expected: green. The determinism tests are unchanged (no /sim edits). If test_main.gd’s tooltip roster test complains about qm_auto, add a desc in the same roster it names.
- Step 9: Commit.
git add input/input_router.gd main.gd ui/debug_settings_panel.gd tests/test_input_router_pad_activity.gd tests/test_console_mcp_status.gdgit commit --file=<msg> # "feat(console): human/perf/qm status + qm_auto + live graphics report for the console MCP"Task 2: Worker guard
Section titled “Task 2: Worker guard”Files:
- Create:
control/src/guard.js,control/test/guard.test.mjs - Modify:
control/src/worker.js(/cmdat line 101,GET /statusat line 154) - Modify:
control/src/panel/client.js:14-15,control/src/panel/live.js:86-87 - Modify:
control/test/live-sessions.test.mjs(the/cmdposts at lines 83, 84, 91, 133, 134, 140, 149) - Modify:
control/README.md(endpoint table + a new “Agent guard” section)
Interfaces:
-
Produces, from
control/src/guard.js:HUMAN_WINDOW_MS,STATUS_FRESH_MS,SAFE_KINDS: Set<string>;isGuarded(kind: string) -> boolean;guardVerdict({cmd, blob, ts, now, override, source}) -> {ok: boolean, reason?: 'human_playing'|'status_unknown', input_age_ms?: number}.
blobis the parsed status blob ornull;tsis the status row’s ms timestamp ornull. -
Produces:
GET /statusreturns{...blob, _ts}, or{}when there is no row. -
Step 1: Write the failing tests in
control/test/guard.test.mjs:
// Remote Console MCP guard (spec 2026-10-08-console-mcp-design.md): agents may not hijack a run a// human is playing. Pure verdict + the real /cmd against the sqlite fake D1.import { test } from 'node:test';import assert from 'node:assert/strict';import worker from '../src/worker.js';import { fakeD1 } from './_d1.mjs';import { guardVerdict, isGuarded, HUMAN_WINDOW_MS, STATUS_FRESH_MS } from '../src/guard.js';
const NOW = 1_000_000;const fresh = (age) => ({ blob: { human: { input_age_ms: age, autopilot: false } }, ts: NOW - 1000, now: NOW });
test('safe kinds always pass, guarded kinds are everything else', () => { for (const k of ['graphics', 'background', 'web_look', 'nebula_look', 'galaxy_look', 'weapon_look', 'set_tunables', 'audio_bus', 'haptic_play', 'qm_auto']) { assert.equal(isGuarded(k), false, k); assert.equal(guardVerdict({ cmd: { kind: k }, ...fresh(5) }).ok, true, k); } for (const k of ['invuln', 'clear', 'spawn_boss', 'dimension', 'damage_mult', 'reset_all', 'some_future_kind', '']) { assert.equal(isGuarded(k), true, k); }});
test('a guarded command is refused while a human input is inside the window', () => { const v = guardVerdict({ cmd: { kind: 'invuln' }, ...fresh(HUMAN_WINDOW_MS) }); assert.deepEqual(v, { ok: false, reason: 'human_playing', input_age_ms: HUMAN_WINDOW_MS }); assert.equal(guardVerdict({ cmd: { kind: 'invuln' }, ...fresh(HUMAN_WINDOW_MS + 1) }).ok, true); assert.equal(guardVerdict({ cmd: { kind: 'invuln' }, ...fresh(-1) }).ok, true, 'no input since boot');});
test('stale, missing or pre-guard status fails closed', () => { const cmd = { kind: 'clear' }; assert.equal(guardVerdict({ cmd, blob: null, ts: null, now: NOW }).reason, 'status_unknown'); assert.equal(guardVerdict({ cmd, blob: { human: { input_age_ms: -1 } }, ts: NOW - STATUS_FRESH_MS - 1, now: NOW }).reason, 'status_unknown'); assert.equal(guardVerdict({ cmd, blob: { fps: 60 }, ts: NOW, now: NOW }).reason, 'status_unknown', 'an older build has no human block');});
test('override:true and source:panel pass; a truthy non-boolean override does not', () => { assert.equal(guardVerdict({ cmd: { kind: 'clear' }, ...fresh(5), override: true }).ok, true); assert.equal(guardVerdict({ cmd: { kind: 'clear' }, ...fresh(5), source: 'panel' }).ok, true); assert.equal(guardVerdict({ cmd: { kind: 'clear' }, ...fresh(5), override: 'yes' }).ok, false);});
const KEY = 'k';const env = () => ({ ...fakeD1(), CONTROL_KEY: KEY });const post = (e, path, body) => worker.fetch(new Request('https://x.test' + path, { method: 'POST', body: JSON.stringify({ key: KEY, ...body }) }), e);
test('/cmd answers 409 for a guarded command while a human plays, and queues nothing', async () => { const e = env(); await post(e, '/status', { code: 'AAA111', blob: { human: { input_age_ms: 2000, autopilot: false } } }); const r = await post(e, '/cmd', { code: 'AAA111', cmd: { kind: 'invuln', on: true } }); assert.equal(r.status, 409); const body = await r.json(); assert.equal(body.error, 'guarded'); assert.equal(body.reason, 'human_playing'); assert.equal(e.db.prepare('SELECT COUNT(*) AS n FROM commands').get().n, 0); const ok = await post(e, '/cmd', { code: 'AAA111', cmd: { kind: 'graphics', effect: 'bloom', on: false } }); assert.equal(ok.status, 200, 'safe kinds still queue'); const panel = await post(e, '/cmd', { code: 'AAA111', source: 'panel', cmd: { kind: 'invuln', on: true } }); assert.equal(panel.status, 200, 'the console panel is Chris at the keyboard');});
test('GET /status exposes the row timestamp as _ts', async () => { const e = env(); await post(e, '/status', { code: 'AAA111', blob: { fps: 60 } }); const r = await worker.fetch(new Request('https://x.test/status?code=AAA111&key=' + KEY), e); const s = await r.json(); assert.equal(s.fps, 60); assert.ok(Number.isFinite(s._ts) && s._ts > 0);});Check that _d1.mjs exposes db the way live-sessions.test.mjs uses it (e.db.prepare(...)); use the same accessor.
-
Step 2: Run the tests and confirm they fail. Run:
cd control && node --test test/guard.test.mjs. Expected: FAIL (cannot find../src/guard.js). -
Step 3: Implement
control/src/guard.js:
// Remote Console MCP guard (spec docs/superpowers/specs/2026-10-08-console-mcp-design.md).// Pure: no D1, no Worker globals. Imported by worker.js (/cmd) AND tools/console_mcp, so the two// can never disagree. It exists to catch an AGENT's accident, not an attacker: anyone holding the// key can send override.export const HUMAN_WINDOW_MS = 120000;export const STATUS_FRESH_MS = 30000;// Render-only / config commands that cannot take over a run. Everything else is guarded, so a// command kind added later is guarded by default.export const SAFE_KINDS = new Set(['graphics', 'background', 'web_look', 'nebula_look', 'galaxy_look', 'weapon_look', 'set_tunables', 'audio_bus', 'haptic_play', 'qm_auto']);
export const isGuarded = (kind) => !SAFE_KINDS.has(String(kind ?? ''));
export function guardVerdict({ cmd, blob, ts, now, override, source }) { if (!isGuarded(cmd?.kind) || override === true || source === 'panel') return { ok: true }; if (!blob || !Number.isFinite(ts) || now - ts > STATUS_FRESH_MS || typeof blob.human !== 'object' || blob.human === null) { return { ok: false, reason: 'status_unknown' }; } const age = Number(blob.human.input_age_ms); if (Number.isFinite(age) && age >= 0 && age <= HUMAN_WINDOW_MS) { return { ok: false, reason: 'human_playing', input_age_ms: age }; } return { ok: true };}- Step 4: Wire
worker.js. Addimport { guardVerdict } from './guard.js';. In/cmd, afterif (!code || !cmd) return j({ ok: false }, 400);:
const srow = await env.DB.prepare('SELECT blob,ts FROM status WHERE code=?').bind(code).first(); const v = guardVerdict({ cmd, blob: srow ? JSON.parse(srow.blob) : null, ts: srow ? srow.ts : null, now: Date.now(), override: body.override, source: body.source }); if (!v.ok) return j({ ok: false, error: 'guarded', reason: v.reason, input_age_ms: v.input_age_ms ?? null }, 409);In the GET /status branch, change the SELECT to SELECT blob,ts FROM status WHERE code=? and the return to return j(row ? { ...JSON.parse(row.blob), _ts: row.ts } : {});.
-
Step 5: Panel and existing tests send
source:'panel'.client.js:15:body:JSON.stringify({code:code(),cmd,key:KEY,source:'panel'}).live.js:87:body: JSON.stringify({code: c, cmd, key: KEY, source: 'panel'}).- In
live-sessions.test.mjs, addsource: 'panel'to each/cmdpost body listed above. Those tests are about queueing, not the guard.
-
Step 6: Run all control tests. Run:
cd control && node --test. Expected: all pass, including the panel harness tests. If one asserts the exact/cmdbody, update it to includesource: 'panel'. -
Step 7: Update the README. Add a row note to the
/cmdtable entry: “409{error:'guarded'}for a non-safe kind while a human played in the last 120 s, or when status is stale or older than 30 s, unlessoverride:trueorsource:'panel'”. Add a short## Agent guardsection that points atsrc/guard.jsand the spec, and names the deploy-order rule: never deploy before the game build carryinghumanis on the devices. -
Step 8: Commit.
git add control/src/guard.js control/src/worker.js control/src/panel/client.js control/src/panel/live.js control/test/guard.test.mjs control/test/live-sessions.test.mjs control/README.mdgit commit --file=<msg> # "feat(control): /cmd refuses run-hijacking commands while a human is playing (409 guarded)"Do NOT deploy the Worker in this task (deploy-order constraint).
Task 3: MCP logic layer + server (sessions, status, send_command, telemetry_query)
Section titled “Task 3: MCP logic layer + server (sessions, status, send_command, telemetry_query)”Files:
- Create:
tools/console_mcp/package.json,tools/console_mcp/.gitignore,tools/console_mcp/lib/http.mjs,tools/console_mcp/lib/tools.mjs,tools/console_mcp/server.mjs,tools/console_mcp/test/tools.test.mjs,tools/console_mcp/README.md - Create:
.mcp.json - Modify:
scripts/gate.sh:51
Interfaces:
-
Consumes:
control/src/guard.js(guardVerdict,isGuarded,HUMAN_WINDOW_MS), via the relative import../../../control/src/guard.js. -
Produces:
consoleClient({baseUrl, key, fetchFn = fetch}) -> {sessions(), status(code), cmd(code, cmd, extra = {}), watch(codes)}. Each method resolves to{status: number, body: object}.humanState(blob, ts, now) -> {playing: boolean|null, input_age_ms: number|null, autopilot: boolean|null}, wherenullmeans unknown.trimStatus(blob) -> object.isSelectOnly(sql) -> boolean.sendCommand(client, {code, cmd, override, reason}, {now, sleep, log}) -> {ok, sent?, refused?, reason?, input_age_ms?, override_reason?}.listSessions(client, {now}) -> [{...session, human_playing, input_age_ms, autopilot}].telemetryQuery(sql, {exec}) -> {rows, truncated}.
-
Step 1: Scaffold the package.
tools/console_mcp/package.json:
{ "name": "dark-cosmos-console-mcp", "private": true, "type": "module", "scripts": { "test": "node --test test/*.test.mjs", "start": "node server.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "1.32.1", "zod": "^3.25.0" }}tools/console_mcp/.gitignore: node_modules/
Run cd tools/console_mcp && npm install, commit package-lock.json, then run git check-ignore -v tools/console_mcp/node_modules and confirm it is ignored. Check which zod major the SDK 1.32 peer-depends on (npm view @modelcontextprotocol/sdk@1.32.1 peerDependencies dependencies) and pin to match.
- Step 2: Write the failing tests in
tools/console_mcp/test/tools.test.mjs:
// Console MCP logic (spec 2026-10-08-console-mcp-design.md) against a fake console client: no// network, no SDK import.import { test } from 'node:test';import assert from 'node:assert/strict';import { humanState, trimStatus, isSelectOnly, sendCommand, listSessions, telemetryQuery } from '../lib/tools.mjs';
const NOW = 5_000_000;const blob = (age, extra = {}) => ({ human: { input_age_ms: age, autopilot: false }, _ts: NOW - 500, ...extra });
function fakeClient(statusBlobs) { const sent = [], watched = []; let i = 0; return { sent, watched, async status() { const b = statusBlobs[Math.min(i++, statusBlobs.length - 1)]; return { status: 200, body: b }; }, async watch(codes) { watched.push(codes); return { status: 200, body: { ok: true } }; }, async cmd(code, cmd, extra = {}) { sent.push({ code, cmd, ...extra }); return { status: 200, body: { ok: true, seq: sent.length } }; }, async sessions() { return { status: 200, body: { now: NOW, sessions: [{ code: 'AAA111', build: 287 }] } }; }, };}const deps = { now: () => NOW, sleep: async () => {}, log: () => {} };
test('humanState: playing inside the window, unknown for stale or pre-guard status', () => { assert.equal(humanState(blob(3000), NOW - 500, NOW).playing, true); assert.equal(humanState(blob(200000), NOW - 500, NOW).playing, false); assert.equal(humanState(blob(-1), NOW - 500, NOW).playing, false); assert.equal(humanState({ fps: 60 }, NOW - 500, NOW).playing, null); assert.equal(humanState(blob(3000), NOW - 60000, NOW).playing, null);});
test('sendCommand refuses a guarded command while a human plays and sends nothing', async () => { const c = fakeClient([blob(3000)]); const r = await sendCommand(c, { code: 'AAA111', cmd: { kind: 'invuln', on: true } }, deps); assert.equal(r.ok, false); assert.equal(r.reason, 'human_playing'); assert.equal(c.sent.length, 0);});
test('sendCommand watches first so a guarded check reads a fresh status', async () => { const c = fakeClient([blob(-1)]); await sendCommand(c, { code: 'AAA111', cmd: { kind: 'clear' } }, deps); assert.deepEqual(c.watched[0], ['AAA111']); assert.equal(c.sent.length, 1);});
test('override needs a reason, is forwarded, and is reported back', async () => { const c = fakeClient([blob(3000)]); const bad = await sendCommand(c, { code: 'AAA111', cmd: { kind: 'clear' }, override: true }, deps); assert.equal(bad.ok, false); assert.match(bad.reason, /reason/); const ok = await sendCommand(c, { code: 'AAA111', cmd: { kind: 'clear' }, override: true, reason: 'Chris asked' }, deps); assert.equal(ok.ok, true); assert.equal(c.sent[0].override, true); assert.equal(ok.override_reason, 'Chris asked');});
test('safe commands skip the status read entirely', async () => { const c = fakeClient([blob(3000)]); const r = await sendCommand(c, { code: 'AAA111', cmd: { kind: 'graphics', effect: 'bloom', on: false } }, deps); assert.equal(r.ok, true); assert.equal(c.watched.length, 0);});
test('a 409 from the Worker is surfaced as a refusal, not a crash', async () => { const c = fakeClient([blob(-1)]); c.cmd = async () => ({ status: 409, body: { ok: false, error: 'guarded', reason: 'human_playing', input_age_ms: 10 } }); const r = await sendCommand(c, { code: 'AAA111', cmd: { kind: 'clear' } }, deps); assert.equal(r.ok, false); assert.equal(r.reason, 'human_playing');});
test('listSessions annotates each device with human state', async () => { const c = fakeClient([blob(3000)]); const s = await listSessions(c, deps); assert.equal(s[0].code, 'AAA111'); assert.equal(s[0].human_playing, true);});
test('trimStatus drops the bulky config/weapons detail and non gfx/dev flags', () => { const t = trimStatus({ fps: 60, config: { published: { tunables: { a: 1 } }, id: 'x' }, weapons: [{ a: 1 }], flags: { 'gfx:bloom': true, 'dev:hard_tuning': false, 'look:web': 'A', 'bus:Music': true } }); assert.equal(t.fps, 60); assert.equal(t.config.published, undefined); assert.equal(t.config.id, 'x'); assert.equal(t.weapons, undefined); assert.deepEqual(Object.keys(t.flags).sort(), ['dev:hard_tuning', 'gfx:bloom']);});
test('isSelectOnly accepts one SELECT/WITH statement and nothing else', () => { assert.equal(isSelectOnly('SELECT * FROM samples LIMIT 5'), true); assert.equal(isSelectOnly(' with x as (select 1) select * from x;'), true); assert.equal(isSelectOnly('DELETE FROM samples'), false); assert.equal(isSelectOnly('SELECT 1; DROP TABLE samples'), false); assert.equal(isSelectOnly('WITH x AS (SELECT 1) DELETE FROM samples'), false); assert.equal(isSelectOnly('PRAGMA table_list'), false);});
test('telemetryQuery rejects writes before exec and caps rows at 200', async () => { let ran = false; await assert.rejects(telemetryQuery('UPDATE samples SET fps=0', { exec: async () => { ran = true; } })); assert.equal(ran, false); const rows = Array.from({ length: 250 }, (_, i) => ({ i })); const r = await telemetryQuery('SELECT i FROM t', { exec: async () => [{ results: rows }] }); assert.equal(r.rows.length, 200); assert.equal(r.truncated, true);});-
Step 3: Run the tests and confirm they fail. Run:
cd tools/console_mcp && node --test test/tools.test.mjs. Expected: FAIL (module not found). -
Step 4: Implement
lib/http.mjs:
// Thin HTTP client for the console Worker (control/README.md). fetchFn is injectable for tests.export function consoleClient({ baseUrl, key, fetchFn = fetch }) { const call = async (path, init) => { const r = await fetchFn(baseUrl + path, init); let body = {}; try { body = await r.json(); } catch { body = {}; } return { status: r.status, body }; }; const q = (p) => `${p}${p.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`; const post = (path, payload) => call(path, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: JSON.stringify({ key, ...payload }) }); return { sessions: () => call(q('/sessions')), status: (code) => call(q(`/status?code=${encodeURIComponent(code)}`)), cmd: (code, cmd, extra = {}) => post('/cmd', { code, cmd, ...extra }), watch: (codes) => post('/watch', { codes }), };}- Step 5: Implement
lib/tools.mjs:
// Console MCP tool logic (spec docs/superpowers/specs/2026-10-08-console-mcp-design.md).// Network goes through an injected client; time/sleep/log through deps, so it is all testable.import { guardVerdict, isGuarded, HUMAN_WINDOW_MS, STATUS_FRESH_MS } from '../../../control/src/guard.js';
export function humanState(blob, ts, now) { const h = blob && typeof blob.human === 'object' ? blob.human : null; if (!h || !Number.isFinite(ts) || now - ts > STATUS_FRESH_MS) return { playing: null, input_age_ms: null, autopilot: null }; const age = Number(h.input_age_ms); return { playing: age >= 0 && age <= HUMAN_WINDOW_MS, input_age_ms: age, autopilot: !!h.autopilot };}
export function trimStatus(blob) { const t = { ...blob }; delete t.weapons; if (t.config) { t.config = { ...t.config }; delete t.config.published; } if (t.flags) t.flags = Object.fromEntries(Object.entries(t.flags).filter(([k]) => k.startsWith('gfx:') || k.startsWith('dev:'))); return t;}
export function isSelectOnly(sql) { const s = String(sql).trim().replace(/;\s*$/, ''); if (s.includes(';')) return false; if (!/^(select|with)\b/i.test(s)) return false; return !/\b(insert|update|delete|drop|alter|create|replace|attach|pragma|vacuum)\b/i.test(s);}
// Watch (status at 1 Hz), then wait up to ~3 s for a status newer than the watch request.async function freshStatus(client, code, deps) { await client.watch([code]); const since = deps.now(); let last = null; for (let i = 0; i < 4; i++) { const r = await client.status(code); last = r.body; if (Number.isFinite(last?._ts) && last._ts >= since - 1000) break; await deps.sleep(1000); } return last;}
export async function sendCommand(client, { code, cmd, override = false, reason = '' }, deps) { if (override === true && !String(reason).trim()) return { ok: false, reason: 'override needs a reason' }; if (isGuarded(cmd?.kind) && override !== true) { const s = await freshStatus(client, code, deps); const v = guardVerdict({ cmd, blob: s && Object.keys(s).length ? s : null, ts: s?._ts ?? null, now: deps.now() }); if (!v.ok) return { ok: false, refused: true, reason: v.reason, input_age_ms: v.input_age_ms ?? null }; } if (override === true) deps.log(`[console-mcp] OVERRIDE ${code} ${cmd?.kind}: ${reason}`); const r = await client.cmd(code, cmd, override === true ? { override: true } : {}); if (r.status === 409) return { ok: false, refused: true, reason: r.body.reason, input_age_ms: r.body.input_age_ms ?? null }; if (r.status !== 200) return { ok: false, reason: `http ${r.status}`, body: r.body }; return { ok: true, sent: r.body, ...(override === true ? { override_reason: reason } : {}) };}
export async function listSessions(client, deps) { const r = await client.sessions(); const out = []; for (const s of r.body.sessions || []) { const st = (await client.status(s.code)).body; const h = humanState(st, st?._ts, deps.now()); out.push({ ...s, human_playing: h.playing, input_age_ms: h.input_age_ms, autopilot: h.autopilot }); } return out;}
export async function telemetryQuery(sql, { exec }) { if (!isSelectOnly(sql)) throw new Error('telemetry_query is read-only: one SELECT / WITH … SELECT statement'); const res = await exec(sql); const rows = (res?.[0]?.results) || []; return { rows: rows.slice(0, 200), truncated: rows.length > 200 };}-
Step 6: Run the tests and confirm they pass. Run:
cd tools/console_mcp && node --test test/tools.test.mjs. Expected: PASS. -
Step 7: Implement
server.mjs. This is the only file that imports the SDK. Check the SDK’s README (or context7,@modelcontextprotocol/sdk) for the 1.32McpServer.registerToolsignature before writing it:
#!/usr/bin/env node// Dark Cosmos Remote Console MCP (spec docs/superpowers/specs/2026-10-08-console-mcp-design.md).// stdio transport; all logic lives in lib/ (tested without this file). Never log the key.import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { z } from 'zod';import { execFile } from 'node:child_process';import { promisify } from 'node:util';import { fileURLToPath } from 'node:url';import { consoleClient } from './lib/http.mjs';import { trimStatus, sendCommand, listSessions, telemetryQuery } from './lib/tools.mjs';import { abTest } from './lib/ab_test.mjs';
const key = process.env.BULLET_HEAVEN_CONTROL_KEY;if (!key) { console.error('[console-mcp] BULLET_HEAVEN_CONTROL_KEY is not set (source ~/.secrets)'); process.exit(1); }const baseUrl = process.env.CONSOLE_URL || '<DEPLOYED_WORKER_URL>';const client = consoleClient({ baseUrl, key });const deps = { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)), log: (m) => console.error(m) };const telemetryDir = fileURLToPath(new URL('../../telemetry/', import.meta.url));const exec = async (sql) => { const { stdout } = await promisify(execFile)('npx', ['wrangler', 'd1', 'execute', 'bullet-heaven-telemetry', '--remote', '--json', '--command', sql], { cwd: telemetryDir, env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: '06f5622d4192dcefefbe0da75d766bfc' }, maxBuffer: 32 << 20 }); return JSON.parse(stdout.slice(stdout.indexOf('['), stdout.lastIndexOf(']') + 1));};const out = (o) => ({ content: [{ type: 'text', text: JSON.stringify(o, null, 1) }] });
const server = new McpServer({ name: 'dark-cosmos-console', version: '1.0.0' });server.registerTool('sessions', { description: 'Live Dark Cosmos devices on the Remote Console, each with human_playing (true = a person gave input in the last 120 s: do not hijack the run).', inputSchema: {} }, async () => out(await listSessions(client, deps)));server.registerTool('status', { description: 'One device\'s live status (trimmed; full:true for the raw blob). human.input_age_ms ≤ 120000 means a person is playing.', inputSchema: { code: z.string(), full: z.boolean().optional() } }, async ({ code, full }) => { const b = (await client.status(code)).body; return out(full ? b : trimStatus(b)); });server.registerTool('send_command', { description: 'Queue a console command. Run-changing kinds are REFUSED while a human is playing; override:true needs a reason and should only follow an explicit ask from Chris.', inputSchema: { code: z.string(), cmd: z.record(z.any()), override: z.boolean().optional(), reason: z.string().optional() } }, async (a) => out(await sendCommand(client, a, deps)));server.registerTool('telemetry_query', { description: 'Read-only SQL (one SELECT) against the PRODUCTION telemetry D1 (bullet-heaven-telemetry). Max 200 rows. Schema: telemetry/schema.sql.', inputSchema: { sql: z.string() } }, async ({ sql }) => out(await telemetryQuery(sql, { exec })));server.registerTool('ab_test', { description: 'Device perf A/B of one graphics toggle: ≥3 ABBA rounds of averaged fps, restores the toggle and qm.auto. Refuses while a human plays. Needs the device in a run.', inputSchema: { code: z.string(), toggle: z.string(), rounds: z.number().int().min(3).optional(), seconds_per_side: z.number().int().min(5).optional(), override: z.boolean().optional(), reason: z.string().optional() } }, async (a) => out(await abTest(client, a, deps)));await server.connect(new StdioServerTransport());Replace <DEPLOYED_WORKER_URL> with the real URL: read the route/workers_dev setting in control/wrangler.toml, or the URL in docs/architecture/dev-tools.md § Remote Console. ab_test.mjs arrives in Task 4. Until then, create lib/ab_test.mjs exporting export async function abTest() { return { ok: false, reason: 'not implemented' }; } so the server boots. Task 4 replaces the stub in the same session; the stub never ships to main without Task 4.
- Step 8: Register the server and wire the gate.
.mcp.json at the repo root:
{ "mcpServers": { "dark-cosmos-console": { "command": "bash", "args": ["-c", "cd \"$(git rev-parse --show-toplevel)/tools/console_mcp\" && { [ -d node_modules ] || npm ci --silent >&2; } && { [ -f \"$HOME/.secrets\" ] && source \"$HOME/.secrets\"; true; } && exec node server.mjs"] } }}Run git check-ignore -v .mcp.json and confirm it is NOT ignored. In scripts/gate.sh:51, add tools/console_mcp/test/*.test.mjs to the node --test list and update the comment on line 8.
- Step 9: Smoke-test the stdio server. Run:
cd tools/console_mcp && source ~/.secrets && printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | gtimeout 10 node server.mjs | head -c 2000Expected: the initialize result and a tools/list result naming all 5 tools. Then confirm the key never appears on stdout:
... | node server.mjs | /usr/bin/grep -c "$BULLET_HEAVEN_CONTROL_KEY"Expected: 0.
- Step 10: Write
tools/console_mcp/README.md. Keep it short: what each tool is, the guard,overrideetiquette (only on Chris’s explicit ask, with the reason quoting him), theCONSOLE_URLoverride, and runningnpm test. Commit:
git add tools/console_mcp .mcp.json scripts/gate.shgit commit --file=<msg> # "feat(console-mcp): local stdio MCP: sessions/status/send_command/telemetry_query with the shared guard"Task 4: ab_test
Section titled “Task 4: ab_test”Files:
- Modify:
tools/console_mcp/lib/ab_test.mjs(replaces the stub) - Create:
tools/console_mcp/test/ab_test.test.mjs
Interfaces:
-
Consumes:
consoleClient(Task 3);humanState,sendCommandfromlib/tools.mjs. -
Produces:
abTest(client, {code, toggle, rounds = 3, seconds_per_side = 30, override = false, reason = ''}, deps) -> {ok, a, b, delta, conclusive, scene_drift, restored, order, reason?}aandbare each{value: boolean, mean, sd, n};restoredis{toggle: boolean, qm_auto: boolean}.
summarize(samples: number[]) -> {mean, sd, n}.
-
Step 1: Write the failing tests in
tools/console_mcp/test/ab_test.test.mjs:
// ab_test (spec 2026-10-08-console-mcp-design.md §ab_test): ABBA rounds of averaged fps, always// restores the toggle and qm.auto, refuses while a human plays.import { test } from 'node:test';import assert from 'node:assert/strict';import { abTest, summarize } from '../lib/ab_test.mjs';
// A fake device: fps depends on the toggle's live value; status ts advances 1 s per read.function device({ fpsOn = 40, fpsOff = 55, bloom = true, auto = true, age = -1, failOnCmd = -1 } = {}) { const st = { bloom, auto, t: 1_000_000, n: 0 }; const log = []; return { log, st, async watch() { return { status: 200, body: { ok: true } }; }, async status() { st.t += 1000; return { status: 200, body: { _ts: st.t, run_time: 100, fps: st.bloom ? fpsOn : fpsOff, human: { input_age_ms: age, autopilot: false }, qm: { auto: st.auto, tier: 0 }, flags: { 'gfx:bloom': st.bloom }, alive: { swarmer: 40 }, perf: { funzones: 0, webs: 0 } } }; }, async cmd(code, cmd) { log.push(cmd); if (++st.n === failOnCmd) throw new Error('network down'); if (cmd.kind === 'graphics') { st.bloom = cmd.on; st.auto = false; } if (cmd.kind === 'qm_auto') st.auto = cmd.on; return { status: 200, body: { ok: true } }; }, };}const deps = (d) => ({ now: () => d.st.t, sleep: async (ms) => { d.st.t += ms; }, log: () => {} });
test('summarize: mean, sample sd, n', () => { const s = summarize([10, 12, 14]); assert.equal(s.mean, 12); assert.equal(s.n, 3); assert.ok(Math.abs(s.sd - 2) < 1e-9);});
test('runs ABBA per round, measures both sides, and restores toggle + qm.auto', async () => { const d = device(); const r = await abTest(d, { code: 'C', toggle: 'bloom', rounds: 3, seconds_per_side: 5 }, deps(d)); assert.equal(r.ok, true); const gfx = d.log.filter((c) => c.kind === 'graphics').map((c) => c.on); // A=true (live), B=false. Per round A,B,B,A, then the final restore to A. assert.deepEqual(gfx.slice(0, 12), [true, false, false, true, true, false, false, true, true, false, false, true]); assert.equal(gfx.at(-1), true); assert.equal(r.a.value, true); assert.equal(r.b.value, false); assert.equal(Math.round(r.a.mean), 40); assert.equal(Math.round(r.b.mean), 55); assert.equal(r.conclusive, true); assert.deepEqual(r.restored, { toggle: true, qm_auto: true }); assert.ok(d.log.some((c) => c.kind === 'qm_auto' && c.on === true)); assert.equal(d.st.auto, true);});
test('restores even when a command fails mid-test', async () => { const d = device({ failOnCmd: 3 }); const r = await abTest(d, { code: 'C', toggle: 'bloom', rounds: 3, seconds_per_side: 5 }, deps(d)); assert.equal(r.ok, false); assert.match(r.reason, /network down/); assert.equal(d.st.bloom, true, 'toggle back to its live default'); assert.equal(d.st.auto, true, 'qm.auto back on');});
test('refuses while a human plays, below 3 rounds, or for an unknown toggle', async () => { let d = device({ age: 4000 }); assert.equal((await abTest(d, { code: 'C', toggle: 'bloom' }, deps(d))).reason, 'human_playing'); assert.equal(d.log.length, 0); d = device(); assert.match((await abTest(d, { code: 'C', toggle: 'bloom', rounds: 2 }, deps(d))).reason, /rounds/); assert.match((await abTest(d, { code: 'C', toggle: 'nope' }, deps(d))).reason, /unknown toggle/); assert.equal(d.log.length, 0);});
test('equal fps on both sides is reported inconclusive', async () => { const d = device({ fpsOn: 50, fpsOff: 50 }); const r = await abTest(d, { code: 'C', toggle: 'bloom', rounds: 3, seconds_per_side: 5 }, deps(d)); assert.equal(r.conclusive, false);});-
Step 2: Run the tests and confirm they fail. Run:
cd tools/console_mcp && node --test test/ab_test.test.mjs. Expected: FAIL (summarizenot exported). -
Step 3: Implement
lib/ab_test.mjs:
// Device perf A/B for one graphics toggle (spec 2026-10-08-console-mcp-design.md §ab_test).// status fps is ONE instantaneous sample swinging ±20 (2026-10-08), so: watched 1 Hz sampling,// ≥3 ABBA rounds, mean ± sd per side, and an explicit inconclusive verdict.import { humanState } from './tools.mjs';
const SETTLE_MS = 3000;const WATCH_EVERY_MS = 20000;
export function summarize(xs) { const n = xs.length; const mean = n ? xs.reduce((a, b) => a + b, 0) / n : 0; const sd = n > 1 ? Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)) : 0; return { mean, sd, n };}
export async function abTest(client, { code, toggle, rounds = 3, seconds_per_side = 30, override = false, reason = '' }, deps) { if (!(rounds >= 3)) return { ok: false, reason: 'rounds must be >= 3' }; if (override === true && !String(reason).trim()) return { ok: false, reason: 'override needs a reason' }; await client.watch([code]); let lastWatch = deps.now(); const keepWatched = async () => { if (deps.now() - lastWatch >= WATCH_EVERY_MS) { await client.watch([code]); lastWatch = deps.now(); } }; const s0 = (await client.status(code)).body; const h = humanState(s0, s0?._ts, deps.now()); if (h.playing === null) return { ok: false, reason: 'status_unknown' }; if (h.playing && override !== true) return { ok: false, reason: 'human_playing', input_age_ms: h.input_age_ms }; if (!s0.run_time) return { ok: false, reason: 'device is not in a run' }; const fk = 'gfx:' + toggle; if (!s0.flags || typeof s0.flags[fk] !== 'boolean') return { ok: false, reason: `unknown toggle ${toggle} (no live ${fk} in status.flags)` }; const A = s0.flags[fk], B = !A, qmAuto0 = !!s0.qm?.auto; const set = (on) => client.cmd(code, { kind: 'graphics', effect: toggle, on }, override === true ? { override: true } : {}); const samples = { a: [], b: [] }; const order = []; let first = null, last = null, error = null; try { for (let r = 0; r < rounds; r++) { for (const side of ['a', 'b', 'b', 'a']) { order.push(side); await set(side === 'a' ? A : B); await deps.sleep(SETTLE_MS); let lastTs = -1; const until = deps.now() + seconds_per_side * 1000; while (deps.now() < until) { await keepWatched(); const s = (await client.status(code)).body; if (s._ts !== lastTs && Number.isFinite(s.fps)) { samples[side].push(s.fps); lastTs = s._ts; first ??= s; last = s; } await deps.sleep(1000); } } } } catch (e) { error = e; } finally { // Restore ALWAYS: the toggle to its live default, then qm.auto (the graphics press turned it off). try { await set(A); } catch { /* reported via restored below */ } if (qmAuto0) { try { await client.cmd(code, { kind: 'qm_auto', on: true }); } catch { /* reported below */ } } } await deps.sleep(1500); const s1 = (await client.status(code)).body; const restored = { toggle: s1.flags?.[fk] === A, qm_auto: !!s1.qm?.auto === qmAuto0 }; if (error) return { ok: false, reason: String(error.message || error), restored }; const a = { value: A, ...summarize(samples.a) }, b = { value: B, ...summarize(samples.b) }; const conclusive = Math.abs(a.mean - b.mean) > a.sd + b.sd; const size = (s) => Object.values(s?.alive || {}).reduce((x, y) => x + y, 0) + (s?.perf?.funzones || 0) + (s?.perf?.webs || 0); const drift = first && last && Math.max(size(first), 1) ? Math.abs(size(last) - size(first)) / Math.max(size(first), 1) : 0; return { ok: restored.toggle && restored.qm_auto, a, b, delta: b.mean - a.mean, conclusive, scene_drift: drift > 0.25 ? `scene size changed ${(drift * 100).toFixed(0)}% between first and last sample` : null, restored, order: order.join('') };}-
Step 4: Run the tests and confirm they pass. Run:
cd tools/console_mcp && node --test. Expected: all pass. If the ABBA assertion fails on the restore count, fix the code, not the test: the expected sequence is 12 round presses plus one restore. -
Step 5: Run the gate. Run:
GUT_LOG=$TMPDIR/bh-mcp-t4.log scripts/gate.sh. Expected: green, with the console_mcp tests listed in the tool-tests line. -
Step 6: Commit.
git add tools/console_mcp/lib/ab_test.mjs tools/console_mcp/test/ab_test.test.mjsgit commit --file=<msg> # "feat(console-mcp): ab_test: ≥3 ABBA rounds of watched 1 Hz fps, always restores toggle + qm.auto"Task 5: Docs, merge, deploy in order, live verification (controller, not a subagent)
Section titled “Task 5: Docs, merge, deploy in order, live verification (controller, not a subagent)”Files:
-
Modify:
docs/architecture/dev-tools.md(new### Console MCP (agents)section under the Remote Console) -
Modify:
CLAUDE.md, one line in the table row for “The Remote Playtest Console + dev commands”: “Agents drive it through thedark-cosmos-consoleMCP (tools/console_mcp/), never raw curl; run-hijacking commands are refused while a human is playing.” -
Step 1: Write the dev-tools.md section. Cover:
- the tools;
- the guard rule (with the safe list’s source of truth,
control/src/guard.js); overrideetiquette;- the
ab_testreading guide:conclusive:falsemeans noise, not “no effect”, so rerun with a longerseconds_per_side;scene_driftmeans the scene changed under the test.
Give the section Starlight-compatible headings; the file already has frontmatter.
-
Step 2: Merge ownership. Run
ListAgents; SendMessage the other bullet-heaven sessions to claim the merge; merge via skillbh-merge-reviewed-branchafter abh-reviewerMERGE verdict;git push origin main. -
Step 3: Deploy step 1/3 — game build. Run
scripts/bump-build.sh, first looping it past the highest BUILD on live branches (git show <branch>:sim/constants.gd). This change is not player-visible, so it needs no What’s new item. -
Step 4: Deploy step 2/3 — Dev install. Use
scripts/dev/install-dev.sh(orbh-worktree-device-install) onto the Apple TV, plus any device Chris names. Confirm withstatusthathuman,perfandqmappear. -
Step 5: Deploy step 3/3 — Worker. Deploy with the
control/README.md§ Deploy commands. This also ships the pending FZ_TILE_PX What’s new from main. Verify a guardedsend_commandto a device Chris is actively using returnshuman_playing. Ask Chris to hold a stick for 5 s first, then confirm it passes once input has been idle for more than 120 s. -
Step 6: Live
ab_test. Run a known no-op toggle (juice_fxin a quiet scene): expectconclusive:false. Then runzonesin a FunZo scene (setup:invuln+damage_mult0 +pause_spawns, sent only when no human is playing): expect conclusive. Confirmrestoredis all true andstatus.qm.automatches the pre-test value. Undo the setup commands afterwards. -
Step 7: Close out. Update the memory
bullet-heaven-funzo-zone-slowdown, moving the 📍 to the web grid. Mark the residual ~45 fps FunZo question as now answerable withab_test.
Execution handoff
Section titled “Execution handoff”Written 2026-10-08 at the end of the planning session, for a fresh session with no conversation context.
Chris’s decisions (all recorded in the spec too):
- The MCP is local stdio, not a remote
/mcpon the Worker. - The guard lives in both the MCP and the Worker.
- The guard is an allowlist; every other kind is guarded.
- A human is playing if their last input was ≤ 120 s ago.
ab_testis part of the MCP, not a separate skill.
He approved the design (“yes”), then the written spec (“yes”), which included the added qm_auto command.
Rejected alternatives:
- A remote MCP on the Worker (cloud agents can’t use the local MCP; accepted).
- An MCP-only guard (raw curl bypasses it).
- A game-side guard (needs an install before it protects anything).
- A denylist (new commands would pass unguarded).
- A 60 s or whole-run window.
Done already: the spec is committed and pushed (0e34554a), and this plan is committed with it. No code, branch, deploy or install yet.
Authorized: implementing on a worktree branch, then merging to main through the normal reviewer gate and merge-ownership protocol.
Needs Chris’s same-day OK at the time:
- the Dev install on his devices (check nobody is mid-run);
- the console Worker deploy (production; must come AFTER the install);
- any
overrideuse; - the live FunZo
ab_testsetup commands.
Planning gotchas:
InputRouteris re-created per run, which is why the stamp lives onmain._seed_graphics_statefreezes a tier snapshot, which is whygraphics_reportreads without seeding.console_graphicsflipsqm.autooff one-way, which is whyqm_autoexists.- Existing
control/test/live-sessions.test.mjs/cmdposts would 409 withoutsource:'panel'.
Open: the deployed Worker URL for server.mjs (Task 3 Step 7 says where to read it).
Next action: run bh-sdd-concurrent, then dispatch Task 1 subagent-driven, using bh-implementer with model opus in its own worktree off origin/main.