npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stim-cli/server

v1.20.2

Published

Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.

Readme

@stim-cli/server

stim-server serves Stim state to paired clients, such as the Stim phone app, and lets the clients the Mac grants control run stim reload and stim stop in a workspace. It runs on the Mac, next to Stim. It changes Stim state only through those two commands. It writes only its own pairing state and action log under $STIM_HOME/server/, and the device recordings described under Recording.

The design is in docs/specs/2026-09-25-stim-server-design.md.

Commands

stim-server [--port <n>] [--loopback-only]
                                  # serve paired clients, port 7787 by default
stim-server pair [--port <n>] [--control]
                                  # print a single-use pairing payload
stim-server devices [list]        # list pairings, approvals, requests and hosted sessions
stim-server devices grant <id> --control|--read|--build|--device-host
                                  # let a paired device run actions, or only read;
                                  # --build approves builds; --device-host approves device hosting
stim-server devices revoke <id>   # revoke a paired device or client, or deny a request
stim-server log                   # list the actions paired devices ran
stim-server setup --client <node-id> --ticket <t> --expires <iso> --build|--device-host
                                  # worker-side approval and setup, see below
stim-server service install|status|update|rollback|uninstall
                                  # run stim-server as a macOS LaunchAgent, see below

--loopback-only makes the server listen on 127.0.0.1 only and refuse any request that names a remote peer (X-Forwarded-For, as tailscale serve --https adds), so phones and other Macs cannot connect; /health then reports loopbackOnly: true. Without it, the server also listens on this Mac's Tailscale addresses while Tailscale runs (see Tailscale). Stim Desktop starts its own server with the flag unless it serves phones.

--env KEY=VALUE and --path-prepend <dir> (each repeatable) apply to the serving command: see Run as a service. When serving, a bare --env KEY takes the value from the server's own environment.

pair --json prints { "qr": <payload>, "expiresAt": "<ISO time>" }, and devices --json prints { "devices": [...] } with each device's id, name, identity, pairedAt, lastSeenAt and capabilities, never its token hash, followed by the build clients and device-host clients. New client records carry requestedCapability (build or device-host), including after approval. Pending records also carry pendingUntil; a legacy pending record without requestedCapability is a build request. log --json prints { "actions": [...] }, the records described under Actions.

GET http://127.0.0.1:7787/health answers requests from this Mac with the server's name, versions, stimBuild (the digest of the bundled Stim's build, which offload compares between Macs), protocol, stimHome, startup, busy (the offloaded builds and hostedSessions running now) and the current Tailscale state. It answers HTTP 200 once startup.state is ready; while the state is pending or degraded (see the LaunchAgent notes below) it answers HTTP 503 with the same body. While Tailscale runs, it also carries route, read from tailscale serve status --json on each request: routed with the HTTPS port that proxies to the server, funneled with the Funnel ports that do, missing, or unknown with a reason; the last three carry the port the setup command would use. When the server runs under Stim Host, it also carries host: { name, screenRecording, accessibility }, the grants macOS gives that app (checked at most every 5 seconds), or null when the check fails. Stim Desktop uses it to find a running server and show its route. A request through tailscale serve or on a Tailscale address without an Origin or Sec-Fetch-Site header, which a web page's request carries, gets only { "server": "stim-server", "version", "protocol" }, which Stim Desktop's Remote Macs list uses to find stim-server on the other Macs of the tailnet. A request from this Mac with a Host other than 127.0.0.1 or localhost gets HTTP 426, like any other plain HTTP request.

GET /setup/<hash> returns a setup journal over the tailnet HTTP route. The hash is exactly 64 lowercase hex characters: SHA-256 of the optional auth.setupTicket on a build or device-host request (43 base64url characters). The registry stores only setupTicketHash and retains it after approval; missing or invalid tickets leave ordinary access requests unchanged.

The journal lives at $STIM_HOME/server/setup/<hash>.json, written atomically only by stim-server and read through core's state reader. It contains the client node, ISO expiresAt, requested capabilities, steps, granted request ids, completion state and optional exit code; it contains no tokens. Only the matching tailnet node can read an unexpired journal. Browser requests, loopback, invalid paths, missing or malformed journals and other nodes get the same 404. Like the agent route, the caller address is the X-Forwarded-For header the tailnet route sets, so a local process that forges it is outside this model. The route returns 503 until startup is ready, caches peer identity for 30 seconds and returns 429 after repeated identity failures. All responses use cache-control: no-store. Steps whose ids start with tools appear only after a build grant. The server prunes journals more than one hour past expiry at startup and hourly.

stim-server runs the stim version this package was released with, not the one on your PATH. It reads the login shell's environment once at start, so PATH, ANDROID_HOME, and STIM_* variables match your terminal even when another app starts it.

Set up a worker Mac

A person on the worker runs the command generated for their client Mac:

stim-server setup --client <node-id> --ticket <43-base64url-characters> --expires <ISO-time> \
  --build --device-host [--port <n>] [--label <name>] \
  [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose]

If the server is not installed globally, use npx --yes --package @stim-cli/server@<version> stim-server in place of stim-server, with the exact version from the client Mac. At least one capability is required. The expiry must be in the future and at most two hours ahead. The defaults are port 7787 and label dev.stim.server. Argument and preflight refusals change nothing. Preflight requires macOS, Node 22.12+, running Tailscale, the client node on this tailnet, and this user's GUI login session.

Running setup on the worker is the worker-side approval. It pre-approves at most one request per chosen capability, from this node, carrying this ticket, until this expiry. A terminal asks Y/n for each grant (default Yes: Enter, y and yes approve; n, no and any other answer decline); use --yes to approve without those questions, including without a terminal. Without a terminal or --yes, setup refuses before installing anything unless every chosen capability already has a matching approval. A person on the worker still approves; an SSH-driven run is not offered. Build and device-host requests are separate grants. A build grant carries no read or control capability, but it is full access to this account in practice (see Build access). A rerun before expiry reports matching approved requests as already approved and never grants a second request. After expiry it refuses with exit 2 before any change. Requests themselves lapse after 15 minutes.

Setup installs its exact server release into ~/Library/Application Support/Stim/services/<label>/versions/, installs Stim Host, installs or reuses the managed LaunchAgent, and creates or reuses a tailnet-only HTTPS route on 7443 or the next free port. Older managed services use the service update busy-wait and rollback behavior. Newer versions stay installed; tools report a Stim build difference. A reused server must be at least 1.16.0 (ignoring its prerelease suffix for this floor), the first version with setup ticket storage and the journal route. An older app server refuses immediately with an update remedy.

When a Desktop-run server already answers on the port, setup reuses it and installs no LaunchAgent. It requires an existing tailnet route and refuses to create one it cannot record. Its permissions belong to that app. The server must use the same Stim home. Export a scratch STIM_HOME in the shell before running setup; --env cannot set it. Repeatable --env KEY=VALUE and --path-prepend <absolute-dir> have the same validation and environment order as service install. A new LaunchAgent inherits only STIM_HOME and SHELL; pass paths and variables needed for CocoaPods, Java or Android explicitly.

For device hosting, setup checks screen recording and device control, requests normal macOS prompts, and opens the matching Privacy & Security panes after about ten seconds without a grant. Prompts appear on this Mac's screen. A terminal accepts s to skip each permission. Without a terminal, each missing permission waits at most five minutes and remains pending in the journal. Missing screen recording prevents viewing hosted simulators; missing device control prevents controlling them. For a Desktop-run server, allow its app in the same panes. Setup never changes TCC or enables Funnel. Funneled or unreadable routes refuse with a remedy. Tool checks print fixes for Xcode, simulator runtimes, CocoaPods, JDK, Android SDK and the Stim build; they install nothing. Android and CocoaPods checks are not needed for hosting alone.

In a terminal, setup prints a banner, then one line per step with a green check, a yellow arrow while a step runs, or a red cross; NO_COLOR, TERM=dumb and a non-terminal give plain [ok], [failed] and [pending] marks, no colors and no in-place line. The Y/n questions stay, one line each. The run ends with a headline such as "janics-mac-mini is ready to build for Janic's MacBook Pro", one line per approved capability, a Fix line for each skipped permission or missing tool, and one To undo line. --verbose prints every step with its detail, the install, service and route notes, and the long summary with the undo commands, as earlier releases did. Plain progress goes to stdout; errors use stim-server: <message> on stderr (when stdout is a terminal, the refusal appears on stdout only). --json sends the long (verbose) progress to stderr, without the banner, and prints one final payload with ok, label, port, route (state, DNS name, HTTPS port), server (version and Stim build), managed, separate granted request ids and client identities, permissions, tools and warnings. Final permission values are granted, denied (not authorized), skipped or not-needed; a timed-out wait remains pending in the journal and names its unavailable feature. Exit codes come from the final journal:

| Exit | Meaning | | ---- | --------------------------------------------------------------------------------------------------------------------- | | 0 | Every chosen capability, needed permission and tool is ready. | | 1 | A refusal or failed step. Completed earlier steps remain in place. | | 2 | No grant before expiry, or an already expired command. | | 3 | At least one grant, but another chosen capability, permission or tool is missing; the output names affected features. |

Setup writes $STIM_HOME/server/setup/<sha256(ticket)>.json from its first step and after every change, including completion and the exit code. The node-bound GET /setup/<hash> route described above mirrors it until expiry, hiding tools until a build grant. Each valid setup run prunes journals more than one hour past expiry. A dedicated server/setup.claims ownership claim serializes runs; the service update claim is released before waiting for requests and permissions. A competing setup refuses with the exact claim and removal command. Ctrl-C or SIGTERM finishes the running step as failed with detail interrupted, completes the journal, releases the setup claim, and exits 1. A typed N also exits 1; a question timeout follows ticket expiry and exits 2.

The To undo line (or the --verbose summary) lists the undo commands:

stim-server devices revoke <build-request-id>
stim-server devices revoke <device-host-request-id>
stim-server service uninstall --label <label>

If route verification or recording fails, setup removes the route it just created; a failed removal prints the exact tailscale serve --https=<port> off remedy. Uninstall removes only a route the managed install created. A reused app's server and route remain managed by that app. If its health response omits permission grants, setup keeps those checks pending or lets a terminal skip them; Stim Host's grants do not stand in for another app's permissions. Pairings, Stim Host and its macOS permissions remain; remove permission grants in System Settings if desired.

Tailscale

Tailscale carries and encrypts the traffic between the phone and the Mac, on the same Wi-Fi or across networks, and identifies each device. Stim adds no cryptography of its own. You need Tailscale on the Mac and on the phone, in the same tailnet or with the Mac shared to the phone's user.

stim-server listens only on 127.0.0.1 and on the Mac's Tailscale addresses, never on every interface. It re-reads the Tailscale state in the background, so a server that started before Tailscale was up, or while it did not answer, starts listening on the Tailscale addresses once it runs, and stops when it goes away. In Stim Desktop, the Pair a Phone wizard (Settings > Phones) creates and verifies the private route without a terminal command; with the Phone app flag off, Set up connection on Settings > Server does. A missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may require Tailscale browser approval. Existing routes stay unchanged.

For manual server setup, run this once so clients can use wss://<mac>.<tailnet>.ts.net:7443 with a valid certificate:

tailscale serve --bg --https=7443 http://127.0.0.1:7787

The dedicated port 7443 keeps the server tailnet only and leaves port 443 to other apps. Do not serve stim-server on a port where Tailscale Funnel is on: Funnel makes every handler on that port reachable from the public internet.

stim-server reads tailscale serve status --json at start and on every pair. It uses the HTTPS port whose / handler proxies to its loopback port, preferring 7443. Without such a route, it assumes 7443 and prints the command above, or the next free port when 7443 is taken. It never suggests a Funnel port. Any handler, at any path, or TCP forward that reaches the server on a Funnel port makes it public, and pair refuses.

When Tailscale is not running, stim-server listens on loopback only and says so on stderr. Start Tailscale, then restart stim-server.

Desktop uses route.setup with no parameters on its authenticated loopback control connection. The result is a verified ServeRoute; an existing route is a no-op. The server refuses forwarded, browser-origin or tailnet connections, read-only clients, unknown configuration and any route exposing this server through Funnel. Only the fixed tailscale serve --bg --https=<free port> http://127.0.0.1:<server port> invocation runs, followed by route verification. No LaunchAgent or Funnel configuration is created. Failure output remains available for an explicit retry, including Tailscale's HTTPS approval link.

Pairing

stim-server pair prints the JSON that the pairing QR code encodes:

{ "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }

The endpoint names the route's port, and omits it for 443. When a route to the server is on a port with Funnel on, pair refuses and exits 1 without creating a token.

The pairing token works once and expires after 5 minutes. A client spends it in hello and receives a random device token, which it presents on every later connection. The server stores only the SHA-256 hash of each token.

At pairing, the server records the peer's tailnet node and user from tailscale whois. A device token presented from any other node is refused, so a leaked token alone is not enough. A device paired over loopback, from this Mac, is accepted only over loopback. tailscale serve connects from loopback and names the peer in X-Forwarded-For, which the server trusts only on loopback connections. Forward only tailscale serve in HTTP mode to the loopback port: a forwarder that omits that header, such as tailscale serve --tcp, ssh -L, or a tunnel, makes every remote peer look like this Mac.

A connection must send hello within 5 seconds. Five failed attempts from the same peer within a minute block new connections from it for up to a minute. The server answers 403 to a WebSocket upgrade that carries Sec-Fetch-Site, or an Origin other than the requested host (http: on loopback, https: through the route), and counts no failed attempt, so a web page can neither connect nor block this Mac's clients.

Paired devices live in $STIM_HOME/server/devices.json. Revoking a device closes its open connections. The server checks registrations on file changes and once a second, so a missed filesystem notification cannot leave a revoked connection authorized.

Scopes

A paired device has the read capability, which serves state, or also control, which runs actions and controls devices. With the workspace-diff feature, read also serves changed and untracked text file contents in registered workspaces, including any non-ignored untracked text file such as an unignored .env. Pairing grants read only, unless the pairing code came from stim-server pair --control. On the Mac, stim-server devices grant <id> --control adds control to a paired device and --read takes it away. Nothing a client sends changes its own capabilities. The server checks the device's capabilities in devices.json on every action and control session, and ends a device's control sessions when it loses control, so taking control away applies to open connections on the next registration check. hello reports the capabilities and actions of the connection's device when it connects; a connection sees a new grant after it reconnects. Under Stim Host, an approved hello also carries the same host grants as /health, which stim doctor reports for remote Macs.

Every method other than hello needs read, except the methods with their own approval: build.* needs build, the device-host.* methods that run a hosted session need device-host, and server.update.* needs the build or device-host approval of the requesting Mac (see Remote update). A device with only build gets forbidden for every other method.

Build access

build lets another Mac on the tailnet run its project's code on this Mac to build for it: config plugins, CocoaPods hooks, Xcode script phases and Gradle plugins run as the user stim-server runs as. The offload worker runs with that user's real home directory and no sandbox, so a build grant is equivalent to full access to the account stim-server runs as. In practice it includes everything read and control allow: project code can edit the server's devices.json (under ~/.stim/server by default, which the worker's real home directory still reaches) to grant itself either, read other build clients' checkouts and blobs, and persist through LaunchAgents or shell startup files. devices grant never turns a paired device into a build client, and the protocol does not list read or control among a build client's capabilities, but do not rely on that as a boundary. Grant it only to Macs you trust with that. Revoking the grant stops future builds and kills the running job's process group. It does not undo anything project code already did. A loopback connection from this Mac cannot get it: it has no tailnet node to bind the token to. A connection from this Mac to its own tailnet address or *.ts.net name does carry the Mac's own tailnet identity and can request build. Because the server trusts X-Forwarded-For on loopback (see Pairing), a process on this Mac can still claim a tailnet peer's address. Run stim-server as a user no one else can run processes as when other people use this Mac, and approve a request only when you expect it.

A Mac gets build only by asking and being approved on this Mac. The client sends hello with auth set to { "request": "build", "deviceName" }, a name of at most 64 UTF-16 code units with no control or format characters. The server records a pending build client bound to the peer's node, answers with a deviceToken, no capabilities and approval: { "state": "pending", "expiresAt" }, and closes the connection. stim-server devices lists it as pending build. On this Mac, stim-server devices grant <id> --build approves it, or a person runs stim-server setup on this Mac with its node and ticket, and stim-server devices revoke <id> denies it; Stim Desktop's Allow and Deny run those commands. Until then, hello with its token fails with approval-pending, which does not count as a failed attempt, while the request itself does, so a peer cannot send more requests than failed attempts. A request lapses after 15 minutes. Each node has at most one pending request, the newest, and the server holds at most 8; more fail with limit-exceeded. There is no pairing code for build: stim-server pair never grants it.

devices grant never gives build to a paired device, or read or control to a build client. Build clients live in $STIM_HOME/server/build-clients.json, apart from devices.json, so a stim-server release without build never reads them and refuses their tokens.

Device-host approval

Device hosting has a separate device-host capability. An approved client can reserve, boot, reconnect to and stop its own iOS simulator or Android emulator through the protocol. It can deliver, install and launch a compatible iOS app bundle, Android APK or prebuilt macOS app, stream the iOS simulator, Android emulator or macOS app and control it. Hosted iOS and Android apps connect back to Metro on the client Mac. Automatic placement remains in #2266.

The Stim client can name expected hosts with stim settings set remote.machines '["<mac>"]' and request access with stim doctor --fix. It stores a separate private credential in $STIM_HOME/device-host-machines.json, pins the worker's tailnet node, and reports approval under deviceHosts in doctor JSON. Plain doctor makes no approval request. Use stim ios --remote <machine> or stim android --remote <machine> for explicit placement.

A client on the tailnet sends hello with auth: { "request": "device-host", "deviceName": "Laptop" }. As with a build request, the server returns a token and pending approval, then closes the connection. It binds the token to the peer's tailnet node. Requests expire after 15 minutes; each node keeps only its newest pending request of this kind, and at most eight device-host requests can be pending. Build requests have a separate limit. The same name validation and failed-attempt limit apply.

On the hosting Mac, inspect stim-server devices, then approve the matching request with stim-server devices grant <id> --device-host, run stim-server setup there with its node and ticket, or use Allow in Stim Desktop. Approve only an expected request: it authorizes that Mac to reserve session-owned simulators or emulators and run native app code. Deny or stim-server devices revoke <id> removes it; revocation also closes its open authenticated connections. The local-process trust boundary described in Build access applies here too.

Hosting clients live in $STIM_HOME/server/device-host-clients.json, separate from phone pairings and build clients. A hosting token grants no read, control or build access, including access to unrelated workspaces. Existing read, control and build tokens cannot gain hosting through devices grant. Loopback requests and pair --device-host are refused.

Hosted here

stim-server devices adds a Hosted here block after pairings and approvals, with the client name, platform, device label, installed app bundle id (or -), state and start time. --json adds hostedSessions next to devices, including an empty array when there are none. Rows are newest first; plain stopped sessions are omitted, while parked and unresolved sessions stay visible. A parked row uses its parking time. A removed client's name falls back to its id. The command reads the host journal directly and needs no server connection.

device-host.sessions takes no params (or {}) and returns { sessions }. Each row has id, client: { id, name }, platform, nullable device and app, state, parked, since (ISO time), and workspace. device-host.sessions.stop takes { "session": "<id>" } and returns { "id": "<id>", "state": "stopping" | "stopped" }. It stops any client's session through the existing stop path, closing Metro, view, agent and logs before owned-device teardown. Repeating a stopped session returns stopped; an absent id gets unknown-session. Both requests require an authenticated loopback Desktop connection with local identity and control access. Local read-only connections, paired phones and remote hosting clients get forbidden. A session whose active owner is not attached to this server reads as unknown; stop reconciles it through the same ownership checks. The approved-client device-host.stop remains limited to that client's own sessions.

Hosted availability offers

Before reserving, an approved hosting client can send:

{ "id": 1, "method": "device-host.offer", "params": { "platform": "ios", "runtime": "27.0" } }

Use platform: "android" with optional systemImage and deviceProfile, or ios with optional deviceType and runtime. Use macos without selectors for the host architecture and macOS version. Omit selectors for the same installed defaults used by reserve. The response includes platform, the selected SDK choice (runtime/model/image and host architecture), resources, capacity and a nullable declined reason. An unavailable SDK choice is null with a reason; unknown or elevated memory pressure declines. The query creates no session, simulator, AVD or ownership claim.

capacity.running counts every reservation not confirmed stopped, including unresolved sessions. max is concurrency.maxDevices; zero means uncapped and available is then null. Android offers also decline when all supported console ports are already recorded as occupied. macOS offers decline when all 64 app slots are reserved. Resource values are advisory: cpus, five-minute loadPerCore, memoryFreeBytes, memoryPressure (nullable when unknown), and workerDiskFreeBytes (nullable when unreadable). Disk space is measured on the Stim home volume, which can differ from Android AVD storage.

An offer is a snapshot, not a reservation or a boot guarantee. Capacity, memory, installed SDKs and local device producers can change before reserve; reserve still takes atomic admission and revalidates native preflight. A non-null choice with a non-null declined reason is currently unavailable. This protocol does not select a host for stim ios or stim android.

Agent driver route

On the hosting Mac, hosting.agentDriver names the tool that lets a client's coding agent drive the macOS app hosted for that client. none (the default) starts nothing. stim-server reference-counts the driver's daemon on running hosted macOS apps: it starts with the first, stops after the last, on revocation and on server shutdown, and restarts if it exits while apps run. The daemon is held under an ownership claim at server/agent-device.claims whose child is the daemon, and its state lives in server/agent-device.

Each hosted app gets a grant scoped to one client and one app. The grant's token is kept in memory only, is returned to that client in app.launch and app.attach results, and is never journaled or logged. The route

ANY /device-host/agent/<session>/<driver path>

on the tailnet serve route is forwarded to the loopback daemon, with the daemon's own token, only when all of these hold: the request carries that session's token as a bearer token, it comes from the tailnet node pinned to the session's approved device-host client, the client still holds device-host, and the session's app is running. Loopback requests, browser requests and other sessions' tokens are refused with 403, an unknown or stopped session with 404.

The agent-device adapter runs the binary that STIM_AGENT_DEVICE_BIN names in stim-server's environment, or else the first of ~/.local/bin/agent-device, /opt/homebrew/bin/agent-device and /usr/local/bin/agent-device; it never searches PATH. It starts agent-device proxy with the native macOS app backend and a daemon policy that admits only requests under a macos-app lease and only the commands that drive one app. It starts only when the daemon's /health lists the macos-app lease backend (callstack/agent-device#3229); otherwise the grant is { "driver": "none" } with a notice, and Stim never hands out the Mac's desktop.

For each running hosted app, the adapter allocates a macos-app lease for <bundleId>@<pid> over agent-device's loopback /admin/leases route with the daemon token, renews it while the app runs and releases it when the app stops or the grant is revoked. The grant carries the lease id and owner scope. The forward passes only POST /rpc, GET /health and GET /artifacts/..., and it rewrites every command and lease call to that session's lease, tenant and tenant session isolation, so a client cannot name another lease or session. It refuses commands outside the daemon policy's list (also inside a batch), drops device selectors and runtime hints, and sets platform to macos.

Hosted iOS session protocol

An approved client sends device-host.reserve with an opaque attempt ID and its workspace/slot identity:

{
  "id": 1,
  "method": "device-host.reserve",
  "params": {
    "workspace": "/client/app",
    "slot": "default",
    "platform": "ios",
    "attempt": "run-1",
    "deviceType": "iPhone 17 Pro",
    "runtime": "27.0"
  }
}

Omit deviceType and runtime to use the worker's installed defaults. The response names an opaque session and its preparing, ready, stopping, stopped or unknown state. Native work runs in a separate bounded child, so the connection remains available. Poll device-host.attach with {"session":"<id>"} or {"attempt":"run-1"}; a ready session includes the exact simulator, runtime and architecture selected on the worker.

After a lost reply or connection, replay the same reserve request or attach to its attempt. That resolves the same session; a changed request with that attempt is refused. Another attempt cannot replace an occupied workspace/slot. The client never supplies a worker filesystem path or another client's session. device-host.stop with {"session":"<id>"} deletes only its recorded, ledger-owned simulator. Poll attach for completion. A new attempt may reserve after the previous one is confirmed stopped. Stopped session homes and device records remain for reconciliation. At start, stim-server retires leftover stopped iOS and Android devices through their exact private ledger ownership.

The worker persists the journal under $STIM_HOME/server/device-host-sessions/ and chooses an isolated worker home under $STIM_HOME/device-host/sessions/. An uncertain create, child exit, journal or deletion outcome retains the slot as unknown. Explicit stop can reconcile a complete record after a server restart; a live or unverifiable owner is refused. Missing ownership records need operator investigation, never a replacement inferred from a simulator name. Revoking this client's approval stops its sessions without granting access to the worker's other devices.

concurrency.maxDevices bounds hosted reservations atomically, including unresolved sessions. Ordinary local producers do not join that reservation transaction, so this is not a machine-wide hard capacity guarantee. Unknown inventory or elevated/unknown memory pressure refuses native creation. This protocol slice does not change stim ios placement.

Hosted Android session protocol

The same reserve/attach/stop methods accept platform: "android" with optional systemImage (an installed system-images;android-<api>;<tag>;<abi> package) and deviceProfile (an installed avdmanager profile). iOS selectors refuse on Android requests. The image must match the host architecture. Omitted selectors use Stim's existing compatible installed-image and default-profile choice. The worker process needs an Android SDK and a JDK that avdmanager can use.

{
  "id": 1,
  "method": "device-host.reserve",
  "params": {
    "workspace": "/client/worktree",
    "slot": "default",
    "platform": "android",
    "attempt": "android-1",
    "systemImage": "system-images;android-30;google_apis;arm64-v8a",
    "deviceProfile": "pixel_6"
  }
}

A preparing Android result includes a server-selected consolePort. The journal reserves distinct ports among hosted sessions and excludes the ports in the host's local config. The worker refuses an observed occupied port before creation and before boot. Local producers do not participate in this reservation, so a racing local launch can still refuse a hosted boot; this is not a machine-wide hard port or device-capacity guarantee.

A ready Android device has avdName, serial, consolePort, systemImage, deviceProfile and architecture (arm64-v8a or x86_64). The worker verifies its exact AVD name and running ABI before reporting ready. Creation remains inside the worker's claimed process group. The worker opens no emulator viewer. Stop and revocation use centralized teardown only for its private ledger's exact AVD. They delete that AVD and retain the session home and record. At start, stim-server retires leftover stopped sessions; unknown native outcomes retain the reservation and require explicit reconciliation. Android Metro and screen/input routing remain follow-ups in #2266. Android sessions refuse the iOS Metro, view and input routes.

Hosted iOS app delivery

App offers, chunks and launches require a ready session held by this server. After its owner disappears, session attach reports unknown; app operations refuse until explicit stop reconciles the retained session. A ready journal entry alone does not authorize another native operation.

Send device-host.app.offer with the ready session, a new opaque app attempt, the expected bundleId, mode: "development"|"release", and manifest: {"sha256":"<digest>","size":<bytes>}. The manifest is a UTF-8 JSON array of {path, kind, size, sha256} entries, where kind is file, exec or link. Paths are relative to the .app root. A link's content is its relative target; it must resolve inside the bundle. No entry may sit below a file or link. Duplicate paths, including Unicode/case aliases, are refused.

macOS offers may also include an optional arguments string array: at most 32 entries, 1024 characters per entry and 8192 characters total, with no NUL, CR or LF. Empty strings are allowed; an empty array is omitted. Other session platforms refuse non-empty arguments. The host passes them as plain executable arguments, without environment injection, stores them in the app receipt and echoes them in delivery replies. They are visible in process listings, so do not put secrets there. Older hosts ignore this field; Stim warns to update stim-server on the host when its delivery does not report the requested arguments.

An offer returns {delivery, missing}. Upload each missing digest with device-host.app.chunk and {session, attempt, sha256, offset, data}; data is base64 for at most 32 KiB of raw bytes. The reply names the next byte offset. Replay of the same bytes is safe after a lost reply. Re-offer to learn received offsets. Upload the manifest first, then re-offer for its missing file content. The manifest is limited to 8 MiB and 20,000 entries, with at most 1 GiB per file, 1 KiB per link, and 4 GiB of declared bundle content. An app attempt cannot change its identity, mode, arguments or manifest. Complete a receiving attempt or stop the session before starting another transfer. Manifest, file and partial blobs are shared by attempts of one session in its content-addressed store; offers recheck complete blob digests and request only missing bytes.

An iOS simulator, Android emulator or macOS session can take its content from a build this Mac ran instead: after the manifest arrives, send device-host.app.handoff with {session, attempt, build: {handoff, sha256}}, where handoff is the token and sha256 the archive digest from that job's build.artifact answer. The server hands the build over only when the build client that ran it still has build, the caller has device-host, and both approvals belong to the same tailnet node. It copies only regular files, and links where the manifest declares links, whose directory resolves inside the staged bundle and whose bytes match their manifest digest; it answers {files, bytes} for what it took. The token is spent once the handoff starts. Re-offer and upload whatever is still missing; an older server answers unknown-method. For iOS, check the hosted-ios-data hello feature before requesting handoff; for Android check hosted-android-data. Without the feature, upload. Android takes only a regular APK file matching the single App.apk manifest entry. Handoff failure falls back to upload. Reused APK blobs skip transfer, while every new attempt still installs.

After every digest is verified, call device-host.app.launch with {session, attempt}. Poll device-host.app.attach for installed or unknown. Reconnect to the same app attempt to reconcile a lost launch reply; replay does not install or launch twice. Session attach includes the latest appAttempt. Development offers may include devClientScheme for an Expo development client; the attempt cannot change that scheme. The worker verifies the plist identity and simulator metadata, the executable's Mach-O platform, architecture and minimum OS, then rechecks the exact private device ledger before install and launch.

Development launch reports launched: "unverified": a bridge or native process alone does not prove bundle delivery. Release launch reports true only after observing a live native app process; absent evidence remains "unverified". Stop and approval revocation cancel an in-flight install before shutting down the owned simulator. Uncertain native outcomes retain the session as unknown; explicitly stop it before retrying. Receipts and artifacts remain in the server-chosen session area; artifact retention and session reuse/retirement remain under #2266 and #2348.

Hosted Android app delivery

Android sessions use the same app offer/chunk/launch/attach methods, with bundleId naming the expected Android package. Their manifest has exactly one file entry named App.apk, with the APK's byte size and SHA-256. Upload the manifest first, then the APK through the same resumable 32 KiB chunks.

The worker verifies both digests, inspects the APK's package identity, minimum SDK and native library ABIs with installed Android build-tools, and rechecks the private ledger, exact running AVD and ABI before installation and launch. It resolves adb from that SDK even when the worker PATH does not include it. A signature or installation conflict refuses; hosting does not uninstall an existing app to resolve it.

Release launch reports true only after observing a live package process. Development launch remains unverified until the client Metro bridge provides bundle evidence. Replaying an installed app attempt does not install or launch again. Owner loss, stop, revocation and uncertain outcomes retain the same reconciliation rules as iOS.

With hosting.agentDriver: agent-device, each installed Android session gets its own daemon and child-aware claim under device-host/sessions/<session>/agent-device.claims. Both Macs need agent-device 0.21.22 or later. The host verifies the serial policy digest and android-instance backend and advertises hosted-android-agent. device-host.app.attach and session attach carry a session grant with android:mobile:<serial> and the same token and client-node checks as iOS. The client writes a 0600 remote config under hosted-android/<slot>/ and reports it as android.host.agent. Use agent-device <command> --remote-config <file>. Missing or unsupported agent-device reports driver: none with its reason.

The policy pins the ledger-owned serial and filters devices to that emulator. Inspection and interaction commands match iOS. Installs, reinstall, uninstall, boot/shutdown, close --shutdown, record, logs, uploads, other serials (including nested batches and leases), and host paths are refused. Ambient fields are stripped and replaced with host-owned platform, serial and lease scope. Stim installs apps. Android snapshots use one-shot helpers without adb forwards; after daemon exit the host stops and verifies only the two fixed agent-device helper packages on that serial. Their APKs leave with emulator deletion. Reinstall, stop, revocation and close await agent teardown before native work; unresolved agent claims refuse replacement and deletion after restart too.

Hosted macOS app sessions

The same offer/reserve/attach/stop methods accept platform: "macos" without selectors. A macOS reservation counts toward concurrency.maxDevices and takes the lowest free appSlot from 1 through 64. Unresolved sessions retain their slot; only a confirmed stop frees it. The ready device describes the host's architecture (arm64 or x86_64), macosVersion and appSlot. No simulator, emulator or created-device ledger entry is needed.

{
  "id": 1,
  "method": "device-host.reserve",
  "params": {
    "workspace": "/client/app",
    "slot": "default",
    "platform": "macos",
    "attempt": "macos-1"
  }
}

Deliver a prebuilt .app using the same digest manifest and resumable chunks. The manifest must include Contents/Info.plist as a file and an executable under Contents/MacOS/. Only release offers are accepted, with no devClientScheme and no com.apple. bundle identity. The host verifies the plist identity, contained executable, architecture slice, MACOS Mach-O platform and minimum OS. URL registrations (CFBundleURLTypes) and update feeds (SUFeedURL) refuse.

Before launch the worker stamps <bundleId>.hosted<appSlot> and ad hoc signs its frameworks and bundle. That identity must fit within 249 characters so its .plist preferences filename fits within the 255-byte filename limit. The existing macOS supervisor owns the app process, with HOME, CFFIXED_USER_HOME and TMPDIR pointing into the session's private app home. This is home and identity isolation, not an OS sandbox for the client's native code. The supervisor and app receive only the host's set PATH, LANG, LC_ALL, LC_CTYPE, USER, LOGNAME, SHELL and TERM, plus the worker STIM_HOME and isolated home and temporary paths. launched: true requires matching live app and supervisor process identities. Stop and revocation verify and stop those processes, then delete only the last recorded hosted bundle identity's preferences domain and plist. A replacement app with a different identity removes the prior hosted identity's preferences after stopping its processes. macOS preferences use the real user's preferences service despite the isolated home. Stop removes the app home and delivered bytes from every app attempt, keeping logs and receipts so app.attach can still report the recorded state.

Once a hosted app is installed, device-host.app.launch and device-host.app.attach return the agent grant the host's driver issued for that session, or agent: { driver: 'none' } while no driver runs or it cannot scope the app; the driver's reason, if any, is the result's notice. The field is built for the response and never stored in the receipt or journal; it is absent before installation and after stop. The worker reports the app's pid, DeviceHost registers the running app with the agent driver before the receipt reads installed, and ends that registration when the session stops, is revoked or the server closes.

device-host.logs.query with {session, cursor?} returns {records, cursor, more, checkpoint?}: the NDJSON records the session's macOS app wrote to its captured log (stdout and stderr as client records, and its exit). iOS sessions capture app-filtered native logs as device records through bounded simctl log show worker queries, with a persisted time checkpoint in the isolated home. Android sessions use logcat -d -v epoch -T <epoch> --pid <pid> on the exact ledger-owned serial after re-verifying its AVD name. Each collection resolves pidof <package> and also queries the last observed PID of that app attempt, to drain a crash or restart. Without any observed PID, logs cannot be attributed to the app. PID-based logcat cannot distinguish historical reuse of an exited PID. Output is bounded by the executor's 64 MiB limit per command and the collection's ten-second budget; an oversized query retries the recent tail and emits a device warning naming the dropped interval. Logcat entries already evicted from its finite buffer are unavailable. Both native collectors use the same checkpoint and NDJSON writer. Windows adapt to the backlog, overlap by five seconds and de-duplicate event identities. checkpoint is the last completed native window in epoch milliseconds. log show reads persisted entries; info-level entries and persistence delayed beyond the overlap may be unavailable. The worker holds a separate child-aware log claim. Queries coalesce onto an in-flight collection or read captured files; collection starts at most once every three seconds per session. App offers, chunks, handoff, launch admission, view and control stay available. Installation and stop cancel and settle collection under bounded worker and termination deadlines before native work. Each native collection has a 10-second step budget inside its 15-second worker deadline. The client drain has a 30-second bound, reports progress on stderr and stops on a stalled cursor and checkpoint. The host collects once more before deletion on stop, revocation or server close, preferring the recent tail if the remaining backlog will not fit. If the final collection drops a backlog interval and eventually succeeds, one device warning record names the interval. Missing or damaged checkpoints are ignored and rebuilt; they never prevent reading collected logs. The client waits up to 180 seconds for stop. The worker paths fit within that wait: the 90-second stop worker, 15-second final collection and up to three 10-second worker group-settle bounds leave 45 seconds for polling and transport. An in-flight handoff copy and closing Metro or view transports are outside those worker bounds. The client pulls that final collection after stop. Stop removes the session blob store and materialized apps, retaining receipts and collected logs. cursor maps each log file name to the byte offset after the last complete line read; pass the previous result's cursor to receive only newer records, and repeat while more is true. Without a cursor, each file starts at most 4 MiB before its end. A page holds at most 1 MiB; a file that rotated since the cursor is read from the end of its previous generation. The session's own client only, for iOS, Android and macOS sessions, and also after the session stopped, because stop keeps captured logs. iOS clients check hello feature hosted-ios-data and Android clients check hosted-android-data; without it, they show an update note and copied logs. Servers that predate macOS queries answer forbidden or unknown-method.

macOS sessions refuse Metro. Viewing and control are supported while the hosted app is running. Automatic CLI placement remains a follow-up in #2403.

Private hosted Metro

The client keeps its verified workspace Metro on loopback. It creates a createMetroGateway from @stim-cli/core, binds it only to its own Tailscale address, and supplies the pinned worker's literal tailnet address, local Metro port and a fresh 32-byte secret encoded as 64 lowercase hex characters. The gateway accepts only that worker address and authenticates each connection before forwarding to the fixed local Metro port. It never targets a client supplied URL. Close the gateway when its session ends.

Call device-host.metro.open with {session, gatewayPort, secret, clientMetroPort?} on the approved hosted connection. The server connects only to that connection's authenticated tailnet peer and returns {port} for the worker's loopback endpoint. clientMetroPort is required for Android and validated as an integer from 1 through 65535. The session records it alongside the bridge port. Android development installation reverses tcp:<clientMetroPort> to tcp:<port> on the exact ledger-owned serial and sets debug_http_host to localhost:<clientMetroPort>. Reopening an installed development session runs a fixed reverse worker under its child-aware claim, serialized with install and stop, so a client reload restores the mapping after a host adb server restart. The worker never restarts the shared adb server. iOS development installation uses the bridge port for RCT_jsLocation and, when offered, the Expo development-client deep link. HTTP and WebSocket bytes stream over WireGuard with socket backpressure; no public tunnel, Funnel or Tailscale serve configuration change is needed. The 64 KiB server message limit remains unchanged.

Replaying the same open request keeps the port. Client disconnection leaves the bridge available for the same session while its server owner lives. Call device-host.metro.close to replace a gateway, then reopen on the same port; an occupied port refuses rather than sending the app to another listener. Closing a bridge interrupts its active streams. Stop, revocation and server shutdown close its sockets before device retirement. After a server owner disappears, the retained session requires explicit stop, as app delivery does.

Expo dev-launcher and CLI versions that send and honor the Forwarded header resolve relative manifest URLs against the worker origin. Older versions may embed the client's local port instead; this slice does not rewrite manifests or claim that those versions work through a different worker port. Client placement still needs to check that contract before selecting hosted Metro. Bare React Native uses the worker RCT_jsLocation. Bridge readiness and manifest requests are not launch proof; development remains unverified until the workspace observes the app's own bundle delivery. Approved clients use stim ios|android --remote auto for automatic device placement. They stay local while there is a free slot, no queued device run, normal memory pressure, no budget refusal and load below server.maxLoadPerCore. Otherwise they rank compatible offers from remote.machines and select a host, or wait locally if none admits. A reserve refusal after an offer fails with STIM_HOSTING_REFUSED and asks to retry; it does not re-place the built app. Automatic native macOS placement remains separate work.

Hosted viewer relay

The client's stim-server relays a hosted workspace's iOS simulator, Android emulator or macOS view and input to its host using the client's approved device-host credential over the pinned tailnet connection. Stim Desktop and phones keep talking only to their own server. The macos-hosted, ios-hosted and android-hosted features advertise these relays. iOS resolves ios.host and Android resolves android.host by the requested slot; macOS retains its default slot. The local platform and slot replace the host's private target fields in events and iOS/Android control audit records. H.264 packets retain their flags and payload and carry the local subscription ID, which identifies the platform and slot. The relay forwards iOS device artwork and orientation metadata. Hosted targets refuse replay (at/rate), duoFrame and physical with bad-request. All of the client's relayed subscriptions and control sessions for one host share one connection, whichever local client opened them. Each request still checks the credential and the pinned node; a changed credential or endpoint opens a new connection. Ending a subscription or session sends device-host.unsubscribe or device-host.control.end, and the connection closes when the last one ends. The host's per-connection limits, such as its 32 subscriptions and input budgets, therefore apply to all of the client's relayed use of that host. While a local client is behind on a relayed H.264 subscription, the relay drops its packets until the next keyframe and, when the host advertises the hosted-congestion feature, sends device-host.frames.congested at most every 250 ms, so the host lowers that app's bitrate as it does for a local subscriber whose socket backs up. Hosted macOS frames and control require the default slot; hosted iOS and Android support named slots. control.begin still needs the local control grant. For macOS apps, Screen Recording for viewing and Accessibility for control are granted on the host, not the client.

Hosted iOS, Android and macOS view and input

An approved hosting client can subscribe to its ready session's exact owned iOS simulator, Android emulator or running macOS app without access to the worker's registered workspaces:

{
  "id": 8,
  "method": "device-host.frames.subscribe",
  "params": { "session": "<hosted-session-id>", "fps": 5, "maxEdge": 1280 }
}

The result contains a subscription ID. JPEG delivery uses the existing frame events; video: ["h264"] selects the existing H.264 binary stream and its backpressure/keyframe rules. device-host.frames.keyframe, device-host.frames.congested and device-host.unsubscribe take that subscription ID as params.subscription. device-host.frames.congested (feature hosted-congestion) reports that a client further downstream is behind on that H.264 subscription; it lowers the bitrate the same way a backed-up socket does. Hosted capture requires the compiled stim-frames helper and does not support replay or screenshot fallback. iOS supports device artwork. macOS frames show only the one window of the hosted app. Viewing is refused before launch and after the app exits. On the hosting Mac, stim-server service install runs the server and stim-frames under Stim Host, whose Screen Recording and control grants they need. Install shows macOS's own requests; stim-server service status shows the grants. Servers launched by Stim Desktop keep Desktop's grants. Missing Screen & System Audio Recording ends the stream; missing Device Control and Data Access (Accessibility on macOS 26 and earlier) leaves viewing available. See Run as a service for the settings panes and Dev caveat.

Android capture uses the same android-device <serial> <adb> <scrcpy-server> helper transport as physical Android, addressed only to the session's exact emulator-<port> serial. It bypasses workspace target resolution and device leases. The server rechecks the private home's created-device ledger, recorded serial and system image, the running AVD identity and ABI. Unknown state refuses capture and input. The helper uses the host SDK from ANDROID_HOME, ANDROID_SDK_ROOT or $HOME/Library/Android/sdk, including under the LaunchAgent. It pushes the built scrcpy jar to /data/local/tmp and removes both that jar and its adb forward when capture stops. One viewer pool serves each session; the lifetime claim tracks the helper as its child. Android capture requires no macOS screen-recording permission.

Start control with device-host.control.begin and {"session":"<hosted-session-id>"}. Its result returns a connection-bound control session ID and lease: null: the hosted lifetime claim protects this private device. Only one controller can drive the device; takeOver: true replaces the previous controller. Use the returned control ID with device-host.input.touch|text, using the same parameters as ordinary input. iOS also supports device-host.input.button|rotate|posture. Android supports device-host.input.button through scrcpy; rotation and posture are unavailable on this path, as for physical Android devices. macOS also supports device-host.input.scroll|key; these two methods are for macOS sessions only. Touch coordinates range from 0 to 1 on the streamed display. End it with device-host.control.end and {"session":"<control-id>"}.

The worker derives the workspace, slot and device or app identity from its owned session; callers cannot select arbitrary worker devices. Every begin and input rechecks session ownership and current approval. Hosting approval grants neither ordinary frames.subscribe nor ordinary control.begin access. Disconnecting releases that connection's capture and input; reconnect to the same hosted session and subscribe again.

Installation, stop, revocation and server close end capture and input before native work reuses the session claim. In-flight native input settles before teardown even after disconnect or takeover; a timeout waits for its child to terminate. Known capture closes even if the journal is unreadable or unwritable; unresolved native state and claims remain retained. Hosted posture commands hold a separate child-aware input claim. A surviving command or unresolved child identity blocks replacement ownership, install and stop even after the server and capture helper exit; the refusal names the claim and its manual cleanup command. An unknown or lost owner requires explicit stop before replacement. Automatic placement remains in #2266.

Run as a service

stim-server service install runs stim-server as a per-user LaunchAgent on macOS, so a remote Mac or a phone-serving Mac keeps it running without a terminal:

stim-server service install [--port <n>] [--label <name>] [--serve]
                            [--env KEY=VALUE]... [--path-prepend <dir>]...
stim-server service status [--label <name>] [--json]
stim-server service update [--label <name>] --release <version>|--from <dir>
stim-server service rollback [--label <name>]
stim-server service uninstall [--label <name>]

install writes ~/Library/LaunchAgents/<label>.plist (label dev.stim.server by default) and starts it with launchctl bootstrap gui/<uid>. The job starts Stim Host, which stays alive as the parent of the absolute node and stim-server.mjs of the install that ran the command, on --port (default 7787), with RunAtLoad, KeepAlive and a 30 second ThrottleInterval, and logs to ~/Library/Logs/Stim/<label>.log. When STIM_HOME or SHELL is set in the installing shell, the job carries it. It takes the node from PATH when that path resolves to the running binary, so a Homebrew Node upgrade does not break the plist. Running install again rewrites the plist and restarts the job; if the new job cannot start, it puts the old plist and job back. install and uninstall act only on a plist that install wrote, and refuse a port that another stim-server (Stim Desktop's, for example) already answers on. Install from a permanent installation, not from an npx cache, because the plist stores its paths. It never touches pairings, anything under $STIM_HOME/server or settings. install waits up to 15 seconds for a /health answer that is no longer pending. An installed LaunchAgent with no answer reports readiness as unavailable, and one that answers degraded reports the reason; both point to status and its log. Installation success alone does not prove that the server is ready.

Install downloads the Stim Host release this package pins from the host-v<version> GitHub release, checks the pinned SHA-256 and App & Flow's Developer ID signature, and installs Stim Host (dev.stim.host) at ~/Applications/Stim Host.app; grants then survive Stim Host updates. Install keeps an identical bundle untouched. A Mac that ran the earlier Stim Host Dev keeps ~/Applications/Stim Host Dev.app; its owner can delete it and its System Settings entries.

After starting the service, install shows macOS's own permission requests on this Mac's screen, one at a time: Stim Host keeps running after install returns, asks for Device Control and Data Access once the Screen Recording request is answered (it waits up to two minutes for each), and opens that System Settings pane with Stim Host listed when macOS shows no request for it. You only approve; over SSH, use Screen Sharing to see that screen. If a request does not appear, turn Stim Host on in System Settings > Privacy & Security > Screen & System Audio Recording (Screen Recording on macOS 14) and Device Control and Data Access (Accessibility on macOS 26 and earlier). Stim never changes these settings itself. Servers launched by Stim Desktop keep Desktop's grants. For an older node-first service, run stim-server service install again to use Stim Host.

The server binds its port before it touches the Stim home. It then reads the Stim home, its server and workspaces directories and the recording ownership directories in a read-only child process, so a read that never returns, such as one on a stalled external volume, cannot block the server's event loop. Until that read returns, /health answers HTTP 503 with startup: { "state": "pending" }, WebSocket upgrades and device-host agent requests get 503, and no status follower, native helper, file watcher or recorder starts. A read that does not return within 10 seconds, plus up to one second to stop the child, leaves the server listening with startup: { "state": "degraded", "reason": "..." } and HTTP 503 on /health, logs the reason once, and reads again every 30 seconds. When a read returns, the server starts its watchers and recorder, /health answers 200 with startup: { "state": "ready" }, and no restart is needed. service status prints health: degraded or health: starting and the reason, and service install reports a listening but degraded server instead of success. Returned filesystem errors still go through the recorder's existing claim refusal; missing directories are created by that protocol. The check preserves claims and recordings. It does not restore an inaccessible volume or change the server's permissions, and a stall that starts after the check passes still blocks the server until the operating system returns.

The job runs in your GUI login session, so it starts when you log in and not at boot. On a Mac with no one at the screen, turn on automatic login. Moving the install, or changing its Node, needs install again.

--serve adds the tailnet-only route from Tailscale on the port stim-server would suggest. It refuses when Funnel is on for a port that reaches the server, and it never enables Funnel. When a route already reaches the port, install records that it did not create it. uninstall removes a route only when install created it and it still points at this port. Without --serve, install prints the command to run. A route that exists only in a foreground tailscale serve session counts as present. To move a service that created a route to another --port, run uninstall first.

--env KEY=VALUE and --path-prepend <dir> pin variables and PATH entries for the server. stim-server replaces its environment with the login shell's at start, so a PATH or GEM_HOME in the plist alone is lost and an entry in ~/.zshrc changes the terminal too. These flags apply after that capture, to the se