Drive a classic Mac (System 7.0-7.5) from a modern host — both real 68k/PPC hardware and emulators, initially targeting your existing MAME setup — for screen viewing, file transfer, scripting classic apps via Apple Events, and (later) reboot / power management. Workspace at /Users/nick/code/mac-control is empty; greenfield.
Surface the whole thing as an MCP server so an AI agent can drive it, with classic-Mac reference docs (Inside Macintosh, Apple Event registries) delivered through a separate, existing MCP docs server you already run — treated here as an assumed dependency, not something we build.
Load-bearing constraint: Remote Apple Events over TCP (eppc://, port 3031) was introduced in Mac OS 9, not System 7. In 7.x, remote Apple Events ride PPC Toolbox over AppleTalk; no mature Linux/macOS library speaks that. Scripting from the host therefore requires a small on-guest bridge that listens on TCP and dispatches Apple Events locally.
Two pieces:
mc-agent— a custom native classic-Mac application we build ourselves, cross-compiled from a modern host using Retro68 (GCC-based, targets 68k and PowerPC classic Mac OS). Background-only faceless app. Listens on a TCP port, speaks a small framed-JSON protocol, dispatches Apple Events locally via the Apple Event Manager, manages files (data + resource forks), captures screen contents via QuickDraw, synthesizes input via posted events plus interrupt-time cursor/button-state updates, runs jobs. Shipped as two separate binaries —mc-agent-68kandmc-agent-ppc— not a fat binary; a Mac Plus with 4 MB RAM and a full hard disk shouldn't pay for PowerPC code it can't run. No MiniVNC, no RFB server, no second binary at all on the guest — everything the controller needs is in one place.mac-controld— modern-host daemon in Rust, single static binary. Speaks framed JSON over TCP tomc-agentfor everything on the guest — script, files, screen capture, input. Also handles subprocess IPC for emulators and (later) smart-plug HTTP for power. Exposes MCP tools; relies on your existing docs MCP server for reference material rather than embedding its own.
Everything rides one TCP link. No AFP, no AppleShare, no FTP, no Netatalk sidecar. Files are chunked over the mc-agent JSON protocol in either direction — small transfers inline, large transfers as a stream of framed chunks. This means the guest needs exactly one thing: a TCP stack (MacTCP or Open Transport). AppleTalk-only guests are out of scope.
┌─ modern host ────────────────────────────────────────┐
│ ┌─ MCP: mac-control ──────┐ ┌─ MCP: docs (yours) ┐ │
│ │ screen.* fs.* │ │ inside-mac / aete │ │
│ │ applescript.* app.* │ │ reference │ │
│ │ dialog.* clipboard.* │ │ (assumed dep) │ │
│ │ menu.* process.* │ └────────────────────┘ │
│ │ volume.* log.* │ │
│ │ power.status ... │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌─ mac-controld (Rust) ────────────────────────┐ │
│ │ target registry → transport → drivers │ │
│ │ ┌─mc-agent-client─┐ ┌─emu─┐ ┌─power─┐ │ │
│ │ │ screen input │ │MAME │ │ later │ │ │
│ │ │ script fs │ │IPC │ │ │ │ │
│ │ └────────┬────────┘ └──┬──┘ └───┬───┘ │ │
│ └───────────┼─────────────┼────────┼───────────┘ │
└──────────────┼─────────────┼────────┼────────────────┘
│ mc-agent │ emu │
│ TCP 4780 │ IPC │ (plug: later)
▼ ▼ ▼
┌─ classic Mac (System 7.0-7.5) ───────────────────────┐
│ ┌─ mc-agent ──────────┐ │
│ │ native app │ │
│ │ (Retro68, C) │ │
│ │ ─ TCP + JSON │ │
│ │ ─ Apple Events │ │
│ │ ─ QuickDraw capture│ │
│ │ ─ Input synth │ │
│ │ ─ Files (chunked) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ Finder + [app list TBD after evaluation] │
└──────────────────────────────────────────────────────┘
The MCP tool list above is the aggregate v1 surface. power is power.status only; reboot / hard power off are deferred to M5.
What it is. A background-only classic Mac app (SIZE resource with onlyBackground set; never calls InitWindows, displays no windows or menu bar, not listed in the Application menu), built with Retro68 on the host as two separate binaries — mc-agent-68k (68k, tested down to 68000 / Mac Plus) and mc-agent-ppc (PowerPC, tested on 601). One INIT-style install: the installer applet detects the CPU via Gestalt(gestaltSysArchitecture) (68k vs PPC), falling back to gestaltProcessorType / SysEnvirons on 7.0/7.1 where newer selectors are absent (gestaltNativeCPUtype is not guaranteed pre-7.5), drops the matching binary into System Folder:Startup Items:, and deletes the other. No MacHTTP, no CGIs, no scripting-additions dance.
Keep it small. No hard byte ceiling, but hold the line on bloat — the target audience includes 4 MB Mac Pluses. Concretely: written in C, not C++ (no libstdc++, no RTTI, no exceptions, no iostreams); no dynamic linking beyond the System; hand-rolled minimal JSON parser rather than a full serialization library; no debug symbols in the shipped .bin. Track binary sizes in CI as a reported metric so regressions are visible, but don't gate merges on a specific number. New methods have to earn their footprint — features that only help niche workflows can be optional or omitted.
Requirements on the guest: System 7.0 or later with any TCP stack — MacTCP or Open Transport. Not OT-only. The agent uses a small internal socket shim (BSD-ish surface over MacTCP's Device-Manager API on one side, OT's endpoint API on the other; a couple hundred lines each) so the same source builds work on 7.0/7.1 (MacTCP) through 7.5.3+ (OT). AppleScript is optional — apple_event.send works without it; applescript.eval needs AppleScript installed (ships in 7.5, add-on for 7.0/7.1).
Transport. Persistent TCP connection, length-prefixed frames. Every frame has a 5-byte header: 1 byte type + 4 bytes big-endian length, then payload.
- Type
0x01= JSON. Request{id, method, params}, response{id, ok, result | error}, or server-initiated{event, payload}notification. UTF-8. - Type
0x02= Binary attachment. First 4 bytes of the payload are the request/responseid(big-endian), remainder is raw bytes. Correlates to a JSON response that has{"$binary": true, "size": N}in the value's place — the caller reads the binary frame that follows on the same connection and substitutes it in.
This means a PICT screenshot ships as (a) small JSON envelope + (b) one raw binary frame. No base64. mc-agent writes OpenPicture/CopyBits/ClosePicture output straight to the socket after the header — the classic Mac never touches every byte.
Bearer token in the first hello message. Single-client only in v1 — a second inbound connection is refused with a busy error until the first disconnects. Classic Mac cooperative scheduling makes concurrent-client semantics a footgun (two clients racing on focus, input synthesis, jobs); revisit if a real use case appears.
Notifications vs responses. Response frames carry the request id. Server-initiated notifications carry event instead of id — that's how the client distinguishes them. Binary frames (type 0x02) always carry the correlating response id in their header; there are no unsolicited binary frames.
Jobs. Methods that can exceed the per-handler time budget return {job_id, status:"running"} synchronously; results arrive later as job.done notifications (payload = the eventual result envelope) or, if the caller subscribed, job.progress events during execution. jobs.status {job_id} polls; jobs.cancel {job_id} requests a clean stop (the job checks a cancel flag at each yield). One job per method call; no job dependencies or fan-out in v1.
Methods (minimum useful set — grows with milestones):
| Method | Purpose |
|---|---|
hello |
{token, client_id, protocol_version} → {server_version, sys_version, machine, cpu, ram, update_state:"clean"|"staged", last_crash_breadcrumb?} |
health |
cheap poll: {uptime, free_mem, last_activity_at} — for heartbeat / power.status. Deeper introspection is on system.info and process.list |
applescript.eval |
{source, timeout_ms} → {ok, result, error:{number, message, line}}; uses OSA to compile+run |
apple_event.send |
raw AE dispatch: `{target:{sig |
fs.list fs.stat fs.get fs.put fs.delete fs.move |
files with resource-fork awareness; fs.get can return data fork, resource fork, or MacBinary III |
screen.capture |
{region?:{x,y,w,h}, depth?} → PICT of the full screen (equivalent to Cmd-Shift-3) or a sub-rectangle, via CopyBits from the main screen device (raw bytes framed on the wire). PICT only — cheap to produce with OpenPicture/CopyBits/ClosePicture, no encoder library in the agent. The agent ships pixels only: window, dialog, and menu semantics live on the controller (OCR + vision). Capture always shows what's on screen; to see an occluded window's true contents, bring its app forward with process.front first and yield for the redraw |
screen.input |
{events:[{type:"click"|"drag"|"key"|"type", x?, y?, path?, keys?, modifiers?, ...}]} in screen coordinates — compiled in the main loop into a pre-built low-level script, then executed step-by-step by an interrupt-time task: cursor moves via the Cursor Device Manager (CrsrDevMoveTo, trap $AADB) where present, else the low-mem globals RawMouse/MTemp/CrsrNew ($82C/$828/$8CE); button state via MBState ($172); keystrokes via PostEvent. Interrupt-time execution is what lets gestures continue while the front app runs a tight tracking loop (menus, drags). Fire-and-report — the controller verifies effects via screen.capture |
clipboard.get |
{flavors:["TEXT","PICT","styl",...]} → {flavor, bytes} for the first available. TEXT for plain text, PICT for images, styl for styled text runs. Reads via Scrap Manager. Staleness caveat: apps with a private scrap only publish to the global scrap on suspend — data copied in the front app may not be visible until that app is suspended; the controller can force this by bouncing focus via process.front |
clipboard.set |
{flavor, bytes} → puts data on the scrap; multiple flavors settable in one call. Same coherency caveat in reverse: the front app only reads the new scrap on a resume-with-convert, so set the scrap while the target is in the background, then bring it forward |
process.list |
running processes: [{psn, name, creator, type, launched_at, mem_size, mem_used, front:bool, background_only:bool}] via GetNextProcess + GetProcessInformation |
process.launch |
{path?, creator?, args?} → PSN of launched app. Path via LaunchApplication; creator resolves through Desktop Database |
process.front |
{psn} → SetFrontProcess |
process.quit |
{psn} → send Quit Apple Event. If the app raises a save dialog, the controller resolves it through its dialog tools (capture + OCR + screen.input) — the agent doesn't parse dialogs |
volume.list |
mounted volumes: [{vref, name, fs, total_bytes, free_bytes, ejectable, startup:bool}] |
cursor.get |
{x, y, busy} where busy = true when a SetCursor(*GetCursor(watchCursor)) or SpinCursor-style busy indicator is showing; strong signal that Apple Events won't answer |
system.time |
current guest date/time + timezone offset. Optional {set: iso8601} param to update the clock |
log.tail |
{n?:100, level?:"info", categories?:[...]} → recent log lines from the in-memory ring buffer |
log.download |
full log file as a binary frame (uses type-0x02) |
log.set_level |
{level:"error"|"warn"|"info"|"debug"|"trace"} → live-change without restart |
system.info |
richer system query: OS version, machine, CPU, physical RAM, free RAM, current user, timezone |
update.stage |
{size, crc32, version} + chunked type-0x02 frames (MacBinary III, both forks) → writes a staged copy of the new agent binary next to the Preferences file; CRC-verified after landing. Never touches the running binary |
update.apply |
launch the staged binary via LaunchApplication, then close the listener and quit. The new instance binds the port with the existing retry-with-backoff (same path as the MacTCP rebind window); its hello reports the new server_version and update_state:"staged" |
update.commit |
the now-running staged instance replaces the Startup Items binary with itself: delete the old file (its owner already quit — deleting open files is what fBsyErr forbids), then CatMove + Rename its own open file into place (renaming open files is explicitly legal — "access paths currently in use aren't affected"). If CatMove balks on the open file, fall back to copying own forks to the destination and deleting the staged copy on next launch. update_state goes clean |
update.abort |
delete the staged file |
jobs.status jobs.cancel |
manage long-running operations |
subscribe unsubscribe |
opt into event stream for a channel (e.g. jobs.*, log.line) |
Concurrency model. Cooperative — classic Mac has no preemptive threads for our purposes. The agent uses the standard event loop with WaitNextEvent, MacTCP/OT async completion, and Apple Event dispatch. Long-running work (big file transfers, deep folder walks, raise-and-redraw yields before capture) is chunked across the loop and reported as a job.
Hang-avoidance discipline (mc-agent must not take the system down). The agent runs cooperatively alongside every other app, so a stuck handler stalls the whole machine. Rules the code must follow:
- No blocking Toolbox calls without an explicit timeout. In particular
AESendalways passes a short timeout (default 5 s, caller-overridable via methodtimeout_ms), neverkAEDefaultTimeout(which is ~60 s). File I/O uses asyncPBRead/PBWritevariants for anything over ~4 KB. MacTCP/OT reads and writes are always async with completion. - Per-handler time budget. No single request handler runs more than ~50 ms of straight-line work before either completing or yielding via
WaitNextEvent. Anything longer becomes a job — the handler returns{job_id}synchronously and the work continues chunked in subsequent event-loop turns. - Chunk everything that could grow.
fs.liston a 10,000-entry folder, PICT capture of a 1024×768 screen — all iterate in bounded chunks withWaitNextEventbetween chunks. Better a stream of 20 ms slices than one 500 ms handler. - The input-executor task must return in microseconds. It runs at interrupt time (Time Manager / VBL); each firing only reads the next step from the pre-built script, writes the cursor/button globals (or calls
CrsrDevMoveTo), posts at most one event, and returns. All parsing and gesture planning happens in the main loop before execution starts. No memory allocation, no QuickDraw, no file I/O at interrupt time. - Watchdog via Time Manager task. A Time Manager task (runs at interrupt time, immune to main-loop stalls) fires every second and checks a heartbeat counter incremented by the main event loop. If the counter hasn't moved in 3 ticks: append
"STALLED in handler X for N ticks"to an in-memory ring buffer that's safe to write from interrupt context (no file I/O allowed at interrupt time). The main loop's next iteration — if it ever gets one — flushes the ring buffer to the log file. If configured, the watchdog also writes the line to the modem serial port at interrupt time (SCC async control call is interrupt-safe) so an external observer (host, MAME serial pipe) captures it even when nothing on the guest can. It can't recover the stall — but recovery isn't the goal; observability is. - AppleScript timeouts.
applescript.evalwraps the script in awith timeout of N seconds ... end timeoutblock so long-runningtellblocks against a hung target don't hang the OSA runtime. - No unbounded loops in the agent, ever. All loops have a max-iteration guard tied to elapsed time. If we hit it, log it and return an error rather than continuing.
Logging. Everything the agent does is logged — this is the primary tool for debugging and for building out functionality. The user shouldn't need a debugger to figure out why a method returned wrong.
- Log file at
System Folder:Preferences:mc-agent Log, plain text, MacRoman + CR line endings (native format), append-only, rotated at 256 KB (keepsmc-agent Log.1,.2; deletes older). Small enough to survive on a 4 MB Plus. - In-memory ring buffer of the most recent ~200 log lines, so
log.tailreturns instantly without disk I/O. - Format:
HH:MM:SS.mmm LEVEL category: message. Categories:transport,dispatch,applescript,apple_event,screen,input,clipboard,process,fs,update,job,watchdog. - Levels:
error,warn,info(default),debug,trace. Configurable in the Preferences file; alsolog.set_levelmethod for live changes. - What we log at
info: every request received (method, id, param summary), every response sent (id, ok/error, duration, result-size or first-error-line), every subscribed event emitted, watchdog stalls, startup/shutdown/reconfigure. - What we log at
debug: raw JSON payloads (truncated), Apple Event descriptors before send + after reply, input-script compilation and per-step execution timestamps, chunk boundaries for long jobs. log.tail {n?, level?, categories?}returns recent log lines;log.subscribestreams new lines aslog.lineevents;log.downloadreturns the full log file via a binary frame. All three exist so the controller can surface logs in an MCP resource for the agent to read without setting foot on the guest.- Crash breadcrumb via the log file (not low memory). Writing to random low-mem addresses on classic Mac is unsafe — only
ToolScratch/ApplScratch(8-12 bytes) are documented as app-usable and other apps may use them too, and a hard crash / reboot wipes RAM anyway. Instead: before any risky Toolbox call, write a single short"BEGIN op=X id=N"line to the log file with an explicitPBFlushFileSyncso it's on disk before the call runs. On successful return, write"END op=X id=N"(batched, no flush). On the nextmc-agentstartup, if the log file's lastBEGINhas no matchingEND, the previous run crashed inside that operation — surface aslast_crash_breadcrumbin thehelloresponse. Cost is one disk flush per risky call (milliseconds on an HD, tolerable); benefit is a durable record with no low-mem hacks.
Cross-process realities. Classic Mac OS has no memory protection, no preemptive scheduling, and no accessibility tree. Several capabilities look natural on paper but are constrained by the environment; the plan reflects these honestly:
- Toolbox UI state is per-process — the agent cannot see other apps' windows, dialogs, or menus. Under the Process Manager each process has its own window list and menu bar;
FrontWindow,SelectWindow,GetMenuBar,MenuKey, and the Dialog Manager all operate on the calling process's state, andmc-agent(background-only, never callsInitWindows) has none. Enumerating another app's windows, parsing its DITLs, or reading its menus would mean walking its heap through undocumented structures — rejected as flaky. Instead the agent ships pixels (screen.capture) and the controller derives windows/dialogs/menus via OCR and vision. The documented cross-app "raise" isSetFrontProcess(whole app layer, exposed asprocess.front); raising a specific window within an app is a synthesized click on it. - Silent capture of obscured windows is not possible cross-process. The offscreen-GWorld trick only works inside the drawing app's own process. Capture always shows the screen as-is; for an occluded window's true contents,
process.frontthe owning app, yield for the redraw, then capture. Disturbs z-order — there is no free lunch. - Synthesized input has a focus race. Posted events go to whichever process is frontmost at dispatch time, not to a specific window. "Click in window X" really means "raise X's app, yield, then inject." If focus shifts between raise and inject, events land in the wrong place. Best-effort; the controller verifies via
screen.capturewhen it matters. - Clipboard coherency is suspend/resume-based. Apps with a private scrap publish to the global scrap only when suspended, and read it back only on resume-with-convert.
clipboard.getright after a foreground Copy may be stale;clipboard.setisn't seen until the target resumes. The controller bounces focus viaprocess.frontto force conversion on both paths. - Standard File Package (Open/Save) is not scriptable in System 7. Neither Apple Events nor the Dialog Manager reach into it. When possible, drive apps via their own
open/saveApple Events (which take an FSSpec and bypass the picker) rather than trying to automate the file dialog itself. Where the dialog is unavoidable, OCR +screen.inputis the escape hatch. - Cooperative scheduling means one hung app hangs everyone, including
mc-agent. If a target app enters a tight loop without callingWaitNextEvent, our TCP socket goes silent along with everything else. No user-mode mitigation — recovery escalates to reboot control (M5). - No memory protection. A crash in any process can take
mc-agentwith it. Same mitigation as above. - MacTCP port rebind window. After the agent quits, its listen port may sit in a MacTCP TIME_WAIT-equivalent state for a while; a fast restart may fail to bind. Retry with backoff on startup.
- Screensaver / idle sleep return black frames. Bootstrap installer disables both by default (
Preferences:General ControlsandPreferences:Energy Saversettings);mc-agent helloreports back if either is still enabled so the controller can warn.
Dealing with blocking modal dialogs. A modal dialog in a target app (e.g. Disk Copy's "cannot read this disk" alert) will block that app's Apple Event dispatch until dismissed — applescript.eval against it will time out. mc-agent itself keeps running as a background app, so screen.capture and screen.input still work. Standard recovery loop for the controller: applescript.eval times out → screen.capture + OCR (the controller's dialog.list) detects a dialog on screen → read its text, pick a button, click it via screen.input (dialog.dismiss) → retry the original call. Detection is capture-polling by the controller — the agent has no way to observe another app's dialogs directly. System-modal alerts (bomb dialog, out-of-memory) can't be dismissed from user code — v1 escalates to manual intervention; M5 will add automated reboot.
Errors. Always a well-typed {error:{code, message, details?}}. Never a success-shaped empty response on failure — this echoes your global rule about network-call error handling.
Binary output. All binary payloads (PICT screenshots, file contents, MacBinary blobs) use the type-0x02 binary frame described above — never base64 inside JSON. mc-agent can therefore stream a large PICT or file directly from its source (Picture handle, file fork, GWorld PixMap) to the socket without ever allocating a copy or CPU-encoding it — critical on 4 MB machines where a 200 KB base64-of-a-PICT would eat significant RAM and seconds of 68030 time. Big transfers can be chunked by emitting several type-0x02 frames for the same id and letting the receiver concatenate — the response envelope's size field is the total.
Self-update over the wire. The first agent feature (M1): after the one-time bootstrap install per guest, every subsequent build ships over the existing TCP link — no more disk images, no manual setup loop. The one hard rule: never overwrite the running binary in place. On 68k the Segment Loader keeps CODE segments purgeable and re-reads them from the app file at runtime; on PPC with virtual memory enabled the application's data fork is the paging file for its code (file-mapped, assumed read-only by the OS). Overwriting either while running corrupts a live process. So updates are staged and two-phase:
update.stagestreams the new binary (chunked, CRC-checked) to a staging file — running binary untouched.update.applylaunches the staged copy and quits; the controller reconnects and smoke-tests the new instance (hello,health, a trivialapplescript.eval).- Only then does
update.commitpromote the staged file intoStartup Items, replacing the old binary.
Rollback for free: until commit, Startup Items still holds the old version — if the staged agent is broken or unreachable, a reboot (controller-driven for emulators; manual or M5 power control for real hardware) comes back up on the old agent, and the controller re-stages a fixed build. apply and commit are wrapped in the crash-breadcrumb BEGIN/END markers so a mid-swap crash is diagnosable from the next hello.
No TLS. Treat the link as trusted LAN. For remote access, tunnel through SSH or WireGuard on the host side.
Non-goals for v1. No RFB / live framebuffer streaming (screen is poll-based via screen.capture). No built-in AFP server. No shell / MPW dispatch (we cross-compile on the host with Retro68; nothing needs a build environment on the guest). No reboot / restart / shutdown control (surface power.status for detection only; recovery from hangs is out of scope until we have hardware or emulator control paths in a later milestone). No multi-client.
Rust, single static binary. Runs equally well on a laptop, NAS, or Raspberry Pi next to a real IIci.
Repo layout:
mac-control/
├── crates/ # Rust host side
│ ├── mc-core/ # Target, Transport, Capability, Driver traits
│ ├── mc-transport/ # tcp-direct, emu-ipc (MAME first)
│ ├── mc-agent-client/ # framed-JSON client; AppleScript + AE builders; screen, input, fs helpers
│ ├── mc-ocr/ # OCR of window/screen images; engine trait + Tesseract impl
│ ├── mc-power/ # emu-subproc now; smart-plug drivers deferred
│ ├── mc-discovery/ # ping, ARP, heartbeat poller
│ └── mc-mcp/ # MCP server; tool registration
├── bin/mac-controld/ # daemon; config in ~/.config/mac-control/targets.toml
├── agent/ # Retro68 C source for mc-agent
│ ├── src/ # shared C source
│ ├── rsrc/ # resource templates (SIZE, vers, BNDL, etc.)
│ ├── build-68k.sh # -> dist/mc-agent-68k.bin
│ └── build-ppc.sh # -> dist/mc-agent-ppc.bin
└── shared/protocol/ # JSON schema for the mc-agent wire protocol
# generates Rust types + C headers
The wire protocol lives in shared/protocol/ and drives codegen on both sides so the Rust client and the C agent can't drift.
Every Target picks a transport; drivers check transport.supports(capability) before registering their MCP tools, so tools 404 cleanly on capability-missing targets.
Tools (minimum useful set):
targets.list,targets.describescreen.capture(full screen or region),screen.click,screen.key,screen.type(backed bymc-agent screen.capture/screen.input)screen.ocr {image | region?}→[{text, bbox, confidence}]. Runs on the host (mc-ocrcrate), not on the guest — the classic Mac has no business running Tesseract. Includes bounding boxes so the agent can locate text spatially.screen.click_text {text, match:"exact"|"contains"|"regex", occurrence?, region?}→ capture, OCR, locate the matching text, click its center viascreen.input. Ergonomic single call for the AI agent — no round-trip to compute pixel coordinates.dialog.list,dialog.dismiss— controller-implemented on capture + OCR + input (the agent can't see other apps' dialogs). Buttons are located by OCR; the default button is identifiable visually by its bold rounded outline. First-class tools so the agent recovers from modal blockers without raw click math.clipboard.get,clipboard.set— Scrap Manager access; TEXT / PICT / styled text. The controller handles the suspend/resume scrap-coherency dance (focus bounce viaprocess.front).menu.select— controller-implemented: Cmd-key keystroke when the equivalent is known (from the app's aete via the docs server, or prior OCR), else a blind press-drag-release gesture over the menu using standard menu metrics, verified by a follow-up capture. There is deliberately nomenu.list— menu contents can't be enumerated cross-app in System 7, and an open menu can't be captured (the front app monopolizes the CPU while tracking); consult the docs server for an app's menus and cmd-keys.process.list,process.launch,process.front,process.quit— process managementvolume.list— mounted volumes with sizescursor.get— position + busy flagsystem.info,system.timefs.list,fs.get,fs.put(with optionaltype/creatorcodes),fs.deleteapplescript.eval,app.tell(sugar with a param builder)agent.update {target, dist_dir?}— orchestrates the full over-the-wire update: picksmc-agent-68kor-ppcfrom the target'scpu(reported inhello), then stage → apply → reconnect → smoke-test → commit. Any failure before commit leaves the guest on the old version and reports the phase that failed.power.status→up|down|unknown+last_heartbeat. Reboot / hard power off deferred to M5.discovery.scan— v1 is config-driven (targets listed intargets.toml); live subnet scan is a later milestone.log.tail,log.download,log.set_level— surfaces the guest's own log so the agent can debug from the outside
No embedded docs:// resources. The agent consults your existing MCP docs server for Inside Macintosh, aete registries, etc. mac-controld links to your docs server in its own README and points agents at it in tool descriptions.
Classic Mac has no accessibility tree — text on screen only exists as pixels. The controller needs OCR so the AI agent can read dialog text, list contents, status messages, and document text in apps without an aete.
Engine trait. mc-ocr::Engine with a default Tesseract implementation (via the tesseract Rust crate or subprocess to the tesseract CLI). A pluggable second implementation for Apple Vision (macOS hosts only) as an optional feature — significantly better quality on small bitmap fonts, but ties the controller to macOS. Vendor-neutral by default; users on a Mac can opt in.
Preprocessing. Classic Mac fonts (Chicago, Geneva, Monaco) at 9-12pt are unkind to generic OCR. Before invoking the engine: upscale 2-4x (nearest-neighbor to preserve pixel edges), binarize if not already 1-bit, and optionally invert for dark-mode themes (rare on System 7 but possible). Cache preprocessed images by source image hash + region so a re-OCR of the same window doesn't repeat the work.
Custom Tesseract training for classic Mac bitmap fonts is a known technique in the retro-computing community — worth investigating in M2 if generic Tesseract accuracy is poor. Ship a .traineddata file trained on Chicago/Geneva/Monaco 9/10/12pt bitmaps.
Each Target in targets.toml declares a transport; mac-controld picks the driver stack from that.
emu-mame— MAME driving the classic Mac. TCP networking confirmed working on the user's local setup, somc-agentinside the guest is reachable over TCP the same way it is on real hardware. Controller owns lifecycle (power.*= process control).tcp— real hardware with any TCP stack (MacTCP on 7.0/7.1, MacTCP or Open Transport on 7.5+). Full capability set throughmc-agent.
Same driver stack for both. AppleTalk-only guests are out of scope; if a user has one, they need to add MacTCP or upgrade to a system that has one.
mac-controld bootstrap-image produces mc-boot.dsk (raw HFS for emulators) or .img / .sit set (real hardware) containing:
mc-agent-68kandmc-agent-ppc— the two binaries plus aPreferencestemplate (listen port, token, log level).- A tiny
Install mc-agentapplet (ours, Retro68-built) that readsgestaltSysArchitecture(falling back togestaltProcessorType/SysEnvironson 7.0/7.1), copies the matchingmc-agent-*intoSystem Folder:Startup Items:and deletes the other from disk, writes the preferences file with a controller-supplied token, then quits.
Optional per-CPU images (mc-boot-68k.dsk / mc-boot-ppc.dsk) for the smallest possible download.
Delivery paths to get the disk image onto a fresh guest:
- Emulator: attach the
.dskas a second disk. - Real Mac with an FTP-capable browser (Fetch, Anarchie, MacTCP + any FTP client):
mac-controld serve-bootstrapruns a temporary HTTP/FTP server on the modern host; user fetches from the guest. - Real Mac with no network yet: floppy set (image splits into 1.4 MB chunks) or Zip cartridge / SCSI2SD / BlueSCSI image.
First-contact handshake: controller opens a TCP connection to mc-agent, sends hello with the pre-shared token, verifies the returned server_version and sys_version, and writes the target's fingerprint into targets.toml. Any hello failure aborts the handshake and reports the specific error — never silent success.
-
M1 — Toolchain +
mc-agentv0.1 + self-update. Retro68 installed and reproducibly producing bothmc-agent-68k.binandmc-agent-ppc.binfromagent/; CI reports binary sizes as a tracked metric. Establishshared/protocol/codegen. Agent implementshello,health, and the fullupdate.*cycle — self-update is deliberately the first feature, because it is the dev loop: one manual bootstrap install per guest, then every subsequent build ships over the wire withagent.update. That pulls the chunked type-0x02binary framing into M1 (update.stage needs it; screen and fs reuse it later). Once the update loop is proven, addapplescript.eval+apple_event.sendand wire the Rustmc-agent-clientand the MCP toolsapplescript.eval+app.tell— shipped to the guest via the update loop itself. Run against MAME. This is the "AI drives Finder" demo. -
M2 — Screen + input.
screen.capture(full screen + region) andscreen.input(posted keys + interrupt-time cursor/button script) inmc-agent; corresponding MCP tools wired. Host-sidemc-ocr,screen.click_text, and the controller-implementeddialog.list/dialog.dismiss/menu.selectland here too — the whole window/dialog/menu semantic layer is OCR on the controller, so screen without OCR isn't just less useful, it's incomplete. -
M3 — Files.
mc-agent'sfs.*methods, reusing the chunked binary framing built for self-update in M1. Resource-fork aware. -
M4 — Real hardware. Real IIci/Quadra (or your box of choice) with MacTCP or OT; bootstrap disk image + delivery path validated end-to-end; installer applet exercised. Run the M1-M3 flow against it.
-
M5 (deferred) — Reboot / power control. Soft restart via Finder AE, hard reboot via smart-plug (real hardware) or subprocess bounce (emulator). Deferred until we hit a real hang and need it.
- MAME screen/input scripting. MAME Lua can inject input and read the framebuffer, but for
mc-agent's in-guest capture path this doesn't matter — screen/input flow throughmc-agentover TCP, not through MAME. The MAME Lua path is only relevant as an out-of-band fallback (e.g. to unstick a hung guest); keep in mind but not on the critical path. - Retro68 toolchain. Cross-compiler is well-maintained but not zero-setup — need Docker or a documented native install path. Two separate binaries (68k, PPC) means two independent Retro68 pipeline runs — simpler than fat, but doubles CI matrix rows. Pin the Retro68 image version and reproduce builds in CI.
- MacTCP vs OT socket abstraction inside
mc-agent. The two APIs are genuinely different (MacTCP is Device-ManagerPBControlcalls, OT is BSD-ish endpoints). Our shim is a couple hundred lines each side but the abstractions leak — async completion semantics, notifier callbacks, and out-of-band data all differ. Budget time to get this right or the agent will hang on one stack while working on the other. - AppleScript missing on bare System 7.0/7.1. Ships standard from 7.5; earlier versions need AppleScript 1.1 from Scripting Additions.
mc-agentshould detect and exposeapplescript.evalcapability accordingly (rawapple_event.sendstill works). - Raise-then-capture depends on well-behaved apps. Bringing an app forward via
process.frontand yielding for the redraw before capturing works cleanly for any standard Toolbox app that redraws on update events (nearly all do). A game or custom-draw utility that lazily draws may return a partially-blank frame — capture again after a short delay, or accept the occluded on-screen state. - Input synthesis rests on de-facto-stable low-memory globals. The Event Manager journaling mechanism is not an option — it's documented only in the pre-System 7 Inside Mac volumes and was dropped without replacement from the System 7-era docs (built for the single-app world; doesn't function under the Process Manager). The working technique is the one remote-control software of the era used:
CrsrDevMoveTo(Cursor Device Manager, trap$AADB) where present, elseRawMouse/MTemp/CrsrNew, plusMBStatefor the button andPostEventfor keys. These globals are stable across System 7 in practice but documented only in third-party references — validate against Finder (plus whatever else is on the disk image) in MAME during M2, and beware races with real user input at the console. - Menus are press-and-hold in System 7 (sticky menus arrive in Mac OS 8), and
MenuSelecttracking is a tight loop in the front app — the agent gets no CPU mid-gesture, so menu drags must execute entirely at interrupt time and an open menu can never be captured or OCR'd. Menu items are selected blind (standard metrics or docs-server knowledge) and verified by capture afterward. - MacRoman↔UTF-8 and CR↔LF normalization. All strings crossing the wire need normalization at the
mc-agent-clienttransport layer. Common footgun even without MPW — file names, dialog text, and AppleScript source all come through in MacRoman. eppc://temptation. Not available pre-OS 9. Document prominently in the README.- Wire-protocol port assignment.
mc-agentneeds a stable non-clashing port. Suggest TCP/4780 (unassigned, memorable — "MAC" if you squint). Confirm before we ship the first bootstrap image.
- M1 acceptance:
agent/build-68k.shandagent/build-ppc.sheach produce a.bin; CI reports sizes as a tracked metric.mac-controld startlaunches MAME with a bundled disk image, opens a TCP connection tomc-agent, sendshello, receives a validserver_version. Update loop:agent.updateupgrades the running agent to a build with a bumpedserver_versionend-to-end in MAME, and a deliberately broken staged build (bad CRC, then a binary that crashes at startup) leaves the guest recoverable on the old version via reboot — this is the gate before any further agent features, since they all ship through it. Then: an MCP client callsapplescript.evalwithtell application "Finder" to make new folder at desktop with properties {name:"hello"}and the folder appears (verified visually in the emulator before M2; viascreen.captureafter M2; viafs.listafter M3). Exercise both the 68k and PPC binaries against appropriate MAME configurations. - M2 acceptance:
screen.capturereturns a decodable PICT of the full screen and of a sub-region (host decodes to PNG for verification);screen.inputselects "About This Macintosh" from the Apple menu via a blind interrupt-time press-drag-release gesture, verified by a follow-up capture; the controller's OCR-baseddialog.listdetects a Finder "Really empty Trash?" alert anddialog.dismiss {button:"cancel"}dismisses it;screen.click_text {text:"OK"}succeeds against a live dialog end-to-end. - M3 acceptance: round-trip a multi-megabyte file both directions with resource fork intact (compare MacBinary hashes host-side).
- M4 acceptance: run the M1-M3 flow against a real Mac on the LAN with
mc-agentinstalled via the bootstrap image, including one over-the-wire agent update. Exercise both a MacTCP-only guest (proves the socket shim) and an OT guest. - Automated CI: headless MAME in a container, exercised through the MCP client end-to-end.
mc-agentunit-tested via a host-run build with a mock Toolbox layer where feasible; wire protocol contract-tested against golden fixtures shared with the Rust client.
Existing docs MCP server— resolved: thedoc-searchserver already configured in this workspace's.mcp.json(nas.local:8080), with aclassic-maccategory of 308 docs (~182k chunks) including the Inside Macintosh volumes, Toolbox Essentials, Files, Processes, Imaging With QuickDraw, Interapplication Communication, PPC System Software, and era programming books. Gap: Apple Event Registry: Standard Suites itself is not indexed (IAC summarizes it); per-app aetes are read from the apps themselves viaapple_event.sendwhen needed. Worth adding the Registry PDF to the index if scripting work leans on standard-suite details.mc-agentlisten port — confirm TCP/4780 or specify a preference.
- Floor: System 7.0, 68000 hardware included.
- Guest TCP stack: MacTCP or Open Transport, via a small internal socket shim in
mc-agent. Not building our own IP stack. - Screen: in-guest QuickDraw capture (full screen / region only — no per-window capture; window/dialog/menu semantics are controller-side OCR), input via posted events + interrupt-time cursor/button script. No MiniVNC. Cross-process memory traversal of other apps' windows/DITLs/menus rejected as flaky; OCR on the controller is the fallback of record.
- Files: chunked over the
mc-agentTCP link, both directions. No AFP, no AppleShare, no FTP, no Netatalk sidecar. - AppleTalk-only guests: out of scope. Users must add MacTCP or upgrade.
- MAME networking: already proven working on the user's local setup; no spike needed.
- Binary strategy: two separate binaries (68k, PPC), installer picks by
Gestalt. Not fat. - Size discipline: C not C++, no libstdc++ / RTTI / exceptions, hand-rolled JSON, no debug symbols shipped. Tracked in CI as metric, not gated.
- Docs: external MCP docs server (yours), not embedded.
- Power:
power.status(detection) only in v1. Reboot / restart / hard power off deferred to M5. - MPW: dropped. We cross-compile with Retro68 on the host; no need for a guest build environment.
- Multi-client: single-client v1; second connection gets
busy. - Self-update: over-the-wire, staged two-phase (stage → apply → smoke-test → commit); never overwrite the running binary (68k Segment Loader re-reads CODE segments; PPC+VM file-maps the data fork). Old binary stays in Startup Items until commit — reboot is the rollback.