Skip to content

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 consult bh-sdd-concurrent before Task 1, and dispatch every implementer into its own worktree (.claude/worktrees/<name>, branched off origin/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

  • 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 plus nebula_look/galaxy_look, which are render-only look selectors of the same kind as web_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) or source === "panel".
  • ab_test: rounds >= 3, ABBA order per round, 3 s settle per side, 1 Hz samples, /watch every 20 s, restore in finally.
  • telemetry_query: a single SELECT / WITH … SELECT statement 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.
  • /sim is 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)”
  1. The human timestamp lives on main, not on InputRouter. InputRouter is 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 rule InputRouter.is_human_input() stays on InputRouter, next to is_pad_activity().
  2. Graphics defaults are reported live, without seeding the tracked state. Seeding _graphics_state at 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_state is refactored onto the same pure graphics_from_qm(qm).
  3. GET /status adds _ts, the row’s server timestamp, so ab_test can drop the duplicate reads it gets when it polls faster than the game posts.
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 (after is_pad_activity, around line 74)
  • Modify: main.gd (_input at 3058; apply_dev_command before the if sim == null at ~3536; build_status at ~3718; _dev_flag_states at 3820)
  • Modify: ui/debug_settings_panel.gd (_seed_graphics_state at 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 every GRAPHICS_TOGGLES effect from the first status on;
    • the dev command {kind:"qm_auto", on:bool}.
  • Step 1: Write the failing is_human_input tests. Append to tests/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_input in input/input_router.gd, directly after is_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 false

joy_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 boot

Make 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_report in ui/debug_settings_panel.gd. Convert _seed_graphics_state so 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, replace var gfx := debug_settings.graphics_state() with var gfx := debug_settings.graphics_report(quality_manager).
    • In apply_dev_command, directly before if sim == null:, add:
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_states is safe with sim == null: it reads only statics and quality_manager. If it isn’t, leave flags out 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.
Terminal window
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.gd
git commit --file=<msg> # "feat(console): human/perf/qm status + qm_auto + live graphics report for the console MCP"

Files:

  • Create: control/src/guard.js, control/test/guard.test.mjs
  • Modify: control/src/worker.js (/cmd at line 101, GET /status at 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 /cmd posts 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}.

    blob is the parsed status blob or null; ts is the status row’s ms timestamp or null.

  • Produces: GET /status returns {...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. Add import { guardVerdict } from './guard.js';. In /cmd, after if (!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, add source: 'panel' to each /cmd post 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 /cmd body, update it to include source: 'panel'.

  • Step 7: Update the README. Add a row note to the /cmd table 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, unless override:true or source:'panel'”. Add a short ## Agent guard section that points at src/guard.js and the spec, and names the deploy-order rule: never deploy before the game build carrying human is on the devices.

  • Step 8: Commit.

Terminal window
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.md
git 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}, where null means 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.32 McpServer.registerTool signature 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:
Terminal window
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 2000

Expected: the initialize result and a tools/list result naming all 5 tools. Then confirm the key never appears on stdout:

Terminal window
... | 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, override etiquette (only on Chris’s explicit ask, with the reason quoting him), the CONSOLE_URL override, and running npm test. Commit:
Terminal window
git add tools/console_mcp .mcp.json scripts/gate.sh
git commit --file=<msg> # "feat(console-mcp): local stdio MCP: sessions/status/send_command/telemetry_query with the shared guard"

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, sendCommand from lib/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?}
      • a and b are each {value: boolean, mean, sd, n};
      • restored is {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 (summarize not 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.

Terminal window
git add tools/console_mcp/lib/ab_test.mjs tools/console_mcp/test/ab_test.test.mjs
git 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 the dark-cosmos-console MCP (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);
    • override etiquette;
    • the ab_test reading guide: conclusive:false means noise, not “no effect”, so rerun with a longer seconds_per_side; scene_drift means 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 skill bh-merge-reviewed-branch after a bh-reviewer MERGE 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 (or bh-worktree-device-install) onto the Apple TV, plus any device Chris names. Confirm with status that human, perf and qm appear.

  • 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 guarded send_command to a device Chris is actively using returns human_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_fx in a quiet scene): expect conclusive:false. Then run zones in a FunZo scene (setup: invuln + damage_mult 0 + pause_spawns, sent only when no human is playing): expect conclusive. Confirm restored is all true and status.qm.auto matches 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 with ab_test.


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 /mcp on 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_test is 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 override use;
  • the live FunZo ab_test setup commands.

Planning gotchas:

  • InputRouter is re-created per run, which is why the stamp lives on main.
  • _seed_graphics_state freezes a tier snapshot, which is why graphics_report reads without seeding.
  • console_graphics flips qm.auto off one-way, which is why qm_auto exists.
  • Existing control/test/live-sessions.test.mjs /cmd posts would 409 without source:'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.