Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
54a44fe
[agent-cli] dev --device binds a device the user names (llp/0033 §--d…
vonovak Oct 12, 2026
e3206e6
[agent-cli] an explicit Android device needs only adb, not the emulat…
vonovak Oct 12, 2026
612bddd
[agent-cli] --device next to a running dev server names both devices …
vonovak Oct 13, 2026
da17b38
[agent-cli] dev --plan --device says what happens to devices, and a r…
vonovak Oct 13, 2026
ef7ec52
[agent-cli] a refusing --device plan names the refusal once (llp/0033…
vonovak Oct 13, 2026
81f4624
[agent-cli] dev --device refusals and plans repeat the caller's comma…
vonovak Oct 13, 2026
7ced829
[agent-cli] a detached dev's --device refusal repeats the caller's --…
vonovak Oct 13, 2026
eb391c0
[agent-cli] every How that repeats the caller's dev command comes fro…
vonovak Oct 13, 2026
accd31d
[agent-cli] Fix explicit device reuse and EAS suggestions
vonovak Oct 13, 2026
0965d31
[agent-cli] llp/0035 names explicit.ts and preview.ts
vonovak Oct 14, 2026
c78b0ca
[agent-cli] The cli:start_plan event carries the plan's explicitDevic…
vonovak Oct 14, 2026
fd47447
[agent-cli] dev --plan --device prints only a refusal of the choice, …
vonovak Oct 14, 2026
3af57c1
[agent-cli] dev --plan previews no --device when its EAS route is imp…
vonovak Oct 14, 2026
7509ba7
[agent-cli] dev refuses --device with BAD_ARGS when the run binds no …
vonovak Oct 14, 2026
59823b6
[agent-cli] --device binds over an own spawned emulator whose instanc…
vonovak Oct 14, 2026
78d147f
[agent-cli] --device naming a serial adb lists offline or unauthorize…
vonovak Oct 14, 2026
b62e1ae
[agent-cli] An own USB binding adb lists in any state skips AVD disco…
vonovak Oct 14, 2026
c9884e3
[agent-cli] A serial adb lists with no permissions is not ready, as o…
vonovak Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
### 🎉 New features

- `navigate` and `smoke` now suppress onboarding, the floating Tools button, and automatic dev-menu opening when launching a development build. Set `EXPO_NO_DEV_MENU=0` to opt out.
- `dev --device <name|udid|serial>` binds a simulator or an Android device the user names. The CLI never shuts it down or deletes it, refuses a device bound to another live worktree, refuses a serial `adb` lists as `offline` or `unauthorized` with `explicit-not-ready` (exit 7) and names what readies it, and `dev --plan` says it will refuse. The plan's `explicitDevice` says the same on `--json` and on the `cli:start_plan` event. A run that binds no local device (`--web`, `AGENT_CLI_NO_DEVICE=1`, iOS off macOS, no Android SDK) refuses `--device` with `BAD_ARGS`. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev`, `smoke` and `dev:stop` reap the devices of other worktrees: a deleted worktree's created simulator is deleted, an expired one is shut down on the next iOS acquisition, and a spawned emulator instance whose worktree is gone or whose lease expired is killed, or reported `device-gone` when its pid has exited or runs another program; `dev:stop --json` lists them under `reaped`. A delete or kill that fails keeps the binding, so the next reap tries again, and `dev:stop` exits 20 with a How line naming the command that does it by hand. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `smoke --ios` and `smoke --android` release the device they bound when the run ends: a child dev server's device through `dev:stop --release`, a device bound next to a reused dev server through the boot phase's cleanup. `smoke --no-start` binds and releases nothing. The report names that device, how the run got it, and what its release did: a `device` line, and `environment.boundDevice` in `--json`. A run that binds nothing and uses the worktree's bound device names it as `already bound · kept` (action `existing`). When no app attached and the run put its dev server and device back, the follow-ups start them again with `dev --<platform> --detach --wait-ready` before `navigate`, and a repeated `smoke` keeps `--port`. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev:stop --release` parks the worktree's created simulator and ends its spawned emulator instance once this worktree's dev server is stopped; plain `dev:stop` keeps them and reports them under `devices` with `released: null`; `false` means a release failed. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
Expand Down
19 changes: 19 additions & 0 deletions packages/@expo/agent-cli/e2e-live/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Device bindings and two worktrees

Each worktree has its own simulator or emulator binding. Local `dev --ios` runs use
`--device "$AGENT_CLI_LIVE_UDID"` when that variable is set; otherwise `dev` creates a
simulator for the worktree. Explicit devices are never shut down or deleted by cleanup.
The live runner uses `dev:stop --release` when cleaning up its servers.

To exercise isolation manually, open two checkouts and run the following in each, using
8082 for the second checkout:

```sh
npx expo-agent-cli dev --ios --detach --wait-ready --port 8081
```

In the first checkout, run
`npx expo-agent-cli dev:stop`, then start it again: it should reuse its simulator. Run
`npx expo-agent-cli dev:stop --release --json` in each checkout to park the simulators
(or end spawned Android instances). For explicit selection, assign a different simulator
UDID to each worktree; naming the other worktree's device must refuse.
10 changes: 10 additions & 0 deletions packages/@expo/agent-cli/e2e-live/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,16 @@ export async function runLiveAsync(
argv: string[],
{ env, label }: LiveOptions = {}
): Promise<LiveResult> {
const udid = env?.AGENT_CLI_LIVE_UDID ?? process.env.AGENT_CLI_LIVE_UDID;
if (
argv[0] === 'dev' &&
argv.includes('--ios') &&
!argv.includes('--eas') &&
udid &&
!argv.some((arg) => arg === '--device' || arg.startsWith('--device='))
) {
argv = [...argv, '--device', udid];
}
if (argv[0] === 'dev:stop' && !argv.includes('--release')) argv = [...argv, '--release'];
run.spend.commands += 1;
const startedAt = Date.now();
Expand Down
22 changes: 22 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/device-lifecycle-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ describe('device lifecycle across two worktrees', () => {
.sort()
).toEqual([first.runDevice, second.runDevice].sort());

const plan = await executeAgentCliAsync(
projects[1]!,
['dev', '--ios', '--local', '--plan', '--device', first.runDevice!, '--json'],
{ env: envFor(projects[1]!) }
);
expect(plan.exitCode).toBe(0);
expect(JSON.parse(plan.stdout)).toMatchObject({
explicitDevice: {
refuses: true,
summary: expect.stringContaining(`is bound to the worktree ${first.projectRoot}`),
how: 'npx @expo/agent-cli dev --ios --local --json --detach --wait-ready',
},
followups: expect.arrayContaining([
expect.objectContaining({
id: 'device-refusal',
command: 'npx @expo/agent-cli dev --ios --local --json --detach --wait-ready',
}),
]),
});
expect(JSON.parse(plan.stdout).followups.map(({ id }: { id: string }) => id)).not.toContain(
'dev'
);
expect(bindings()).toHaveLength(2);

const kept = await executeAgentCliAsync(projects[0]!, ['dev:stop', '--json'], {
Expand Down
2 changes: 1 addition & 1 deletion packages/@expo/agent-cli/e2e/__tests__/plan-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ describe('@expo/agent-cli dev --plan', () => {
});
expect(plan.reasons.join('\n')).not.toContain('does not take that route on its own');
expect(plan.followups.map((followup) => followup.command)).toContain(
'npx @expo/agent-cli dev --ios'
'npx @expo/agent-cli dev --ios --json --detach --wait-ready'
);
});

Expand Down
14 changes: 8 additions & 6 deletions packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,17 +202,17 @@ export type ReleasedDevice = {

## Choice (`choose.ts`, pure)

Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its highest-numbered `iPhone <n> Pro`, else `iPhone <n>`, else `iPhone <n> Pro Max`, whatever order `simctl` lists them in, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs:
Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its highest-numbered `iPhone <n> Pro`, else `iPhone <n>`, else `iPhone <n> Pro Max`, whatever order `simctl` lists them in, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds`, `runningSerials`, and the listed serials that are not ready, with their state. With `--device`, Android reads only `adb devices -l`: a USB device needs no emulator package. Outputs:

```ts
| { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed and available; Android spawned: the instance check; Android explicit: serial listed); also --device naming the own device
| { kind: 'ios-create'; runtime; deviceType; name }
| { kind: 'android-spawn'; port; avd }
| { kind: 'explicit'; device }
| { kind: 'refuse'; reason: 'no-ios-runtime' | 'no-avd' | 'no-free-port' | 'explicit-not-found' | 'explicit-bound' | 'own-explicit-over-owned' | 'not-reusable'; boundBy?: { id: string; root: string }[]; matches?: string[] }
| { kind: 'refuse'; reason: 'no-ios-runtime' | 'no-avd' | 'no-free-port' | 'explicit-not-found' | 'explicit-not-ready' | 'explicit-bound' | 'own-explicit-over-owned' | 'not-reusable'; boundBy?: { id: string; root: string }[]; matches?: string[] }
```

- `--device` matches a simulator by name or udid, case-insensitive, and an emulator by serial only, because read-only instances share one AVD name. Two or more matches are `explicit-not-found` with `matches`. A device that another worktree's `created` or `spawned` binding names, live or stale, is `explicit-bound`, and so is one its live `explicit` binding names; an expired `explicit` binding of another worktree is removed in the section and the device is bound here, because a named device belongs to whoever names it now. A simulator named `agent-cli …` that no binding names is `explicit-not-found`, so a leaked create is never bound. `--device` naming another device while the own binding is `spawned` is `own-explicit-over-owned`, because one file per worktree and platform cannot hold both and a replaced instance would be orphaned. While the own binding is `created`, the own simulator's file is removed in the same section and the simulator is deleted with `simctl delete` after it, the `explicit` binding is written, and the delete is reported on stderr: the user named the device, so the replacement is what they asked for, and a parked binding would refuse forever. An own `explicit` binding is replaced.
- `--device` matches a simulator by name or udid, case-insensitive, and an emulator by serial only, because read-only instances share one AVD name. Two or more matches are `explicit-not-found` with `matches`. A serial `adb` lists in a state other than `device`, such as `offline` or `unauthorized`, that no candidate matches is `explicit-not-ready`, not `explicit-not-found`. A device that another worktree's `created` or `spawned` binding names, live or stale, is `explicit-bound`, and so is one its live `explicit` binding names; an expired `explicit` binding of another worktree is removed in the section and the device is bound here, because a named device belongs to whoever names it now. A simulator named `agent-cli …` that no binding names is `explicit-not-found`, so a leaked create is never bound. `--device` naming another device while the own binding is `spawned` and the instance check holds is `own-explicit-over-owned`, because one file per worktree and platform cannot hold both and a replaced instance would be orphaned; an own `spawned` binding that fails the instance check counts as gone, and the `explicit` binding replaces its file. While the own binding is `created`, the own simulator's file is removed in the same section and the simulator is deleted with `simctl delete` after it, the `explicit` binding is written, and the delete is reported on stderr: the user named the device, so the replacement is what they asked for, and a parked binding would refuse forever. An own `explicit` binding is replaced.
- With `reuseOnly`, any choice but `reuse` is `not-reusable`.
- An own binding whose device is gone is removed under the lock and allocation goes on. `no-free-port` carries the serials and the worktrees they are bound to in `boundBy`. There is no cloud variant: the cloud path writes a binding and chooses nothing.

Expand Down Expand Up @@ -291,8 +291,9 @@ Each reaped device is reported on stderr and in JSON with reason `expired`, `del
| `DEVICE_UNAVAILABLE` | `no-ios-runtime` | 7 | "no iOS runtime with an iPhone is installed" | `xcodebuild -downloadPlatform iOS` |
| `DEVICE_UNAVAILABLE` | `no-avd` | 7 | "no Android virtual device exists" | create one in Android Studio |
| `DEVICE_UNAVAILABLE` | `no-free-port` | 20 | the serials and the worktrees they are bound to; a serial no binding names is listed as "not ours" | `<cli> dev:stop --release` in a listed worktree, or stop the unlisted serial by hand |
| `DEVICE_UNAVAILABLE` | `explicit-bound` | 20 | "<name> is bound to the worktree <root>" | `<cli> dev` without `--device` |
| `DEVICE_UNAVAILABLE` | `explicit-not-found` | 1 | "no device matches <query>, or more than one does" with `matches` | `<cli> status --json` |
| `DEVICE_UNAVAILABLE` | `explicit-bound` | 20 | "<name> is bound to the worktree <root>" | the caller's `<cli> dev` without `--device` |
| `DEVICE_UNAVAILABLE` | `explicit-not-found` | 1 | "no device matches <query>, or more than one does" with `matches` | the same command with `--device <first match>`; none: `simctl list`/`adb devices` |
| `DEVICE_UNAVAILABLE` | `explicit-not-ready` | 7 | "adb lists <serial> as <state>" | allow USB debugging or reconnect, then the same command with `--device <serial>` |
| `DEVICE_UNAVAILABLE` | `own-explicit-over-owned` | 1 | "this worktree runs the emulator instance <serial>" | `<cli> dev:stop --release` |
| `DEVICE_UNAVAILABLE` | `not-reusable` | 20 | "a dev server is running and this worktree has no <platform> device to reuse" | `<cli> dev:stop` |
| `DEVICE_UNAVAILABLE` | `create-timeout` | 22 | "the device tool did not answer in time" | run the command again |
Expand Down Expand Up @@ -558,8 +559,9 @@ What happens to the device of a deleted or expired worktree?

How does a person pick a device?

- In `DEV_OWN_FLAGS`; its value is read as `resolvePort` reads `--port`, `--device=<v>` included; the flag and value are stripped from the Expo args as `withoutPortArgs` strips the port; `detachArgv` carries it unchanged; with `--eas` it is `BAD_ARGS`, checked where `assertEasRunFits` runs. The match and the refusals are in §Choice; the origin's rule is Contract 7. `dev --plan` naming a device bound to another worktree carries "will refuse: <name> is bound to <root>".
- In `DEV_OWN_FLAGS`; its value is read as `resolvePort` reads `--port`, `--device=<v>` included; the flag and value are stripped from the Expo args as `withoutPortArgs` strips the port; `detachArgv` carries it unchanged, and a detached parent gives its child the caller's argv in `__EXPO_AGENT_CLI_CALLER_ARGV`, which `resolveDevOptions` reads once and keeps from the Expo CLI, so every How line that repeats the caller's `dev` command (`rerunDevCommand`, which adds `--detach --wait-ready` to a foreground command) repeats what the caller typed; with `--eas` it is `BAD_ARGS`, checked where `assertEasRunFits` runs, and so is a run that binds no local device (`--web`, `AGENT_CLI_NO_DEVICE`, a host that cannot run the platform's device), checked before the probe. The match and the refusals are in §Choice; the origin's rule is Contract 7. With `--device`, Android reads only `adb devices -l`: a USB device needs no emulator package. `dev --plan --device` prints a `Device:` line under the steps and the plan's `explicitDevice` (on `--json` and `cli:start_plan`) from the same choice, reuse-only while this worktree's dev server runs, read without writes or boots: the device it binds, the expired foreign binding it takes over and the own created simulator it deletes, or "Will refuse — <reason>", whose How command replaces the `dev` follow-up while the plan still exits 0. Only a refusal of the choice is printed so; a device tool error, a timeout or an unreadable binding fails the plan as it fails the run. A plan whose EAS route nobody chose previews no device, because the run stops before it binds one.
- Live tier: the live tier passes `--device $AGENT_CLI_LIVE_UDID` to `dev` or lets it create. The runbook in `e2e-live/README` gains the two-worktree setup.
- `explicit.ts` holds the `--device` request, its pure choice and the section that binds it (`readExplicitChoice`, `chooseExplicitDevice`, `bindExplicitSection`); `preview.ts` holds the `dev --plan --device` line (`previewExplicitDeviceAsync`).

### Lifecycle output

Expand Down
35 changes: 35 additions & 0 deletions packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { fetchAdvertisedUrlAsync } from '../advertisedUrl';
import { devDetachAsync, OPEN_PLATFORM_GRACE_MS } from '../detachAsync';
import { detachedLogPath } from '../logFile';
import { isProcessAlive } from '../processLiveness';
import { CALLER_ARGV_ENV } from '../deviceOption';
import { resolveDevOptions } from '../resolveOptions';

vi.mock('../../devLock', () => ({ readDevServerLockAsync: vi.fn() }));
Expand Down Expand Up @@ -531,6 +532,8 @@ describe('a lock whose port Metro has not logged', () => {

// @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it
describe('the bound device', () => {
const platform = process.platform;
afterEach(() => Object.defineProperty(process, 'platform', { value: platform }));
const running = {
url: 'http://127.0.0.1:8393',
port: 8393,
Expand Down Expand Up @@ -720,6 +723,38 @@ describe('the bound device', () => {
});
expect(error.message).toBe(refusal.message);
});

it("gives the child's --device refusal the caller's flags, --detach and --wait-ready too", async () => {
vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(null);
const argv = ['--ios', '--device', 'Sim', '--detach', '--wait-ready', '--port', '8092'];
let spawned: { args: readonly string[]; env: NodeJS.ProcessEnv } | undefined;
vi.mocked(spawn).mockImplementation(((
_command: string,
args: string[],
options: { env: NodeJS.ProcessEnv }
) => {
spawned = { args, env: options.env };
const child = Object.assign(new EventEmitter(), { pid: 4242, unref: vi.fn() });
setTimeout(() => child.emit('exit', 1, null), 50);
return child as unknown as ChildProcess;
}) as typeof spawn);

const result = devDetachAsync(projectRoot, resolveDevOptions(argv), { print: false }).catch(
() => null
);
await vi.runAllTimersAsync();
await result;

const childArgv = spawned!.args.slice(spawned!.args.indexOf('dev') + 1);
vi.stubEnv('__EXPO_AGENT_CLI_DETACHED', spawned!.env.__EXPO_AGENT_CLI_DETACHED);
vi.stubEnv(CALLER_ARGV_ENV, spawned!.env[CALLER_ARGV_ENV]);
try {
expect(childArgv).not.toContain('--detach');
expect(resolveDevOptions(childArgv).callerArgv).toEqual(argv);
} finally {
vi.unstubAllEnvs();
}
});
});

/** The log a detached child writes for `error`, line by line, as `logCmdError` prints it. */
Expand Down
Loading
Loading