Architecture¶
A note on "TimBotTu" and "the corpus." NOW grew out of a larger private project, TimBotTu, which maintains a corpus of validated findings about classic Mac hardware and platform behaviour — measured claims with their evidence, not lore. Documents here cite it where a number came from there. The corpus is not public and is not required to build, run or understand NOW; no TimBotTu runtime code is imported on any side, which is a boundary this project holds deliberately rather than an accident of packaging. Read a citation as provenance for a figure, and nothing more.
Product boundary¶
Two applications and exactly one connection between them: a single versioned contract over one multiplexed wire. No TimBotTu runtime imports, no general remote-control surface. One app on each side, polished and human-facing.
The PowerPC guest is the reference implementation (decided 2026-07-19): a Carbon app using the 8.6+ toolbox (CarbonLib 1.6+, Open Transport via runtime CFM resolution, Appearance). The 68K sibling that decision said "may exist someday" now does — NOW-68K, Retro68 68K, non-Carbon Toolbox C, metal-proven on a PowerBook 180c — and the constraint it was granted still holds: it serves a subset of the same contract additively, and it does not shape the PowerPC codebase. Who serves what is contract-coverage.md.
Wire¶
contract/asyncapi.yaml is the contract:
an 8-byte binary frame header (channel / flags / transfer / length)
multiplexing a JSON control channel and a raw bulk channel over one TCP
connection, written as AsyncAPI 3.0 with normative prose for the frame
layout and connection rules. WS-shaped semantics without literal
WebSocket: hello gate with revision refusal, guest-driven ping/pong,
bye with close codes, one connection per guest.
Several guests share one port. Each connection is one guest, told apart
by the identity in its hello; the host serves all of them but DRIVES
one — the "active" guest that every request-shaped call goes to, chosen
in the sidebar's pop-up or under Guest ▸ Drive. Each module model keeps
what it holds per machine or throws it away on a switch, deliberately
and for stated reasons (now-host/Sources/Host/GuestScopedState.swift).
The guest dials the host — classic Mac OS listeners are the fragile half of OS 9 networking, so every listener stays on the modern side.
The machine's own answer, in hello¶
hello carries an optional agent field: disabled / read-only /
full, an ordered enum so a higher tier can be added without
re-shaping the wire. It is the Macintosh's own statement about what an
agent companion may do to it, and it is the only place that statement
appears — the host does not offer a switch, because the machine being
driven is the one entitled to answer.
Three rules the contract states and the host obeys:
- Absence is a fact about the sender, not an answer. A guest that predates the field has said nothing, and silence must never be read as consent. It currently fails open, which is a dated decision (2026-07-30) matching today's default-on behaviour rather than a property of the design; it is the line that flips when an installer ships.
- An unrecognised token is not consent either. A receiver that cannot name a tier cannot claim to be under it, so everything is refused.
- Nothing else in
hellochanged. The field is additive, and the 68K guest sends none — see contract-coverage.md.
Two planes on the control channel¶
command.* is the typed spine: a caller that knows the command, a declared
output schema per verb, a closed registry. exec.* is the console plane: one
opaque line in, the guest's own console text out, nothing declared about what
a line may say. Neither replaces the other and the split is the point — see
remote-console.md.
Exec rides the control channel and never the bulk lane. The lane is one transfer wide across both directions, so a console session holding it would block capture and file transfer for its lifetime; and control frames queue-and-retry, which is what console text needs, because unlike pixels it cannot be re-derived.
Transfer rules (each learned the hard way)¶
- One contiguous send per frame. Real classic NICs drop the second of two back-to-back small writes (Farallon TX burst).
- Meter bulk writes; the gap is the point. The same card drops
frames inbound too, including ones that land on the heels of its own
ACK. Handing TCP a whole 32 KB frame keeps the socket buffer non-empty,
so TCP fires the next segment 0.13 ms after every ACK and it dies: 48%
of segments retransmitted, each costing a 311 ms RTO, pinning inbound
at 4 KB/s while outbound ran at 227. The host now writes 1448 B every
3 ms (
GuestListener.Pacing) — 256 KB went 63.9 s → 0.8 s. Note it is the quiet time, not the write size: 1448 B with no gap still runs at 5 KB/s. Protocol framing is untouched at 32 KB, because the guest reassembles a byte stream and must never be able to tell. - Control messages queue and retry. A streaming guest runs the send
buffer at the brim; a single unretried
OTSndofcapture.endor a heartbeat ping dies there ofkOTFlowErrand wedges every layer at once. Control frames drain from the event loop, never interleaving into a partially-sent bulk frame; bulk stays best-effort — pixels are re-capturable, protocol words are not. - Abandon transfers, never frames. An abort drains the in-flight
bulk frame to its boundary before
capture.end ok:false; cutting a frame mid-send desyncs the peer's decoder. - One transfer at a time. Requests and offers are refused busy, not preempted. The stream bracket owns the lane while open.
- Stops are always answered.
stream.stopgetsstream.stoppedeven for a stream the guest no longer has; the host also self-heals (session close clears the bracket, stop times out after 5 s). - Tuning rides the messages.
capture.requestandstream.startcarry optional knobs (chunk, pacing, compress, predictive, interlace); absent fields fall back to the guest's panel. The initiator decides; there is no remote configuration to sync. - Only the receiver knows what arrived. A sender's own completion
says its socket accepted the bytes, which on this link runs minutes
ahead of delivery: the put bar reached 100% with a third of a 2.7 MB
file written, and the same false signal fed the inactivity watchdog,
so a stalled guest read as healthy. Progress therefore travels back
from the receiver (
file.progress), and a watchdog is fed by nothing else. Advisory by design — dropped rather than queued when the control queue is busy, so its absence means an old peer, not a stalled one.
The console is a dumb shell¶
The host console does not know what commands the guest has, and must not. There are two guests and they serve different verb sets, neither of them closed (contract-coverage.md has the roster, derived). A command list on the host would be wrong for one of them and wrong again the next time either grows a verb — and wrong quietly, because a command the guest had and the console did not was refused locally, without ever reaching the wire.
So the line a human types is relayed as typed. command.request carries
either shape:
args— typed, for a caller that knows the command: a host module, an agent, a test.line— the raw text after the command name, for a console. Presence is the signal and""is present, sogestalttyped by a person (snapshot) is a different request fromgestaltcalled by a module (every group). Each command's grammar is stated once in the contract asx-lineand implemented once, by the machine that serves the verb —now-guest-ppc/src/commands/cmd_line.cfor the Carbon guest.
command.request has only ever run host to guest: the guest's console
runs its own commands in-process, and the host serves none. So line
adds no asymmetry — the other direction does not exist. If it ever does,
the field means the same thing there.
Where the line runs. Host-side there are four verbs, all behind a /
prefix so a bare word is always the far machine's and a command added to
either guest can never be shadowed. Three act on this console: /clear,
/save (write the scrollback to a file — the command-agnostic
replacement for gestalt --save, which only worked because the host knew
what gestalt returned) and /help. The fourth, /swpage, drives the
software.list family, which is a wire family the host implements rather
than a command any guest serves. That is the whole test for belonging
here: no guest could answer it. Everything else, including a typo,
crosses the wire and comes back answered — unknown-command is the
guest's word, not a local guess at it.
Discovery is a request. help is an x-command: a bare one lists what
that machine serves, a topic returns one command's usage. Both guests
answer it from their own table (now-guest-ppc/src/commands/cmd_help.c,
now-guest-68k/src/commands/commands68.c), which is also the table their own consoles
read, so help cannot drift from the commands. The host console's Tab
completion is that answer, fetched on the first Tab and dropped when the
wire drops; a guest too old to serve help has no completion, which is
the honest outcome rather than a fallback list. History is host-local —
recalling a line needs no notion of what the line means.
Menus on the host¶
Two surfaces, one rule for which is which.
The status item is what exists when there is no window: the
connection glyph and status line, Open, Screenshot Guest, Quit. The
main menu (MainMenu.swift) is everything, and duplicates exactly
those three verbs — a status item that mirrored the whole menu would be
two surfaces to keep honest. Its View menu is the module registry,
derived rather than retyped; the verbs that act on the other machine sit
in a Guest menu, because none of them touches anything on this side.
The app shipped without a main menu for a while, which is worth stating
plainly because the symptom was elsewhere: NSApp.mainMenu dispatches
key equivalents, so there was no ⌘Q, no ⌘W and no ⌘C/⌘V in the console,
while a Quit item plainly existed in the status item — where its ⌘Q only
fires with that menu open.
⌘Q tells the guest first. bye is a write and a write needs a turn of
the run loop, so applicationShouldTerminate returns terminateLater
and GuestListener.shutDown reports once the socket has taken the
farewell — bounded at half a second, because a guest wedged badly enough
to need telling is exactly the one that will not read.
Faces, and the one door they share¶
A face is something that can ask this host to act on the guest: the
app's own UI, and its MCP surface. A projection is one capability
rendered for every face at once — its schema, its bounds, its
availability rule and its answer, written once.
HostProjectionCatalog is the registry, in presentation order;
HostProjectionRegistry refuses a duplicate capability name.
What each row reaches, what it requires, and every guest capability no
row exposes yet — with a reason or an admission of not having noticed —
is mcp-coverage.md, which is derived from the registry
in-process and gated by MCPCoverageTests. The boundary and trust model
are agent-integration.md. Neither is restated
here, and neither is the row count: the tables are derived and the
prose is not, which is a lesson this repository has now learned twice.
Three rules hold the layer together.
- The companion is a client, not a face of its own. It cannot ask for anything the app could not; adding a tool means adding a row, and a row arrives on every face together.
- Only
HostProjectionDispatchinvokes a projection, so every invocation emits an audit event and nothing can act on the guest quietly. This is the rule most worth knowing about because it is the one a text-scanning gate cannot fully prove — the gate forgives any line containingdispatch.invoke(, and the real fix is narrowinginvoke's visibility (source-text-gates.md). - Consent is checked before the row runs, not inside it. One check covers every registered row without per-row opt-in.
The consent ceiling¶
HostCapabilityTier is read-only < full, ordered rather than boolean.
disabled is deliberately not a tier — it is the absence of one. The
guest's hello answer becomes a ceiling, and a row is permitted when
its required tier is at or below it.
A row's required tier is derived from what it already publishes:
readOnlyHint in its own rendered MCP descriptor. There is no separate
tier field to keep in step, because a second declaration of the same fact
is the thing that goes stale. destructiveHint is not a second boundary;
it is only a coherence check — a row claiming both read-only and
destructive fails the suite. A row declaring neither falls to full,
which is fail-restrictive.
A denial is not an unavailability, and a caller must be able to tell.
Consent refusal is a JSON-RPC error, code -32010 — in the
implementation-defined range, deliberately not -32602, because the
caller asked correctly. Its data names the ground (machine-declines,
above-granted-tier, unrecognized-tier), the capability, the tier it
needed and the machine's verbatim answer. A capability the guest simply
does not serve comes back the other way: a successful result carrying
the row's own unavailable arm. So a caller branches on transport shape,
not on prose.
Companions, not sockets¶
The local surface is one request per connection and the companion is
short-lived and client-launched, so an isConnected boolean would be
true for milliseconds and false whenever anyone looked. The host models
companions instead (AgentCompanionActivity), identified by the pid
the kernel attests on the accepted socket rather than by anything the
peer claims. Four states, derived from the clock at read time rather than
stored: never-attached, working (a call is in flight — it
outranks recency), active (seen inside a two-minute window) and
idle. The ledger keeps counts and clock times and deliberately no
operation names, arguments or payloads, so it cannot become the back door
that reintroduces what an audit event leaves out.
Logging¶
Each side keeps a log: one file per launch in a now-logs folder, plus
the last lines in memory. Classic guests keep 10 recognized launch logs by
default under one configurable 1–100 policy. tail reads either machine's from either
console. What belongs in one, what must never be logged (anything in a
per-chunk path), and how to read the two together is
docs/logging.md.
Capture and streaming¶
Full-screen capture cost is VRAM read bandwidth — transaction-bound at ~434 ns per bus beat on the PB1400c, floor ~90 ms, CopyBits within ~15% of it (see vram-readout.md and the TimBotTu corpus). Every streaming design decision follows from measurements:
- Banded, pipelined capture — banding is free (~0.2 ms per extra CopyBits), so frame N+1 is captured a band at a time from the event loop while frame N sends; capture is scheduled to complete as the send completes. Frame period approaches max(capture+encode, wire).
- Delta frames — after each capture the guest diffs against the
previous frame (memory-bound, pixel-granular for free) and sends
key/delta(≤16 dirty rects, byte-granular column spans) /empty(~150 bytes) frames. Deltas reference the previous frame implicitly; TCP ordering makes that safe.stream.refreshforces a keyframe. - Predictive capture (toggle) — read only last frame's dirty rows plus a margin, plus a rotating sweep slice; partial VRAM reads are exactly linear, so capture cost scales with screen activity.
- Interlacing (toggle) — one decimated CopyBits into a half-height
GWorld captures a field per frame (2:1 point sampling); each field
diffs against its own parity, wire rects carry
rowStep. Composes with predictive. - Keyframes are the correctness anchor: always whole, always full scale, outside both policies. A key replaces the host's canvas and carries no row mapping, so a decimated or partial capture can never be exported as one — and the need for a key can arise after a capture is in hand (palette change, failed diff), when what is in hand may be a field. That frame is dropped and the next capture reads everything. The guest enforces this before export; the host rejects a half-height key rather than resizing its canvas to match.
- Frames are paced even when they are free. An empty frame skips the
bulk lane, so the wire does not pace it. The guest keeps a floor of
its own (~15 fps, backing off to ~4 while nothing changes) that
minIntervalMsraises; without it a static screen under predictive capture floods the control plane. - Recording is host-side: every stream encodes live to a temp QuickTime movie with real VFR timestamps; stop offers the file.
Nested loops and wire liveness¶
The connection is serviced from one place — conn_service(), called
each pass of main.c's event loop. Every Toolbox call that runs its
own event loop suspends that one, and with it the entire wire: no
pings answered, no requests served, no transfers pumped. A modal dialog
froze the guest's wire exactly this way (see
nested-loops.md).
Two rules, both enforced by review:
- Any nested loop must pump.
pump.hprovides the shared idle hooks — a modal filter, a Nav Services event proc, aTrackControlaction proc — each callingnow_wire_pump(). A bareModalDialog(NULL, …)is a defect.MenuSelect,DragWindow,GrowWindow,TrackGoAwayandTrackBoxaccept no callback and cannot be pumped; they stall the wire for the duration of a mouse-down, which is why a held-open menu visibly pauses a stream. - Pumped code must never open a dialog. A modal opened from a network callback nests inside the modal already running, and the guest becomes unrecoverable. Wire code sets status strings; keep it that way.
The guest can only promise liveness it controls. Classic Mac OS is cooperatively multitasked, so a foreground application that busy-waits instead of yielding starves every background process, this one included. That is why the host carries its own deadline on every request rather than trusting the guest to answer.
Guest ownership¶
wire.c— connection state machine, control TX queue, transfer (g_xfer), offer, stream bracket and frame pump. Serviced non-blocking from the event loop; nothing here ever waits.capture.c— span/decimation GWorld capture, pumped in bounded steps.pixels.c— wire pixel export (palette + per-row PackBits), diff.json.c— the one tolerant JSON scanner (natively unit-tested).commands.c— one command table serving both consoles, overcmd_line.c(the console-line grammar, natively tested) andcmd_help.c(the command doc table, whichhelpanswers from and the guest's own console renders);console_model.cthe guest console's scrollback and history.workshop_window.c/workshop_sidebar.c/workshop_layout.c— the one window, its rail, and the pure geometry both read.screenshots_module.c/files_module.c(+files_browser_view.c,files_share_view.c) /console_module.c/connection_module.c— the pages, one per sidebar row, behindWorkshopModuleOps. Adding one is docs/adding-a-workshop-module.md.prefs.c— versioned preferences (accretive record, v9).agent_access.c— the one seam that answershello'sagentfield. A function rather than a constant so a future switch or installer has a single landing place; today it returnsfullunconditionally.main.c— Toolbox event loop; drops its WaitNextEvent sleep to 0 while any pump is live.
Host ownership¶
GuestListener+Session— listener lifecycle, hello gate, capture routing (solicited / pushed / stream by id), stream canvas compositing, idle timeout.CaptureDecoder— wire pixels to CGImage; delta rect patching.StreamRecorder— live VFR H.264 encoding.ScreenshotModuleModel/ConsoleModel/SettingsModel/DiagnosticsModel/AgentActivityModeland the rest — module state;ModuleRegistrythe composition root;HostAppStatewiring. A module declares aModulePlacement, so the sidebar's pinned footer is derived from the one ordered array rather than stored twice, and the View menu numbers that array rather than being retyped.DiagnosticsModelruns the three guest probes that measure the machine itself (vprobe,shotdiag,putstat) and decides which cards can run from that machine's ownhelptable, never from which Mac it is. A refusal is shown in the guest's words; an unknown-command refusal reads as absence rather than as an error.AgentActivityModel+AgentCompanionModelare the Agent page: companion presence, the machine's consent answer, and a bounded in-memory audit stream. It is a glance, not the record — the log is the record — and it holds no arguments, paths or payloads.HostProjectionCatalog/HostProjectionRegistry/HostProjectionDispatch— the projection layer above;NOWMCPServerrenders the registry for the companion face.MainMenu— the menu bar, as a pure function over the registry;App.swiftinstalls it, owns the status item, and holds the quit sequence.ConsoleInputFieldis AppKit because ↑/↓ and Tab are out of SwiftUI's reach on macOS 13.
Projects are a second authority domain¶
The host owns one bounded Projects root in Application Support, including its Git object store, persistent workspaces and candidate receipts. A guest-home project does not move into that domain when imported: the host copy is a history mirror and scratch workspace until a digest-guarded promotion settles on the classic Mac. Toolchains and active guest projects remain guest-owned; their private import and candidate transfer scopes never join generic Files.
The Development service is headless and page-neutral. The host module, Chat and MCP use one typed adapter; the PPC Workshop page and wire commands use one guest runtime. CodeKitten consumes the shared project format and is only an optional human handoff. The complete ownership and lifecycle description is development.md.
Naming seam¶
Display names, creator codes, bundle identifiers, and preference keys
stay confined to now-guest-ppc/src/core/product_identity.h and
now-host/Sources/Host/ProductIdentity.swift.