@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:7787The 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
