Skip to content

feat(server): EAS Simulator device hosts - #21844

Merged
juliusmarminge merged 31 commits into
device/expo-accessfrom
device/eas-host
Oct 20, 2026
Merged

juliusmarminge merged 31 commits into
device/expo-accessfrom
device/eas-host

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Configured EAS hosts now appear in the device hub. Opening the host's iPhone starts an EAS Simulator session, waits until it runs, and shows the simulator like any other device. Powering it off stops the session; closing the panel without powering off leaves it running until its idle limit.

Cost bounds. Sessions bill the Expo account until they stop, so a started session is always tracked or stopped:

  • every session is created with the host's run and idle limits, and the host refuses to start past its session limit;
  • each session is recorded by its unique name, atomically, before Expo is asked to create it; a start whose record cannot be written does not happen, and an unreadable record is an error rather than an empty one;
  • a start runs in the host, so a client that disconnects while a session boots leaves it running and tracked; creating it runs uninterrupted, and the stop for a failed or interrupted start is in place before anything can interrupt it;
  • the host knows every session it owns: starts in progress, tracked sessions, and sessions whose stop failed (including the stop after a failed start). They count against the limit, as does a create Expo did not answer or answered with a server error, until a sweep finds the session or Expo would have listed it by now (5 minutes), and every record a previous server left, until a sweep whose listing succeeded has looked for it. A create Expo refused frees the slot once a sweep shows Expo did not list its session anyway, and keeps its record, marked refused, for the reaper in case the refusal was misread; a restart does not count such a record. A record three hours old stops counting against the limit whatever its listing says, so an app that is gone cannot hold the host; since Expo's longest session runs 115 minutes from when it is ready, and being ready can wait on Expo's queue, the record itself stays until a listing of its app confirms the session is not running, or for a day. A session whose stop keeps failing is let go once its status says Expo ended it. Saving the host in Settings replaces its instance; one whose scope has closed neither sweeps nor writes the record, so it cannot stop its replacement's session, and closing waits for a record write already in flight so it cannot overwrite the replacement's record either. A listing sweeps before anything starts, and one sweep runs at a time. Without a credential a start says how to sign in before anything else, and a record Expo would have listed by now stops counting on the next listing even though no sweep can look. A session whose stop failed is retried by the next listing and let go once Expo reports it ended; the refusal at the cap says what is still being cleaned up. One failed status poll during a start is retried rather than ending it;
  • stopping or closing the host (device support off, host changed or removed, server stopping) interrupts starts in progress, waits for them to stop their sessions, and stops tracked ones;
  • recorded sessions the host does not own are found by name and stopped at start and while listing, including ones a crashed server left behind; a recorded session Expo does not list yet is kept until Expo must have ended it;
  • sessions started elsewhere on the account are never touched.

EAS project. Sessions run under the host's EAS project, or the one found in the thread's project, so the MCP tool and the device.open RPC pass the thread's workspace (threadWorkspaceRoot).

Per-session endpoints. Each session has its own serve-sim preview host and agent-device daemon:

  • Every EAS simulator reports the same UDID, so a running EAS device's id is its Expo session id. DeviceHostReady.upstream lets a host route proxied requests itself: the proxy sends stream, input and WebRTC signalling to the right session with that session's token, as a Bearer header or WebSocket subprotocol, and swaps the session id for the UDID serve-sim knows. The token never reaches clients.
  • DeviceOperations.agentEndpoint gives per-device agent config files and the id agent-device selects the device by (the UDID). device_open checks agent access first, opens the device, then targets the opened device, whose daemon only exists once its session runs.
  • Hosts that end devices themselves (endsDevices) drop sessions on devices they stop listing; local and SSH hosts keep theirs through a listing that misses a device.

Also: device host errors name the host by its label instead of its id; a host without an Expo credential is still ready, and opening a device says how to sign in; and the client opens each device per thread in its own command lane, so a long EAS start no longer holds up powering off another device, turning device support off, or another thread's start.

Device settings stay unavailable on EAS devices: they run simctl on the simulator's machine, which EAS does not expose.

Part of built-in EAS Simulator support (stack #21847). Layer 5 of 6.

Testing:

  • EAS host tests, against a fake Expo API and preview host:
    • a bounded session start, the device listed by its session id, and the agent endpoint with the UDID;
    • two concurrent sessions with the same UDID told apart, listed, routed and stopped separately;
    • proxied requests rewritten with the session token and UDID, a malformed path escape answered 404, and serve-emu refused;
    • an interrupted open leaving a tracked session that closing the host stops;
    • stopping the host while a session is still starting stops it without waiting for it to be ready;
    • the periodic sweep leaving a start in progress, and its record, alone;
    • a failed stop keeping the session counted until a retry succeeds, also after a failed start (listed when it was running);
    • an unwritable record refusing the start before anything is created;
    • stopping on close, refusing past the limit, stopping only recorded sessions found by name (keeping a fresh unlisted record, forgetting an expired one), holding the slot for a create whose outcome is unknown until the clock frees it, holding it when the failed create's session is listed but its stop fails, holding it for a record a previous server left until the sweep stops its session (a fresh unlisted one until the clock frees it), holding it while the listing that would find it fails, freeing the slot of one Expo has ended by now while its listing keeps failing but keeping its record until a listing confirms (a day-old one is forgotten), letting go of a stopping session Expo ended although its stop keeps failing, keeping a refused create's record without counting it after a restart, keeping an old record whose session is listed but cannot be stopped, leaving a replacement's session alone once the host's own scope has closed (also from a listing already in flight), waiting for a record write in flight before the scope closes, stopping the session a refused create made anyway, and asking for a project before creating anything.
  • Device service: sessions on a device the host stops listing are dropped only for hosts that end devices; labelled error messages.
  • MCP test: the quick start targets the id agent-device knows, not T3's device id.
  • Live checks against Expo: sessions started, streamed over WebRTC, driven, and stopped through this host on a Mac, following the handoff note on feat(web): set up EAS Simulator hosts in Settings #21846.

Done with Claude Opus 5.5 in Claude Code, running in T3 Code.

🤖 Generated with Claude Code

@juliusmarminge
juliusmarminge added this pull request to stack #21847 October 19, 2026 18:09
@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Oct 19, 2026
@juliusmarminge juliusmarminge added the macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews label Oct 19, 2026
@github-actions github-actions Bot added the size:XL 500-999 changed lines (additions + deletions). label Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 5.0 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.8 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.9 KiB — 29.3 KiB ✅
Codex Live turn messages — 2 — 8 ✅
Claude Total thread wire — 5.0 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.8 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 21.2 KiB — 29.3 KiB ✅
Claude Live turn messages — 2 — 8 ✅

Baseline: unavailable · PR result: 83994e5 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@juliusmarminge
juliusmarminge force-pushed the device/eas-host branch 2 times, most recently from 85f8851 to 7dd5473 Compare October 19, 2026 21:00
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Oct 19, 2026
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:XXL 1,000+ changed lines (additions + deletions). labels Oct 19, 2026
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Oct 20, 2026
@juliusmarminge
juliusmarminge force-pushed the device/eas-host branch 2 times, most recently from 856f7fe to ae43b5d Compare October 20, 2026 01:35
@juliusmarminge
juliusmarminge force-pushed the device/eas-host branch 2 times, most recently from 085d283 to 45b3c23 Compare October 20, 2026 02:14
juliusmarminge and others added 29 commits October 20, 2026 00:51
The generated Expo client selects `__typename` on a session's remote
config to tell its union members apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every EAS Simulator reports the same UDID, so a second running session
replaced the first in the host's map (leaving it billed and untracked)
and a new session matched the device of one Expo had already ended.
Running EAS devices now take Expo's session id as their device id, and
the serve-sim proxy swaps it for the UDID.

The device service also drops a session once its host stops listing
the device, so a session Expo ended no longer keeps a panel or floating
preview streaming nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`DeviceHostUnavailableError` printed the host's id, a UUID for SSH and
EAS hosts. Hosts now carry their label, the device service and the EAS
host set it on the error, and the message shows it in place of the id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A start runs in the host's scope, so a client that disconnects while a
  session boots leaves it tracked instead of dropped. Creating the
  session runs uninterrupted, and the stop for a start that fails or is
  interrupted afterwards is in place before anything can interrupt it.
- The ledger records each session by name before Expo creates it, is
  written atomically, and a start fails if it cannot be written. An
  unreadable ledger is an error, not an empty list. Recorded sessions
  nothing tracks are found by name and stopped at start and while
  listing.
- A session whose stop fails stays tracked, counted against the limit,
  and listed, and the next listing retries the stop.
- The epoch is read before a start yields, so a stop in that window is
  seen; a malformed escape in a proxied path is a 404, not a defect.
- agent-device selects an EAS simulator by its UDID: hosts report that
  id with their agent endpoint, and device_open passes it.
- Only hosts that end devices themselves drop sessions on devices they
  stop listing; a local or SSH listing that misses a device keeps them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device commands for an environment ran one at a time, so an EAS
Simulator start, which can take minutes, held up powering off a running
session or turning device support off. Opens now queue per device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The periodic sweep took a start still in progress for a leftover:
  it stopped the session the user was waiting for, or, before Expo
  listed it, dropped its record. The host now knows every session it
  owns (starts in progress, tracked sessions, and sessions whose stop
  after a failed start failed) and the sweep skips them.
- A recorded session Expo does not list is forgotten only once Expo
  must have ended it, so a create that timed out or a session past the
  first page stays recorded for a later sweep.
- A start that fails after creating a session, whose stop then fails,
  keeps the session counted against the limit and retried by listing.
- Stopping the host interrupts starts in progress and waits for them to
  stop their sessions, instead of letting them run until they are ready.
- One failed status poll or preview probe during a start is retried.
- A host without an Expo credential is still ready; opening a device
  then says how to sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every EAS Simulator start opens the same catalog device, so a second
thread's start waited behind the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…proxy

An EAS session's preview host is a third-party origin; a cookie it set
was passed through and landed on T3's origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A recovered session whose stop fails (a create whose response failed,
  then a failed stop) is adopted as unresolved, so it counts against the
  limit and listing retries it whatever path found it.
- A start cut short by a host stop fails with "was stopped while the
  session started" instead of a bare interrupt the client drops.
- The refusal when the cap is reached says when a session is still being
  stopped, rather than telling the user to close one that is not listed.
- Stopping the host also reaps recorded leftovers, so a session a timed
  out create made is not left to its idle limit.
- The startup reap runs in the background, so a slow Expo does not hold
  the device lock other hosts' operations wait on, and a signed-out host
  does not re-resolve the credential on every listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en mocks

device_open checks agent readiness and reads the thread's workspace before opening, and agentTarget returns the id agent-device selects the device by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A create Expo did not answer, or answered with a server error, may
have made a session. Its slot was freed as soon as recovery did not
find it, so a host at its limit could start another billed session. Such
a create now counts against the limit until a listing finds the session
or Expo would have listed it by now (5 minutes); a create Expo refused
still frees the slot at once. A session whose stop keeps failing is let
go once Expo reports it ended, and the refusal at the limit says what is
being cleaned up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After a restart, a session recorded just before the server went, which
Expo does not list yet, survived the sweep but counted against nothing,
so a host at its limit could start another. Such a record now holds a
slot, like a create with an unknown outcome, until Expo lists it or
would have by now. The reaper resolves both kinds since it reads the
record anyway, which replaces the separate check each listing ran.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main now lints for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eep finds it

An old record whose session still ran counted against nothing after a
restart while the startup sweep was still in flight, so a host at its
limit could start another. Every record a previous server left now
holds a slot until a sweep looks for it, and a listing sweeps before
anything starts. One sweep runs at a time, so two cannot stop the same
session twice. A refused create still checks whether Expo lists its
session before forgetting the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A start now resolves the credential before taking a slot, so a host
without one says how to sign in rather than that sessions are still
being cleaned up. Without a credential no sweep can look for records a
previous server left, so a record Expo would have listed by now stops
counting against the limit on the next listing anyway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…find it fails

A sweep whose listing failed freed the slots of records past their
deadline although it had ruled nothing out, so a host at its limit
could start past it while the old sessions still ran. A record now
stops counting only once a listing of its app succeeded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its listing says

A record whose app can no longer be listed (gone, or another account's)
held its slot for good. Once Expo has ended the session by its own
limits, the slot and the record go. A refused create keeps its record
for the reaper too, so a misread refusal can free a slot early but
never lose a running session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cords as long as one can run

A tracked session whose stop kept failing was never asked for its
status, so one Expo had ended by itself stayed listed and held its
slot. The listing now reads the status after a failed stop and lets an
ended session go, as it does for sessions a failed start left. A record
also lives as long as a session can run: Expo's longest runs 115
minutes from when it is ready, not from when it was made.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A refused create keeps its record for the reaper, and a restart adopted
every record as a session that may run, so refused creates held slots
for hours once their app could not be listed. The record now says the
create was refused, and a restart leaves such records to the reaper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only a successful listing forgot an expired record, so a refused
create's record for an app that can no longer be listed stayed for
good and cost a failed listing every sweep. The sweep now forgets every
expired record it does not own, whatever the listing says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is not running

Expo's run limit counts from when a session is ready, and being ready
can wait on Expo's queue, which has no bound, so no age proves a record
names nothing that runs. After three hours the record stops counting
against the limit, but it is forgotten only once a listing of its app
succeeds without it, or after a day, when no queue could have held it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the record

Saving a host in Settings replaces its instance under the same id, and
both share one record. A listing still running on the old instance
could sweep the replacement's new session, which it did not own, and
stop it. Once its scope closes an instance neither sweeps nor writes
the record; a start on it fails before Expo is asked for anything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eeps nothing

Covers the sweep's own check, which a listing that started before the
scope closed depends on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cope closes

Closing the scope marked the host closed without waiting for a write
already under way, so a replacement could write its own record first
and have it overwritten by the old write. Closed is now set under the
record lock, after any write in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te in flight

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lock

Nothing is listed during the close, and the test waits in real time for
the close's own sweep, so only the write in flight can hold it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… made required

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it began

A device listing runs before the lifecycle lock, so a session opened
while it ran could be pruned by it as one on a device the host no
longer lists, although its device had simply not been listed yet
(review, Macroscope). Only sessions from before the listing are pruned
by it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant