apps/server/src/device/LocalDeviceHost.ts

import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts";
import { deviceToolInstallMessage } from "@t3tools/contracts";
/**
 * The device host that is this machine.
 *
 * Runs expo-device-hub as a supervised child on a loopback port and starts the
 * agent-device daemon in HTTP mode under a T3-owned state directory. Both are
 * lazy: the device service requires explicit setup consent before it calls
 * ensureReady to install tools or start helper processes.
 *
 * The hub runs in its standalone mode (origin root). The T3 proxy strips its
 * own prefix, and the Device panel derives stream and socket URLs from the
 * prefix itself rather than from anything the hub prints.
 */
import {
  type DeviceHostSummary,
  type DevicePlatform,
  type DevicePlatformAvailability,
  LOCAL_DEVICE_HOST_ID,
} from "@t3tools/contracts";
import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
  resolveNodeExecutable,
  type NodeRuntimeUnavailableError,
} from "@t3tools/shared/nodeRuntime";
import * as NetService from "@t3tools/shared/Net";
import { isCommandAvailable } from "@t3tools/shared/shell";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/unstable/http";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as DeviceHost from "./DeviceHost.ts";
import {
  agentDeviceStateDir,
  type DeviceToolPaths,
  ensureAgentDevice,
  ensureDeviceHub,
  isAgentDeviceInstalled,
  isDeviceHubInstalled,
  deviceToolVersions,
  DEVICE_HUB_VERSION,
} from "./DeviceToolchain.ts";

const HUB_READY_TIMEOUT_MS = 30_000;
const DAEMON_READY_TIMEOUT_MS = 30_000;
const DAEMON_POLL_MS = 100;
const HUB_RESTART_STABLE_UPTIME_MS = 60_000;
const HUB_RESTART_MAX_DELAY_MS = 30_000;

/**
 * Written beside the agent-device state so a server that dies without running
 * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to
 * a loopback port forever. The next start reads it, kills only a process that
 * is still that hub, and replaces the file.
 */
const HubStateFile = Schema.Struct({
  pid: Schema.Int,
  port: Schema.Int,
  entryPath: Schema.String,
});
const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile));
const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile));

const AgentDeviceDaemonFile = Schema.Struct({
  httpPort: Schema.Int,
  token: Schema.String,
  pid: Schema.optional(Schema.Int),
  version: Schema.optional(Schema.String),
});
const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));

interface HubProcess {
  readonly nodePath: string;
  readonly child: ChildProcessSpawner.ChildProcessHandle;
  readonly scope: Scope.Closeable;
  readonly origin: string;
  readonly startedAtMillis: number;
}

interface RunningHost {
  readonly hub: HubProcess;
  readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null;
  readonly helpers: DeviceHost.DeviceHostReady["helpers"];
}

const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* (
  platform: DevicePlatform,
): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
  const hostPlatform = yield* HostProcessPlatform;
  if (platform === "ios") {
    if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode.";
    if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found.";
    return null;
  }
  const sdk = yield* androidSdk;
  if (!sdk.root)
    return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory.";
  if (!sdk.adb)
    return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
  if (!sdk.emulator)
    return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`;
  if (!sdk.avdmanager) {
    if (sdk.legacyAvdmanager)
      return `The Android SDK command-line tools in ${sdk.root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.`;
    return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
  }
  return null;
});

/** Resolve the SDK once for both diagnostics and the environment passed to helpers. */
const androidSdk = Effect.gen(function* () {
  const fs = yield* FileSystem.FileSystem;
  const path = yield* Path.Path;
  const environment = yield* HostProcessEnvironment;
  const platform = yield* HostProcessPlatform;
  const home = environment.HOME ?? environment.USERPROFILE ?? "";
  const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim();
  const candidates = explicit
    ? [explicit]
    : [
        path.join(home, "Library", "Android", "sdk"),
        path.join(home, "Android", "Sdk"),
        path.join(
          environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
          "Android",
          "Sdk",
        ),
      ];
  if (!explicit) {
    for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) {
      if (!directory) continue;
      const resolved = yield* fs
        .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb"))
        .pipe(Effect.option);
      if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value)));
    }
  }
  const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
  for (const root of candidates) {
    const adb = yield* exists(
      path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"),
    );
    const emulator = yield* exists(
      path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"),
    );
    if (explicit || adb || emulator) {
      const avdmanager = yield* exists(
        path.join(
          root,
          "cmdline-tools",
          "latest",
          "bin",
          platform === "win32" ? "avdmanager.bat" : "avdmanager",
        ),
      );
      const legacyAvdmanager =
        !avdmanager &&
        (yield* exists(
          path.join(root, "tools", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager"),
        ));
      return { root, adb, emulator, avdmanager, legacyAvdmanager };
    }
  }
  return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false };
});

const deviceHostEnvironment = (
  environment: NodeJS.ProcessEnv,
  sdkRoot: string | null,
  hostPlatform: NodeJS.Platform,
  path: Path.Path,
): NodeJS.ProcessEnv => {
  return sdkRoot
    ? {
        ...environment,
        ANDROID_HOME: sdkRoot,
        PATH: [
          path.join(sdkRoot, "platform-tools"),
          path.join(sdkRoot, "emulator"),
          environment.PATH ?? environment.Path ?? "",
        ].join(hostPlatform === "win32" ? ";" : ":"),
      }
    : environment;
};

export const make = Effect.fn("LocalDeviceHost.make")(function* () {
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
  const config = yield* ServerConfig.ServerConfig;
  const path = yield* Path.Path;
  const fs = yield* FileSystem.FileSystem;
  const net = yield* NetService.NetService;
  const runner = yield* ProcessRunner.ProcessRunner;
  const httpClient = yield* HttpClient.HttpClient;
  const environment = yield* HostProcessEnvironment;
  const hostPlatform = yield* HostProcessPlatform;
  const sdk = yield* androidSdk;
  const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path);
  const startLock = yield* Semaphore.make(1);
  const runningRef = yield* Ref.make<RunningHost | null>(null);
  const restartDelayRef = yield* Ref.make(0);
  const hostId = LOCAL_DEVICE_HOST_ID;

  const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* (
    platform: DevicePlatform,
  ): Effect.fn.Return<DevicePlatformAvailability> {
    const reason = yield* platformReason(platform).pipe(
      Effect.provideService(FileSystem.FileSystem, fs),
      Effect.provideService(Path.Path, path),
    );
    return reason === null ? { platform, available: true } : { platform, available: false, reason };
  });

  const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () {
    const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([
      Effect.all([platformAvailability("ios"), platformAvailability("android")]),
      isDeviceHubInstalled(config.baseDir).pipe(
        Effect.provideService(FileSystem.FileSystem, fs),
        Effect.provideService(Path.Path, path),
      ),
      isAgentDeviceInstalled(config.baseDir).pipe(
        Effect.provideService(FileSystem.FileSystem, fs),
        Effect.provideService(Path.Path, path),
      ),
    ]);
    const running = yield* Ref.get(runningRef);
    const daemon = running?.agentDevice
      ? yield* readDaemonFile().pipe(Effect.option)
      : Option.none();
    const hubAlive = running
      ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false))
      : false;
    const agentAlive =
      Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false;
    const tools = yield* deviceToolVersions(config.baseDir, {
      ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}),
      ...(agentAlive && Option.isSome(daemon) && daemon.value.version
        ? { agent: daemon.value.version }
        : {}),
    }).pipe(
      Effect.provideService(FileSystem.FileSystem, fs),
      Effect.provideService(Path.Path, path),
    );
    return {
      tools,
      id: hostId,
      kind: "local",
      label: "This machine",
      platforms,
      hubInstalled,
      agentDeviceInstalled,
    };
  });

  const hubEnvironment = (): NodeJS.ProcessEnv => ({
    ...hostEnvironment,
    FORCE_COLOR: "0",
    NO_COLOR: "1",
  });

  const stopHub = (hub: HubProcess | undefined) =>
    hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;

  const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json");

  const isProcessAlive = (pid: number) =>
    Effect.sync(() => {
      try {
        process.kill(pid, 0);
        return true;
      } catch {
        return false;
      }
    });

  /**
   * A hub left behind by a previous server is identified by pid plus the
   * command line's entry path, so a recycled pid belonging to something else
   * is never touched.
   */
  const reapStaleHub = Effect.gen(function* () {
    const previous = yield* fs
      .readFileString(hubStatePath())
      .pipe(Effect.flatMap(decodeHubStateFile), Effect.option);
    if (previous._tag === "None") return;
    const alive = yield* isProcessAlive(previous.value.pid);
    if (alive) {
      const commandLine = yield* runner
        .run({
          command: "ps",
          args: ["-o", "command=", "-p", String(previous.value.pid)],
          timeout: Duration.seconds(5),
          timeoutBehavior: "timedOutResult",
        })
        .pipe(
          Effect.map((result) => result.stdout),
          Effect.orElseSucceed(() => ""),
        );
      if (commandLine.includes(previous.value.entryPath)) {
        yield* Effect.logWarning("Stopping a device hub left behind by a previous server", {
          pid: previous.value.pid,
          port: previous.value.port,
        });
        yield* Effect.sync(() => {
          try {
            process.kill(previous.value.pid, "SIGTERM");
          } catch {
            // Already gone.
          }
        });
      }
    }
    yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
  }).pipe(Effect.ignoreCause);

  const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) =>
    encodeHubStateFile({
      pid: Number(hub.child.pid),
      port: Number(new URL(hub.origin).port),
      entryPath: hubTool.entryPath,
    }).pipe(
      Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)),
      Effect.ignore,
    );

  const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
    hubTool: DeviceToolPaths,
    nodePath: string,
  ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
    yield* reapStaleHub;
    yield* fs
      .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true })
      .pipe(Effect.ignore);
    const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
      Effect.mapError(
        (cause) =>
          new DeviceHost.DeviceHostError({
            hostId,
            step: "reserving a port for the device hub",
            cause,
          }),
      ),
    );
    const origin = `http://127.0.0.1:${port}`;
    const scope = yield* Scope.make("sequential");
    const child = yield* spawner
      .spawn(
        ChildProcess.make(
          nodePath,
          [
            hubTool.entryPath,
            "--port",
            String(port),
            "--host",
            "127.0.0.1",
            "--hide-sidebar",
            "--hide-boot-device",
          ],
          {
            detached: false,
            shell: false,
            stdout: "pipe",
            stderr: "pipe",
            env: hubEnvironment(),
          },
        ),
      )
      .pipe(
        Effect.provideService(Scope.Scope, scope),
        Effect.mapError(
          (cause) =>
            new DeviceHost.DeviceHostError({
              hostId,
              step: "starting the device hub",
              cause,
            }),
        ),
      );
    const startedAtMillis = yield* Clock.currentTimeMillis;
    const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
    yield* Effect.forkIn(observeHubOutput(hub), scope);
    yield* waitForHttpReady({
      baseUrl: origin,
      path: "/readyz",
      timeoutMs: HUB_READY_TIMEOUT_MS,
      makeError: (info) =>
        new DeviceHost.DeviceHostError({
          hostId,
          step: "waiting for the device hub to answer",
          cause: info.cause,
        }),
    }).pipe(
      Effect.provideService(HttpClient.HttpClient, httpClient),
      Effect.tapError(() => stopHub(hub)),
    );
    yield* recordHub(hub, hubTool);
    yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
    return hub;
  });

  const observeHubOutput = (hub: HubProcess) =>
    hub.child.all.pipe(
      Stream.decodeText(),
      Stream.splitLines,
      Stream.map((line) => line.trim()),
      Stream.filter((line) => line.length > 0),
      Stream.runForEach((line) =>
        Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }),
      ),
      Effect.ignoreCause,
    );

  /**
   * Restart the hub when it dies under us, with the same doubling backoff the
   * relay connector uses so a hub that crashes on boot cannot spin.
   */
  const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> =>
    Effect.gen(function* () {
      yield* Effect.result(hub.child.exitCode);
      const running = yield* Ref.get(runningRef);
      if (running?.hub.child.pid !== hub.child.pid) return;
      const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis;
      const delay = yield* Ref.modify(restartDelayRef, (current) => {
        if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0];
        const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS);
        return [current, next];
      });
      yield* Effect.logWarning("Device hub exited; restarting", {
        pid: Number(hub.child.pid),
        delayMs: delay,
      });
      yield* Effect.sleep(Duration.millis(delay));
      yield* startLock.withPermits(1)(
        Effect.gen(function* () {
          const current = yield* Ref.get(runningRef);
          if (current?.hub.child.pid !== hub.child.pid) return;
          const replacement = yield* spawnHub(hubTool, hub.nodePath);
          yield* Ref.set(runningRef, { ...current, hub: replacement });
          yield* Effect.forkDetach(superviseHub(replacement, hubTool));
        }),
      );
    }).pipe(
      Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })),
    );

  const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json");

  const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () {
    const raw = yield* fs.readFileString(daemonFilePath());
    return yield* decodeDaemonFile(raw);
  });

  /**
   * agent-device auto-starts its daemon on any command. A trivial `devices`
   * call in HTTP mode is the documented way to bring it up; its output is the
   * daemon.json this reads back.
   */
  const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
    agentTool: DeviceToolPaths,
    nodePath: string,
  ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {
    const stateDir = agentDeviceStateDir(path, config.stateDir);
    yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
    const existing = yield* readDaemonFile().pipe(Effect.option);
    const daemonEnvironment: NodeJS.ProcessEnv = {
      ...hostEnvironment,
      AGENT_DEVICE_STATE_DIR: stateDir,
      AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
      // The daemon idles out after five minutes by default; the server owns
      // its lifetime here and stops it explicitly.
      AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0",
      AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
      FORCE_COLOR: "0",
      NO_COLOR: "1",
    };
    const toEndpoint = (
      file: typeof AgentDeviceDaemonFile.Type,
    ): DeviceHost.AgentDeviceEndpoint => ({
      baseUrl: `http://127.0.0.1:${file.httpPort}`,
      token: file.token,
      entryPath: agentTool.entryPath,
    });
    if (existing._tag === "Some") {
      const alive = yield* HttpClient.withScope(httpClient)
        .get(`http://127.0.0.1:${existing.value.httpPort}/health`)
        .pipe(
          Effect.timeout(Duration.seconds(2)),
          Effect.flatMap((response) =>
            response.arrayBuffer.pipe(Effect.as(response.status === 200)),
          ),
          Effect.scoped,
          Effect.orElseSucceed(() => false),
        );
      if (alive) return toEndpoint(existing.value);
      yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore);
    }
    // There is no `daemon start`; the first command in a state dir spawns the
    // daemon and blocks until it answers. `devices` is the cheapest one.
    yield* runner
      .run({
        command: nodePath,
        args: [agentTool.entryPath, "devices", "--json"],
        env: daemonEnvironment,
        timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
        timeoutBehavior: "timedOutResult",
      })
      .pipe(Effect.ignore);
    const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS;
    while (true) {
      const file = yield* readDaemonFile().pipe(Effect.option);
      if (file._tag === "Some") return toEndpoint(file.value);
      if ((yield* Clock.currentTimeMillis) > deadline) {
        return yield* new DeviceHost.DeviceHostTimeoutError({
          hostId,
          timeoutMs: DAEMON_READY_TIMEOUT_MS,
        });
      }
      yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS));
    }
  });

  const stopAgentDeviceDaemon = (
    agentTool: { readonly entryPath: string; readonly nodePath: string } | null,
  ) =>
    agentTool
      ? runner
          .run({
            command: agentTool.nodePath,
            args: [
              agentTool.entryPath,
              "daemon",
              "stop",
              "--state-dir",
              agentDeviceStateDir(path, config.stateDir),
            ],
            env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
            timeout: Duration.seconds(10),
            timeoutBehavior: "timedOutResult",
          })
          .pipe(Effect.ignore)
      : Effect.void;

  let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;

  const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
    onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
  ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
    const running = yield* Ref.get(runningRef);
    if (running) {
      const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
      if (alive) return running;
      yield* Ref.set(runningRef, null);
    }
    const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
      Effect.provideService(FileSystem.FileSystem, fs),
      Effect.provideService(Path.Path, path),
      Effect.provideService(HostProcessPlatform, hostPlatform),
    );
    const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
      Effect.provideService(FileSystem.FileSystem, fs),
      Effect.provideService(Path.Path, path),
    );
    if (!installed) {
      const inventory = yield* summary;
      yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub));
    }
    const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
      Effect.provideService(FileSystem.FileSystem, fs),
      Effect.provideService(Path.Path, path),
      Effect.provideService(ProcessRunner.ProcessRunner, runner),
      Effect.mapError(
        (cause) =>
          new DeviceHost.DeviceHostError({
            hostId,
            step: "installing device support",
            cause,
          }),
      ),
    );
    yield* onPhase("starting");
    const hub = yield* spawnHub(hubTool, nodePath);
    yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe(
      Effect.provideService(Path.Path, path),
      Effect.provideService(ProcessRunner.ProcessRunner, runner),
      Effect.ignore,
    );
    const candidate = helperPaths(hubTool);
    const [axExists, cliExists] = yield* Effect.all([
      fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
      fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
    ]);
    const next: RunningHost = {
      hub,
      agentDevice: null,
      helpers: {
        serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
        serveSimCli: cliExists ? candidate.serveSimCli : null,
      },
    };
    yield* Ref.set(runningRef, next);
    yield* Ref.set(restartDelayRef, 0);
    yield* Effect.forkDetach(superviseHub(hub, hubTool));
    return next;
  });

  const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
    startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady)));

  const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
    startLock.withPermits(1)(
      Effect.gen(function* (): Generator<
        Effect.Effect<
          unknown,
          | DeviceHost.DeviceHostError
          | DeviceHost.DeviceHostTimeoutError
          | NodeRuntimeUnavailableError
        >,
        DeviceHost.DeviceHostAgentReady
      > {
        const running = yield* ensureHubReady(onPhase);
        if (running.agentDevice) return { ...toReady(running), agentDevice: running.agentDevice };
        const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe(
          Effect.provideService(FileSystem.FileSystem, fs),
          Effect.provideService(Path.Path, path),
        );
        if (!installed) {
          const inventory = yield* summary;
          yield* onPhase(
            "installing",
            deviceToolInstallMessage("agent tools", inventory.tools?.agent),
          );
        }
        const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
          Effect.provideService(FileSystem.FileSystem, fs),
          Effect.provideService(Path.Path, path),
          Effect.provideService(ProcessRunner.ProcessRunner, runner),
          Effect.mapError(
            (cause) =>
              new DeviceHost.DeviceHostError({
                hostId,
                step: "installing agent tools",
                cause,
              }),
          ),
        );
        agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath };
        yield* onPhase("starting");
        const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath);
        yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe(
          Effect.provideService(Path.Path, path),
          Effect.provideService(ProcessRunner.ProcessRunner, runner),
          Effect.ignore,
        );
        const next = { ...running, agentDevice };
        yield* Ref.set(runningRef, next);
        return { ...toReady(next), agentDevice };
      }),
    );

  const helperPaths = (hubTool: DeviceToolPaths) => {
    const serveSimDist = path.join(
      hubTool.installDir,
      "node_modules",
      "expo-device-hub",
      "vendor",
      "serve-sim",
      "dist",
    );
    return {
      serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"),
      serveSimCli: path.join(serveSimDist, "serve-sim.js"),
    };
  };

  const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
    runner
      .run({
        command:
          command === "emulator" && sdk.root
            ? path.join(
                sdk.root,
                "emulator",
                hostPlatform === "win32" ? "emulator.exe" : "emulator",
              )
            : command,
        args,
        env: hostEnvironment,
        timeout: Duration.millis(options?.timeoutMs ?? 20_000),
        timeoutBehavior: "timedOutResult",
        ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
      })
      .pipe(
        Effect.map((result) => ({
          stdout: result.stdout,
          stderr: result.stderr,
          code: Number(result.code),
        })),
        Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })),
      );

  const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
    hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
    nodePath: running.hub.nodePath,
    run,
    helpers: running.helpers,
  });

  const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe(
    Effect.map((running) => (running ? toReady(running) : null)),
  );

  const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)(
    Effect.gen(function* () {
      yield* stopAgentDeviceDaemon(agentToolRef);
      yield* Ref.update(runningRef, (running) =>
        running ? { ...running, agentDevice: null } : running,
      );
    }),
  );

  const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)(
    Effect.gen(function* () {
      const running = yield* Ref.getAndSet(runningRef, null);
      yield* stopHub(running?.hub);
      yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
      yield* stopAgentDeviceDaemon(agentToolRef);
    }),
  );

  // Never leave the hub or daemon behind when the server's scope closes.
  yield* Effect.addFinalizer(() => stop);

  const host: DeviceHost.DeviceHost["Service"] = {
    id: hostId,
    summary,
    platformAvailability,
    ensureReady,
    ensureAgentReady,
    current,
    stopAgent,
    stop,
  };
  return host;
});

export const layer = Layer.effect(DeviceHost.DeviceHost, make());

/** Exposed for tests. */
export const __testing = {
  AgentDeviceDaemonFile,
  androidSdk,
  platformReason,
  deviceHostEnvironment,
};