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
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.

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 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.

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;
}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.

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;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.

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);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.

_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 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.