← back

where the agent should live

sep 2026

two answers to remote agents: move the frames, or move the session onto the fleet.

Every agent I run lives on a Mac I am usually not sitting at. The laptop is closed in a bag, the mini is on a shelf, and the work is a claude process with a folder, a transcript and an opinion about what to do next. The question is not how to prompt it. It is where the process should live, what should cross the network, and whose disk is telling the truth.

I built two answers and they disagree on the one thing that matters. redeye leaves the process where it is and moves frames instead: stream-json events and screen pixels over a relay of its own that stores nothing. universe moves the session to the far machine, runs the agent there and brings back the log — and it ships no transport of its own for that, it rides herds, which already knows which Macs are up and how to reach them. Build the pipe, or use the fleet. Flip between them.

leave every process on the Mac and move only frames, through a relay that forgets them

Nothing listens

I did not want an open port on a laptop that changes networks daily, so the Mac and the phone both dial out and the relay joins them. It keeps only the live connections in memory and stamps the sender on every message, so a phone cannot pose as another. The Mac names a recipient, and the relay strips that and passes the rest to that phone.

The price is that it remembers nothing. A sleeping Mac gets the phone an offline error, not a queue, and a connection that dies silently still looks open, so the Mac pings every five seconds and cuts the link after two misses.

pairing. a six-character code, a six-minute expiry, and no inbound port on either end
redeye-backend/app/main.pythe device door
frame["from"] = device_id
frame["fromUser"] = from_user
delivered = await hub.to_host(host_id, frame)

# ... and coming back the other way, the recipient is popped off
target = frame.pop("to", None)
if target:
    await hub.to_device(host_id, target, frame)
else:
    await hub.broadcast_devices(host_id, frame)
the sender is written by the relay after the read, never merged with what the phone sent. a frame that can name its own sender can name somebody else's.
phonerelaythe Macrequestto this Macrequest + senderreply + recipientto that phoneeventsphone app4 MiB message capphone doorstamps the senderroutermemory only, no diskMac doorreads the recipientrelay linkoutbound, 5s pingMac approutes by message type
one message, both ends dialing out
holdslive connections per Maca room of phones per Maca 25s heartbeatthe sender, stampednever holdsa transcripta local patha queue for a sleeping Maca frame after it is sent
what the relay holds
1pingevery 5s2wait10s per ping32 missesany message resets4hard closeno polite goodbye5reconnect1s to 30s, jitter
the watchdog on the Mac

A screen that drops frames on purpose

The agent works on the Mac's real screen, so the phone gets pictures of it. Capture runs in a hidden window that reads frames straight off the desktop, because a video nobody sees only advances when a busy compositor gets to it. Each frame is paced from grab time, shrunk to a 1000 pixel JPEG at quality 0.52, and dropped if it matches the last one.

Every layer prefers now over complete. The Mac skips a frame when half a megabyte is already queued, the relay hangs up past 1 MiB, and the phone throws away any image that finishes decoding behind a newer one.

the Mac's screen on the phone. jpeg frames over the same socket as the events
redeye-desktop/src/renderer/capture.jsthe capture loop
const frame = result.value;
try {
  if (wanted()) encode(frame, frame.displayWidth, frame.displayHeight);
} finally {
  frame.close();
}

function wanted() {
  if (!running || pending) return false;
  return performance.now() - lastGrabbedAt >= 1000 / settings.fps;
}
no queue anywhere. every frame is closed on the way past and only the affordable ones are encoded, so a slow encode costs a frame rather than accumulating latency you never get back.
the Macrelayphonepaced framesa JPEGone encodea framea framean imagescreen capture30fps off the desktopencoderJPEG, quality 0.52, 1000pxframe pacerfastest viewer winsbacklog checkskips past 512 KiBsize gate1 MiB, then hang upnewest winsstale decodes droppedscreen viewdecoded off main thread
one screen frame, desktop to pixels
1read framenative desktop rate2pacetimed from last grab3scaledown to 1000px4encodeJPEG at 0.525duplicatefiltersame image dropped6hand offto the sender
the capture loop
Mac send backlog512 KiBrelay frame cap1,024 KiBphone message cap4,096 KiB
three ceilings on one frame

What it can drive, and what it can only watch

Redeye holds the agents it started by their pipes and rebuilds the rest from the Mac itself: transcripts from four engines, the process list for running Claude agents, and the session files Claude writes naming each conversation and whether it is busy.

That is narrower than a map of every agent on the laptop. Only Claude sessions show as live, Grok and Mantis appear only as transcripts, and a session running in a terminal can be followed but not typed into, since the terminal owns its input. Stopping one kills the process, refused when two share a folder, and driving one means resuming its conversation in a new agent I own.

home. the folders on that Mac, and which of them have a live session in them
redeye-desktop/src/main/running.jsfinding a live session
const out = await run("/bin/ps", ["-Ao", "pid=,ppid=,etime=,command="]);
for (const line of out.split("
")) {
  const match = line.trim().match(/^(d+)s+(d+)s+(S+)s+(.*)$/);
  if (!match) continue;
  const [, pid, ppid, elapsed, command] = match;
  const argv0 = command.split(/s+/)[0];
  if (path.basename(argv0) !== "claude") continue;
matched on basename(argv[0]) and nothing looser. claude spawns shells for your Bash calls, and those carry the word claude in their own argv, so a substring match counts a session's own tool calls as extra sessions.
coding agentsthe Macphoneprocess, agesession id, folderworking folderforeign onlysummariesthe listrunning agentsfound by process namesession filesconversation, busy or idleprocess scan2s cache, 4s timeoutours or notskip what I startedpast sessions60 per pagestatus taggerlive, workingsession listpast and running
finding agents it did not start
Claudetranscripts on diskGroktranscripts on diskMantistranscripts on diskCodexlisted, never drivensession filesClaude onlyopen portsnot scanned
where discovery looks
phonethe Macthe agentfollowwhere I left offnew lineseventsstopkillsession listtap to followlive previewreplay, no hapticstranscript readerposition stays localfile followerwatch, 1s pollstopperrefuses if 2+ sharetranscriptappend-only, read-onlyterminal agentinput not ours
following a terminal session
startedreads its output liveinterrupt keeps the sessionmodel tuned mid-run400 events replayedfoundfollows the transcriptstop ends the sessionnothing to tunelast 300 events read
started by redeye vs found on the Mac

Start anywhere, resume anything

A phone picks a folder, an engine and a permission mode, and the Mac starts that coding agent there with my shell and credentials. Claude stays running with a stream open both ways, so a prompt is one line of input and stop is an interrupt that leaves the conversation standing. Grok and Mantis cost a fresh process every time you send something, resumed by id, and stopping means killing it.

Resume is the same start plus a session id, which is also how a conversation found on disk becomes one I can drive. A reconnecting phone asks to reattach and gets the last 400 finished events, never half-typed text, then the live stream.

the Mac console. link state, paired devices, and per-session token spend
redeye-desktop/src/main/relay.jsthe watchdog
const PING_EVERY = 5000;
const MISSES_ALLOWED = 2;

this.timers.ping = setInterval(() => {
  if (ws.readyState !== WebSocket.OPEN) return;
  const ts = Date.now();
  this.pending.set(ts, ts);
  setTimeout(() => {
    if (!this.pending.delete(ts)) return;
    this._sample(null);
    this._missed(ws);
  }, PING_EVERY * 2);
  this.send({ t: "ping", ts });
}, PING_EVERY);
each ping gets twice its interval to come home, so two misses is ~20s of silence. long enough that a phone switching towers does not cost a reconnect, short enough that nobody sits staring at a Mac that claims to be connected.
phonethe Macthe agentfolderstartresume id, modeenginelaunch in folderstartednew sessionfolder, engine, modephone appsends the requestsession starternew id, allowed modessession managerone agent per sessionengine configresume, permissionscoding agentstreaming both ways
an agent started from the phone
stream: Claudeone agent per sessionprompt is a line of inputinterrupt by requestmodel, mode tuned liveper message: Grok, Mantisa new process each timeprompt as an argumentinterrupt kills itthe next one resumes by id
held open vs a process each time
1connectiondropstunnel, dead zone2reconnect1s to 30s3reattachby session id4history replayrebinds the phone5catch up400 events, no partials6live againevent stream
a phone catching back up

One line out, one card in

Every line the agent prints passes through one translator, shared by the three engines that speak Claude's format, and leaves as a typed event. Partial text is a courtesy the Mac drops when the connection backs up, since the finished block follows and replaces it.

On the same Wi-Fi none of this needs the internet. The phone finds the Mac through local discovery and proves itself with a 32 byte secret handed over earlier through the relay. The Mac cannot tell which path a message came in on, so it tries the local network first.

one card per stream-json event. a write, a diff, a test run
redeye-desktop/src/main/agent.jsstdout to cards
_ingest(chunk) {
  this.buffer += chunk;
  const lines = this.buffer.split("
");
  this.buffer = lines.pop() ?? "";

  for (const raw of lines) {
    const trimmed = raw.trim();
    if (!trimmed) continue;
    let parsed;
    try {
      parsed = JSON.parse(trimmed);
    } catch {
      continue;
    }
    for (const event of normalize(parsed, this.stream)) {
      event.at = Date.now();
the last line of each chunk is held back, because a chunk boundary lands mid-line often enough to matter. normalize() maps one stream-json event onto the card the phone draws.
the agentthe Macrelayphoneraw outputa tool callan eventeventsto this phonecoding agentprints one linetranslatorshared by 3 engineshistorylast 400, no partialsevent streampartials dropped if backe…senderlocal network firstrouterto the phone by idsession stateapplied per sessiontool cardwhat the agent did
one tool call, agent to a card
phonerelaythe Machere I amhello + secretmarked localsame messagesvia routereventslocal linkfinds the Mac on Wi-Fiphone appone message handlerrelaywhen off-networkannouncerany free portlocal server5s to say helloMac appcannot tell which
the same messages without the relay

The split comes down to what you are willing to lose. redeye loses nothing on the Mac and everything in transit: a dropped socket costs you the view, never the work, and the relay can be restarted without a single session noticing. universe loses the live picture and gets durability in exchange: the lid closes, the work keeps going, and the far disk holds the record until you come back for it.

Neither is finished. redeye can only drive the claude processes it can see, and follows a terminal session read-only. universe sends work over the relay only, because negotiating a direct route took about 9s against 0.49s, and it does not yet find a run again after the app relaunches. Both are the honest state, and both are what I am working on.