Skip to content

[agent-cli] dev --device binds a device the user names (llp/0035 §--device on dev) - #151

Merged
vonovak merged 18 commits into
vonovak/lifecycle-4-reapfrom
vonovak/lifecycle-5-device-flag
Oct 15, 2026
Merged

vonovak merged 18 commits into
vonovak/lifecycle-4-reapfrom
vonovak/lifecycle-5-device-flag

Conversation

@vonovak

@vonovak vonovak commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Why

A person wants the agent on their own simulator or USB phone. Without this PR there is no way to name one. Two worktrees must not share it: if worktree A holds dogfood-explicit, dev --device dogfood-explicit in worktree B must refuse. No release or reap may shut it down.

What changes

  • dev --device <name|udid|serial> binds that device as explicit. Release and reap never shut it down, delete it or kill it.
  • It refuses a device another live worktree holds (exit 20), an ambiguous name (lists the matches), and a second device while this worktree's dev server runs (names both).
  • It takes over another worktree's expired binding, and deletes the own created simulator it replaces.
  • An Android serial needs only adb devices -l, not the emulator package.
  • dev --plan --device prints a Device: line (bind, take over, delete, or refuse), and JSON explicitDevice. A refusing plan offers the refusal's How and exits 0.
  • A How that repeats the caller's dev command keeps its flags and adds --detach --wait-ready.

Review guide

  1. llp/0033 §--device on dev.
  2. src/deviceBinding/explicit.ts. Look closely at readExplicitChoice and bindExplicitSection (transfer and delete under the lock).
  3. index.ts, preview.ts, emulator.ts, src/plan/rerun.ts, resolveOptions.ts (reads __EXPO_AGENT_CLI_CALLER_ARGV once).
  4. Tests: explicit-test.ts, resolveOptions-test.ts, devAsync-test.ts.

Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack.

Stacked on PR D (vonovak/lifecycle-4-reap).

Validation

  • Unit: 5076 pass. The adb-only test failed in preview and acquisition before the fix.
  • E2E on macOS: device-lifecycle-test gains the plan block; the log records e2e green, without counts.
  • Dogfood: sessions 1, 2, 3, 5 and 7. A concurrent --device race had one winner. Session 7 checked the How lines.
  • Not run: no USB phone. Live tier with AGENT_CLI_LIVE_UDID not run.

Notes

  • Commit 2 fixes Kudo's finding (explicit Android needed the emulator package).
  • explicitDevice gives the device facts as a sentence, not fields (M3, open).
  • General changes: plan follow-ups drop repeated commands, and PORT_TAKEN_AFTER_RETRY uses rerunDevCommand.

🤖 Generated with Claude Code

@vonovak
vonovak added this pull request to stack #150 October 13, 2026 13:57
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from fe2f781 to 10dae9f Compare October 13, 2026 15:47
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from 10dae9f to bd8e010 Compare October 13, 2026 16:24
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from bd8e010 to 0edd118 Compare October 13, 2026 19:25
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch 3 times, most recently from 2d11115 to eeda9d5 Compare October 14, 2026 08:31
@vonovak
vonovak marked this pull request as ready for review October 14, 2026 08:32
@vonovak
vonovak requested a review from Kudo October 14, 2026 08:33
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from eeda9d5 to 71e220f Compare October 14, 2026 09:26
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from 71e220f to a79fa7b Compare October 14, 2026 14:02
...withDevice(plan, deviceIdOf(device)),
explicitDevice: { refuses: false, summary },
};
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] dev --plan --device catches every preview failure, stores it as explicitDevice.refuses, and returns 0. That includes create-timeout (exit 22 on a real run), an unreadable binding, and tool failures such as ADB_NOT_RUNNABLE. Those are not device-choice refusals. refusal() and the tool errors leave suggestedCommand unset, so how is null: the Device line keeps only the first sentence, the How is dropped, and the dev follow-up is skipped because refuses is true.

Suggestion: Catch only explicit-not-found, explicit-bound, own-explicit-over-owned, and not-reusable. Rethrow tool, timeout, and unreadable errors so --plan fails the same way the run does.

* What `dev --device` does to devices when this plan runs, or the refusal it will meet, with the
* refusal's How command. Read without writes or boots. Absent without `--device`.
*/
explicitDevice?:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] explicitDevice is part of the plan, and dev --plan --json prints it because emitStartPlan spreads the plan object. The cli:start_plan event still sends only the fields declared in src/plan/events.ts (src/plan/emit.ts builds that payload and does not copy explicitDevice). An agent that reads the event stream sees a normal plan when this one will refuse or bind a named device.

Suggestion: Add explicitDevice to the cli:start_plan payload, null when the plan has none, the same way devServerPort is copied.

if (!isCurrent()) return null;
return bindAndroidSectionAsync(projectRoot, { inventory, reuseOnly, tools });
}
const candidates = inventory.runningSerials.map((serial) => ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Android --device candidates are only runningSerials. parseAndroidDevices keeps state device and drops unauthorized and offline. preview.ts builds the same list, and listedSerials is parsed but not used for the match. A serial adb lists in one of those states, including this worktree's own explicit USB phone, becomes explicit-not-found ("No device uniquely matches"). The How says to run adb devices, which shows that serial, and the same --device fails again. The owned spawned emulator is the only exception, and only while its pid is alive.

Suggestion: If the query equals a listed serial whose state is not device, refuse with that state and a How that says to authorize or reconnect. Do not call it a miss. Keep state device as the set that can actually be bound.

platform,
own ? { bound: deviceNameOf(own.device), named: deviceNameOf(device) } : undefined
);
if (own?.device.backend === 'local-android' && own.device.origin.kind === 'spawned')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] own-explicit-over-owned applies to every own spawned binding, with no pid check. A dead emulator still rejects --device <other serial> with "This worktree runs the emulator instance …", which is false. Reap skips this worktree's own files, so the dead binding is not removed on the way out. Without --device, a dead pid is already treated as gone and replaced. Naming that same dead serial is also wrong: it is not added to the candidates, so it is reported as no match.

Suggestion: Throw own-explicit-over-owned only when tools.isPidAlive(emulatorPid). When the pid is dead, drop the binding and continue the explicit choice.

): Promise<StartPlan> {
const selectedPlatform = boundPlatform(options);
const explicit = explicitRequestOf(options.device, options.callerArgv);
if (selectedPlatform && explicit) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Plan mode previews --device and returns before the implicit-EAS stop. On a host that can see a device but cannot build here, dev --android --device <serial> --plan (adb present, no toolchain) or the iOS equivalent can print "Will bind …" and pin run / install steps. The same command without --plan throws EAS_ROUTE_NOT_CHOSEN and never acquires. A choice refusal is also printed for a check the run never reaches. The new EAS follow-up strips --device and says the local device is not used, which contradicts the Device line.

Suggestion: When plan.buildLocation is an implicit EAS route, skip the device preview and leave explicitDevice unset. Keep the follow-up that drops --device.


return {
mode: argv.includes('--plan') ? 'plan' : 'run',
...(device === undefined ? {} : { device }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] --device is accepted for every platform. --eas is rejected, but --web, AGENT_CLI_NO_DEVICE, and a host that cannot bind (boundPlatform is null) keep the flag and then ignore it. There is no preview and no error. The plan follow-up still repeats --device, because explicitDevice was never set, so the suggested command names a device the run will not bind.

Suggestion: Reject --device with --web as BAD_ARGS, as with --eas. When this host will not bind the named platform, refuse with that reason instead of starting a server and dropping the selection.

vonovak and others added 5 commits October 15, 2026 08:34
…evice on dev)

A person who wants the agent on their own simulator or a USB phone names it with
`dev --device <name|udid|serial>`. The CLI binds that device as `explicit`: release
and reap never shut it down, delete it or kill it. A device bound to another live
worktree is refused, and `dev --plan` says so before anything runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or package (llp/0033 §--device on dev)

`dev --device <serial>` read the AVD list with `emulator -list-avds` before it
matched the serial, so a ready USB phone on a host with no emulator package failed
with EMULATOR_NOT_RUNNABLE, in the plan preview and in acquisition (Kudo, review
5491842950). An explicit selection matches only `adb devices -l` serials, so it
now reads only that list. The spawn path still reads the AVDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in its refusal (llp/0033 §--device on dev)

With this worktree's dev server running and a live binding, `dev --device <other>`
refused not-reusable with "this worktree has no ios device to reuse", which was
false (dogfood session 1, F7). The refusal now names the device the running
server uses and the one --device names. The code, exit 20 and the dev:stop How
line stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efusal leads with its How (llp/0033 §--device on dev)

A plan that would refuse --device put "Will refuse" as the last Why bullet and
still suggested running the plan (dogfood session 1, F8). A plan that would bind
said nothing about devices, so it hid the delete of the own created simulator and
the takeover of another worktree's expired binding (F6).

The plan now carries explicitDevice from the same readExplicitChoice result and
prints it as a Device line under the steps: "Will bind <name> (<id>)", the
worktree it takes over from, and the simulator it deletes; or "Will refuse:
<reason>". A refusing plan offers the refusal's How command (now also its
suggestedCommand) instead of `dev --<platform>`, and still exits 0. The preview
writes nothing and boots nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… §--device on dev)

The Device line read "Will refuse: <reason>", and a refusal whose How command
equals another follow-up's command (status --json from project-context) listed
that command twice. The line now reads "Will refuse — <reason>", matching the
Build line's dash, and the plan follow-ups drop a later entry whose command
repeats an earlier one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vonovak and others added 13 commits October 15, 2026 08:34
…nd, and a plan next to a running server refuses as the run does (llp/0033 §--device on dev)

Dogfood session 2 found three gaps. With this worktree's dev server running,
`dev --device X --plan` said "Will bind" while the run refused not-reusable,
and its follow-up `dev --ios` dropped --device and --port (N4). An ambiguous
--device sent the agent to `status --json`, which lists no simulators (N5).
The explicit-bound How dropped the caller's --port (F10).

The plan now reads the choice reuse-only when the dev-server lock answers,
the same test the detached run uses. A bind plan's dev follow-up keeps
--device and a named --port. acquireDeviceAsync and the preview take
explicit as { query, rerun }, where rerun is the caller's arguments without
--device and --plan: explicit-bound repeats that command, an ambiguous match
repeats it with --device <first match>, and no match points at
`xcrun simctl list devices available` or `adb devices`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…detach, --wait-ready and --json (llp/0033 §--device on dev)

The detached child raises the refusal, and its own argv lacks the flags the
parent strips. The parent now passes its argv in __EXPO_AGENT_CLI_CALLER_ARGV,
and explicitRequestOf builds the How command from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m one helper, runs detached, and reads the caller argv once (llp/0033 §--device on dev)

Review M1: four builders repeated the caller's `dev` command with
different results. The plan refusal and bind follow-up suggested a
foreground `dev`, which blocks an agent, and the bind follow-up dropped
--local. `rerunDevCommand` (src/plan/rerun.ts) is now the one builder: the
platform first, --plan dropped, an optional --device added, and
--detach --wait-ready added when the caller had no --detach. The explicit
refusals, the plan's `dev` follow-up and PORT_TAKEN_AFTER_RETRY use it;
their local copies are deleted.

Review M2: `__EXPO_AGENT_CLI_CALLER_ARGV` was parsed unguarded in a helper
and inherited by every subprocess. `resolveDevOptions` now reads it once
into `callerArgv`, only under __EXPO_AGENT_CLI_DETACHED=1, falls back to
the own argv when it is malformed, and removes it from the environment.
The port-taken How in a detached child uses the same caller argv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuse foreground device replacement while the worktree server runs. Reuse bound USB devices without the emulator package, rechecking the binding before acquisition. Remove local device arguments from suggested EAS runs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e, null without --device (llp/0035 §--device on dev)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and fails as the run does on a tool error, a timeout or an unreadable binding (llp/0035 §--device on dev)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…licit, because the run stops before it binds one (llp/0035 §--device on dev)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…local device, before the probe (llp/0035 §--device on dev)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e is gone, and only a live own instance refuses own-explicit-over-owned (llp/0035 §Choice)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d refuses explicit-not-ready (exit 7) and names what readies it (llp/0035 §Choice)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very, and the recheck under the lock tests the same listing (llp/0035 §Choice)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne state (llp/0035 §Choice)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/lifecycle-5-device-flag branch from a79fa7b to c9884e3 Compare October 15, 2026 06:47
@vonovak vonovak changed the title [agent-cli] dev --device binds a device the user names (llp/0033 §--device on dev) [agent-cli] dev --device binds a device the user names (llp/0035 §--device on dev) Oct 15, 2026
@vonovak
vonovak merged commit d40eb26 into main Oct 15, 2026
15 checks passed
@vonovak
vonovak deleted the vonovak/lifecycle-5-device-flag branch October 15, 2026 11:54
vonovak added a commit that referenced this pull request Oct 15, 2026
…b uses it (llp/0035 §iOS binding) (#134)

PR 3a of llp/0035 (one device per worktree), based on main after the
prep in #133 merged. Review bottom-up in stack #150: #134 → #137 → #146
→ #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is
separate from this stack. The five design files `llp/0035` to `llp/0035
§EAS session binding` land in the first commit.

## The question

How does a worktree get an iOS simulator nobody else has, and how do the
other verbs find it?

## What changes for an agent

- `dev --ios` binds a simulator this CLI created for the worktree, named
`agent-cli <digest>`, boots it with `simctl bootstatus -b`, and runs
every `expo run:ios` and install step with `--device <udid>`. The
binding is a JSON file under `<expo home>/agent-cli/bindings/`, held by
a 60 min lease that use renews; every write runs under one machine-wide
lock. Two worktrees get two simulators.
- `navigate`, `runtime:reload`, `runtime:stop`, `status` and the
installed-app readers read the binding and never list devices to choose
one. Without a platform flag, any `up` binding wins. A worktree with no
usable binding is refused with `NO_BOUND_DEVICE` and the one `dev`
command that fixes it. Main's `NO_DEVICE` and `NO_IOS_DEVICE` are gone.
- `dev --detach` on a running server boots the worktree's own parked
simulator and refuses `not-reusable` otherwise. A device refusal in the
detached child reaches the agent with its own code, exit and How line,
through the child verdict.
- `smoke --ios` takes its simulator from the binding and leaves it
bound; it no longer shuts down the simulator it booted.
- Android is unchanged until 3b: its first-`adb`-device rung is injected
into the rung loop.
- The EAS fallback without `--eas` accepts the `.env.eas-simulator` id
only, until PR 5 binds sessions.

## Commits

1. The module `src/deviceBinding/` with its unit tests and the five
LLPs. No caller.
2. The wiring: the resolver callback, `withDevice`, the detach relay,
the rung loop in every reader, smoke, the deletions.
3. The e2e suite, the LLP sentences that said "first booted", and the
changelog.

## Decisions taken while implementing (to fold back into llp/0035 §iOS
binding)

- The child verdict carries the error code and exit (`Error code <CODE>
exit <N>`), written only when `__EXPO_AGENT_CLI_DETACHED=1`.
- A failed boot is `DEVICE_UNAVAILABLE` reason `boot-failed`, exit 20,
"run the command again".
- `withDevice` runs after the `--plan` return and the refusals; main's
port block stays before `--plan`, because the plan prints the port.
- Smoke's install phase now honours `installNeeded` from the boot
result: a created simulator's disk holds nothing.
- `status`'s next action still names the plan's `dev --ios` command, not
the `--detach --wait-ready` form; changing it touches every status
snapshot.
- `src/deviceBinding/` has `ios.ts`, `errors.ts`, `events.ts` and
`tools.ts` beyond the RFC's file list, all within the 250-line budget.

## Verification

- Typecheck, lint (no new warnings), `bun run format:check`.
- Unit: 229 files, 4842 tests passed. Every test named in llp/0035 §iOS
tests exists under that name.
- Build, then e2e: 41 files, 835 tests passed; the device cases run with
`AGENT_CLI_NO_DEVICE=0` on macOS.
- Not yet run: the live checks of llp/0035 §iOS tests (two real
worktrees, `navigate` wall time, a cold `simctl create`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
…137)

Each worktree gets its own read-only Android emulator instance on a free
console port. `dev --android` acquires it, build/install steps receive
its serial, and device commands use the recorded binding instead of
whichever emulator happens to be first.

The registry reserves ports held by every listed emulator, including
booting/offline instances. Cleanup checks whole `-avd` and `-ports`
values before killing an owned process, and validates the binding
identity so release or failed-boot cleanup cannot remove a replacement
binding. Windows install-test coverage is restored.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #134. The four review-fix commits are included in this PR,
ending at `95a1184a`.

## Validation

The split was previously checked with typechecks, formatting, lint and
unit tests on each layer, plus focused lifecycle/dev/stop/smoke
integration tests and local-device dogfooding. This publication
preserves the reviewed branch tip; tests were not rerun for the stack
and PR-description changes.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
Devices have a 60-minute lease, but a long-running Metro or native build
may go longer without a device command. Renew the worktree's leases
every five minutes while the dev-server or build runner is active,
including bindings created after the runner starts. Renewal stops with
the runner; a lost lease is reported once.

The wrappers cover `dev`, detached `dev`, `start`, and build steps.
`AGENT_CLI_NO_DEVICE=1` disables renewal. Timer and wiring tests cover
lease renewal. The design documents describe lifecycle behavior and
dependencies.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #137 (`vonovak/android-device-instance`).

## Validation

- Unit: 5,006 pass, including timer tests and two wiring tests that fail
if either wrapper is removed.
- E2E: `dev-test` and `dev-detach-test`, 73 of 73.
- Dogfooding: with Metro running and no verb in between, the binding's
`expiresAt` moved forward at +5 and +10 minutes (read from the binding
file).
- Documentation update: repository formatting, document links and
stale-reference checks pass.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
Stopping Metro can leave the worktree's devices allocated. `dev:stop
--release` shuts down and parks a created iOS simulator or ends a
spawned Android emulator after confirming this worktree's server has
stopped. Plain `dev:stop` keeps devices; explicitly selected devices are
preserved. A remaining or replacement server prevents release.

When stop refuses because ownership cannot be verified, JSON now reports
the observed process liveness and port occupancy instead of falsely
reporting that both are gone. Unmeasured facts are `null`. Missing lock
evidence no longer claims the server was never started by the CLI.
Signal authorization and device-release policy are unchanged.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #146. Binding-identity and failed-boot cleanup protections come
from #137.

## Validation

- 47 focused stop/release unit tests and 14 built-CLI stop tests passed;
new regressions failed before the reporting fix.
- At the integrated #152 tip: 5,138 unit tests across 241 files and 136
built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build,
typecheck, lint (existing warnings only), and formatting passed. The
selected cold simulator creation/install/release smoke test passed in
127.55 seconds. The full smoke suite was not completed; partial bounded
runs are not a full-suite pass. These fix regressions use
subprocess/device stubs; native acceptance was not repeated for this
source-fix pass.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
An unattended smoke run should return the device resources it acquired.
Release a device bound by smoke's started dev server through `dev:stop
--release`, or release the boot phase's own binding when reusing an
existing server. A device already bound and running before the run stays
available; `--no-start` acquires and releases nothing.

Name the bound device and its cleanup result in human output and
`environment.boundDevice`. Follow-up commands account for whether the
run stopped its server or released its device, and preserve the caller's
port. Tests cover replacement bindings, reused servers, kept devices and
iOS/Android restart guidance.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #147 (B, `vonovak/lifecycle-2-release`).

## Validation

This layer is rebased over the stop-reporting correction in #147; its
smoke implementation is unchanged. At the integrated #152 tip: 5,138
unit tests across 241 files and 136 built-CLI
dev-detach/status/device-lifecycle/stop tests passed. Build, typecheck,
lint (existing warnings only), and formatting passed. The selected cold
simulator creation/install/release smoke test passed in 127.55 seconds.
The full smoke suite was not completed; partial bounded runs are not a
full-suite pass. These fix regressions use subprocess/device stubs;
native acceptance was not repeated for this source-fix pass.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
A deleted worktree cannot run `dev:stop --release`, and expired leases
leave device resources behind. Device acquisition and `dev:stop` reap
other worktrees' stale bindings: delete created simulators for deleted
roots, shut down expired simulators when an iOS inventory is available,
and end owned Android emulator instances. Explicit devices are
preserved.

Registry changes happen under the lock; device actions run afterward,
including when acquisition refuses. Results appear under `reaped` in
`dev:stop --json`. Cross-worktree results identify `projectRoot` and
report `devServerCleanup: "not-checked"`; deleted-root human output also
explains that CLI/Metro processes may remain. A late last-build metadata
write skips an already deleted project root.

## Scope and limits

Reaping uses lease expiry as its liveness signal. After a long host
sleep it can park a simulator while Metro still runs. This PR cleans
device resources; it does not terminate orphan CLI/Metro processes after
worktree deletion. Safe server cleanup needs durable server ownership
information and remains a separate follow-up. The metadata guard does
not make the existing write sequence atomic against simultaneous
directory deletion.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #148. The first commit extracts iOS acquisition; subsequent
changes implement reaping and clarify its reporting.

## Validation

- 152 device-binding/last-build/stop-lifecycle unit tests and two
built-CLI device-lifecycle tests passed. Reporting and deleted-root
write regressions failed before the fixes.
- At the integrated #152 tip: 5,138 unit tests across 241 files and 136
built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build,
typecheck, lint (existing warnings only), and formatting passed. The
selected cold simulator creation/install/release smoke test passed in
127.55 seconds. The full smoke suite was not completed; partial bounded
runs are not a full-suite pass. These fix regressions use
subprocess/device stubs; native acceptance was not repeated for this
source-fix pass.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 15, 2026
…tatus sends an unbound worktree to dev (llp/0035 §Lifecycle output) (#152)

`status` can recommend the wrong platform or lose a chosen Metro port
when a startup has not published its lock or its server has stopped. It
can also recommend device commands for a worktree whose device lease has
expired. This PR reports the worktree's binding and produces recovery
commands using the known development context.

## Behavior

- `status --json` exposes each binding's platform, backend, identity,
origin, state and expiry. Detached `dev` names its bound device in human
output and under `device` in JSON.
- A real `dev` invocation retains only parsed platform, requested port,
device, build backend and app target in `.expo/agent-cli-last-dev.json`.
Dry runs do not write it. This record is a recovery hint, not
process-liveness evidence.
- Status chooses the explicit internal platform override, then the saved
request, then a sole bound platform, then the project default. A
different explicit platform discards the other saved choices. No new
public platform flags are added to `status`.
- Recovery preserves an explicit local status port, the current
own-server port, or the saved requested port. A port observed serving
another project is omitted. Remote URL ports are not treated as local
Metro requests.
- Recovery uses #151's quoted retry builder and restores the dev plan
steps when replacing a verification action. `rule` and `target` retain
their documented plan meaning.
- Rebinding guidance preserves the connected-app, web, EAS-session and
unsupported-device-host exceptions. A worktree without a binding but
with its own running server is told to stop that server before
restarting to bind a device.

## Stack

Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149
→ #151 → #152. EAS session binding (#139) is separate from this stack.

Based on #151. Review `src/dev/lastRequest.ts`,
`src/status/statusAsync.ts`, `src/deviceBinding/summary.ts`, and
detached dev's device reporting.

## Validation

- Android startup without a lock, sole Android binding, stopped-request
recovery, explicit overrides, device-name quoting, corrupt/unwritable
context, dry-run behavior, foreign ports and restored plan steps have
regression coverage.
- A built-CLI Android start → stop → status test verifies retained
platform, port and target choices.
- At the integrated #152 tip: 5,138 unit tests across 241 files and 136
built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build,
typecheck, lint (existing warnings only), and formatting passed. The
selected cold simulator creation/install/release smoke test passed in
127.55 seconds. The full smoke suite was not completed; partial bounded
runs are not a full-suite pass. These fix regressions use
subprocess/device stubs; native acceptance was not repeated for this
source-fix pass.

The saved request does not establish that a native build is running or
fix separate startup-ownership issues. Device origin describes how it
became bound, not what the current invocation did.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants