← 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.
one message, both ends dialing out
phonerelaythe Macphone app4 MiB message capphone doorstamps the senderroutermemory only, no diskMac doorreads the recipientrelay linkoutbound, 5s pingMac approutes by message typerequestto this Macrequest +senderreply +recipientto that phoneevents
what the relay holds
holdsnever holds
live connections per Maca transcript
a room of phones per Maca local path
a 25s heartbeata queue for a sleeping Mac
the sender, stampeda frame after it is sent
the watchdog on the Mac
  1. 1pingevery 5s
  2. 2wait10s per ping
  3. 32 missesany message resets
  4. 4hard closeno polite goodbye
  5. 5reconnect1s to 30s, jitter

then back to 1

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.
one screen frame, desktop to pixels
the Macrelayphonescreen 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 threadpaced framesa JPEGone encodea framea framean image
the capture loop
  1. 1read framenative desktop rate
  2. 2pacetimed from last grab
  3. 3scaledown to 1000px
  4. 4encodeJPEG at 0.52
  5. 5duplicate filtersame image dropped
  6. 6hand offto the sender

then back to 1

three ceilings on one frame
  • Mac send backlog512 KiB
  • relay frame cap1,024 KiB
  • phone message cap4,096 KiB

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.
finding agents it did not start
coding agentsthe Macphonerunning agentsfound by process namesession filesconversation, busy or idleprocess scan2s cache, 4s timeoutours or notskip what I startedpast sessions60 per pagestatus taggerlive, workingsession listpast and runningprocess,agesessionid, folderworking folderforeign onlysummariesthe list
where discovery looks
  • Claudetranscripts on disk
  • Groktranscripts on disk
  • Mantistranscripts on disk
  • Codexlisted, never driven
  • session filesClaude only
  • open portsnot scanned
following a terminal session
phonethe Macthe agentsession listtap to followlive previewreplay, no hapticstranscript readerposition stays localfile followerwatch, 1s pollstopperrefuses if 2+ sharetranscriptappend-only, read-onlyterminal agentinput not oursfollowwhere I left offnew lineseventsstopkill
started by redeye vs found on the Mac
startedfound
reads its output livefollows the transcript
interrupt keeps the sessionstop ends the session
model tuned mid-runnothing to tune
400 events replayedlast 300 events read

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.
an agent started from the phone
phonethe Macthe agentnew sessionfolder, engine, modephone appsends the requestsession starternew id, allowed modessession managerone agent per sessionengine configresume, permissionscoding agentstreaming both waysfolderstartresume id, modeenginelaunch infolderstarted
held open vs a process each time
stream: Claudeper message: Grok, Mantis
one agent per sessiona new process each time
prompt is a line of inputprompt as an argument
interrupt by requestinterrupt kills it
model, mode tuned livethe next one resumes by id
a phone catching back up
  1. 1connection dropstunnel, dead zone
  2. 2reconnect1s to 30s
  3. 3reattachby session id
  4. 4history replayrebinds the phone
  5. 5catch up400 events, no partials
  6. 6live againevent stream

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.
one tool call, agent to a card
the agentthe Macrelayphonecoding agentprints one linetranslatorshared by 3engineshistorylast 400, nopartialsevent streampartials droppedif backed upsenderlocal networkfirstrouterto the phone byidsession stateapplied persessiontool cardwhat the agentdidraw outputa tool callan eventeventsto thisphone
the same messages without the relay
phonerelaythe Maclocal linkfinds the Mac on Wi-Fiphone appone message handlerrelaywhen off-networkannouncerany free portlocal server5s to say helloMac appcannot tell whichhere I amhello + secretmarked localsamemessagesvia routerevents

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.