You are working on mac-control: remote control for classic Mac OS
(System 7+) from a modern host or LLM. Guest-side C runs under Retro68;
host-side Rust exposes an MCP surface so any MCP client can drive the
guest. Read README.md for the human-facing overview.
- Never yank media from a running guest. Ejecting a mounted disk /
quitting an app / hard-resetting a MAME VM without asking will bomb
the guest. Ask before
vmctl unmount,vmctl quit,vmctl reset, unmounting a shared host volume, or killing a guest process. See the user memoryfeedback_never_unmount_disksfor context. - Never auto-install the jGNE filter at startup.
agent/src/jgne_filter.cinstalls a system-wide hook at low-mem$29A. On-demand install viadiag.jgne installworks (validated 2026-08-02, cross-app keystroke injection to Finder confirmed via A5 self-skip). Auto-install duringmain()was re-attempted 2026-08-03 and immediately wedged the agent post-restart — root cause unknown. Callers install per-session when they need cross-app input. Seeproject_jgne_cross_app_workingin memory for the working technique. - Never route input synth through MAME / vmctl. mc-control has
to work on real classic Mac hardware, not just the emulator. See
feedback_no_mame_input_injectionin memory. - Never rename apps or types after vendors. Generic, capability- describing names only. This is a global user rule.
- Never skip pre-commit hooks or bypass signing. If a hook fails, fix the underlying issue.
- 68k agent binary target: ≤ ~80 KB. Currently ~78 KB. Every feature added is size + guest RAM. If a change pushes past this, either explain why or find something to drop.
- Guest is a Mac II with 8 MB RAM. MacTCP takes a chunk; usable
free memory is ~200-300 KB. Handles must be purgeable when idle;
the arena grows dynamically but caps at 128 KB. Watch
FreeMemin logs. - Wire framing caps: 64 KB JSON per frame, 128 KB binary per frame. Bigger transfers (like update.stage) are chunked at 16 KB.
Guest:
./agent/build-68k.sh
# Always via this script — it re-runs CMake configure to refresh the
# version stamp (YYYY.M.D.H.M.S). Raw `cmake --build agent/build-68k`
# uses a stale timestamp and breaks the update-verify step.Host:
cargo build --release # all crates + binaries
cargo test # includes mock-agent round-tripPush a rebuilt agent to a running guest (no reboot):
./target/release/mc-cli --host <guest-ip> update \
--binary agent/dist/mc-agent-68k.bin \
--version <stamp>
# --compress packbits saves ~1% on the compiled binary but is meaningful
# for text / resource payloads.Restart the MCP server after rebuilding mac-controld:
# No `claude mcp restart` command exists — remove + re-add, or start
# a new session. MCP clients don't hot-reload the server process.
claude mcp remove mac-control
claude mcp add --scope project mac-control \
./target/release/mac-controld -- --host <guest-ip>Agent:
agent/src/main.c— event loop + per-conn state machine + heartbeat.agent/src/transport_mactcp.c— MacTCP async I/O + notifier.agent/src/frame.[ch]— 5-byte framing codec.agent/src/json.c— arena-backed parser (see pitfalls).agent/src/arena.c— Handle-backed bump allocator;mc_arena_trimmatters (see pitfalls).agent/src/dispatch.c— method table + envelope builders.agent/src/methods_*.c— one file per method group.agent/src/packbits.c— streaming decoder for wire compression.agent/src/ae_desc.c— Apple Event JSON ↔ AEDesc.agent/src/screen_input.c— Time Manager tape for click/drag/type.agent/src/jgne_filter.c— research; do not auto-install.agent/src/update.c— stage / apply / commit self-update.
Host:
crates/mc-agent-client/— pure library, protocol types + sync client.crates/mc-pict/— classic PICT → PNG (headerless variant included).crates/mc-ocr/— macOS Vision OCR helper.crates/mc-mac-roman/— MacRoman ↔ UTF-8 codec.bin/mc-cli/— developer CLI; one verb per method plus composers (aete,aete-file,click-text,menu).bin/mac-controld/— MCP server;src/tools.rsis the tool registry.
Wire is defined by the code, not a spec doc: agent/src/methods_*.c
(guest) and crates/mc-agent-client/src/ (host types) are the source
of truth. Keep them in sync when reshaping a method.
- JSON parser arena bloat.
parse_string_rawpre-allocatedremainingbytes per string and used to leak the tail. Requests over ~170 bytes with many string fields silently corrupted intobad_params: missing 'method'. Fixed withmc_arena_triminagent/src/json.c— don't reintroduce. - PICT emitted by the agent has no 512-byte header.
sipson macOS misrenders it as all-white; usemc_pict::decode_to_png. PostEventis per-process. Under Process Manager (System 7+) each process has its own event queue.PostEventfrom mc-agent reaches ONLY mc-agent's queue. Cross-app input works via jGNE filter with A5 self-skip (see the jGNE ground rule above) — keystroke injection to Finder is verified. Menu-hold-open is not: mouseDown is delivered butMenuSelectreads button state from a source we haven't identified. Seeproject_menu_screenshot_statusin memory. For menu-driven work, prefer Apple Events.AEDescdata via Handle, notAEGetDescData. Multiversal headers predate the modern API. Readdesc->dataHandlewithHLock+GetHandleSizedirectly.- AETE format varies by era. Our parser (
crates/mc-agent-client/ src/aete.rs) handles System 7 / Retro68 output. Carbon-era AETEs (iTunes 4.9) have variant fields; parse fails partway through. - Time Manager tasks + code segments. Anything installed via
InsTimeMUST beRmvTime'd before mc-agent exits, else the next fire jumps into freed code and bombs whichever process is frontmost. Seemc_screen_input_shutdown. - Cursor / MBState low-mem restore. Same shutdown path resets
MBStateand clears modifier bits inKeyMap. Without this, an aborted tape can leave the OS thinking the button is held down system-wide. - AETE pstrings are packed, no padding. Earlier assumption of word-alignment was wrong for the AETEs we've tested.
- Design north-star:
plan.md - Backlog + gotchas:
todos.md - Wire:
agent/src/methods_*.c(guest) +crates/mc-agent-client/src/(host types). No standalone spec. - Per-project memory (accumulated context, load-bearing corrections):
~/.claude/projects/-Users-nick-code-mac-control/memory/MEMORY.md
- Guest unreachable? Try
mc-cli hello; checkmc-cli logs -n 40. - Made a bad push? The old binary is in Startup Items; a reboot recovers unless commit succeeded.
- Weird MacTCP
-23012(invalidBufPtr)? Usually async send buffer moved before completion; retry. - VM frozen after
jgne install? Hard reset; verify diag-only path before ever retrying.