@stim-cli/server 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1773 -92
  2. package/dist/protocol-BHSl7bQ_.mjs +2772 -0
  3. package/dist/protocol.d.mts +2 -496
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5388 -684
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-CvqlgkBK.mjs +14887 -0
  50. package/dist/stim-server.mjs +6 -4421
  51. package/package.json +3 -3
  52. package/dist/protocol-IroIg8Td.mjs +0 -865
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  `stim-server` serves Stim state to paired clients, such as the Stim phone app,
4
4
  and lets the clients the Mac grants control run `stim reload` and `stim stop`
5
5
  in a workspace. It runs on the Mac, next to Stim. It changes Stim state only
6
- through those two commands, and writes only its own pairing state and action
7
- log under `$STIM_HOME/server/`.
6
+ through those two commands. It writes only its own pairing state and action
7
+ log under `$STIM_HOME/server/`, and the device recordings described under
8
+ [Recording](#recording).
8
9
 
9
10
  The design is in
10
11
  [`docs/specs/2026-09-25-stim-server-design.md`](../../docs/specs/2026-09-25-stim-server-design.md).
@@ -15,35 +16,178 @@ The design is in
15
16
  stim-server [--port <n>] # serve paired clients, port 7787 by default
16
17
  stim-server pair [--port <n>] [--control]
17
18
  # print a single-use pairing payload
18
- stim-server devices [list] # list paired devices
19
- stim-server devices grant <id> --control|--read
20
- # let a paired device run actions, or only read
21
- stim-server devices revoke <id> # revoke a paired device
19
+ stim-server devices [list] # list paired devices, approved clients and access requests
20
+ stim-server devices grant <id> --control|--read|--build|--device-host
21
+ # let a paired device run actions, or only read;
22
+ # --build approves builds; --device-host approves device hosting
23
+ stim-server devices revoke <id> # revoke a paired device or client, or deny a request
22
24
  stim-server log # list the actions paired devices ran
25
+ stim-server setup --client <node-id> --ticket <t> --expires <iso> --build|--device-host
26
+ # worker-side approval and setup, see below
27
+ stim-server service install|status|update|rollback|uninstall
28
+ # run stim-server as a macOS LaunchAgent, see below
23
29
  ```
24
30
 
31
+ `--env KEY=VALUE` and `--path-prepend <dir>` (each repeatable) apply to the
32
+ serving command: see [Run as a service](#run-as-a-service).
33
+
25
34
  `pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
26
35
  `devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
27
- `identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash.
36
+ `identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash,
37
+ followed by the [build clients](#build-access) and [device-host clients](#device-host-approval).
38
+ New client records carry `requestedCapability` (`build` or `device-host`),
39
+ including after approval. Pending records also carry `pendingUntil`; a legacy
40
+ pending record without `requestedCapability` is a build request.
28
41
  `log --json` prints `{ "actions": [...] }`, the records described under
29
42
  [Actions](#actions).
30
43
 
31
44
  `GET http://127.0.0.1:7787/health` answers requests from this Mac with the
32
- server's name, versions, protocol, `stimHome`, and the Tailscale state it
33
- started with. While Tailscale runs, it also carries `route`, read from
45
+ server's name, versions, `stimBuild` (the digest of the bundled Stim's build,
46
+ which offload compares between Macs), protocol, `stimHome`, `startup`, `busy`
47
+ (the offloaded `builds` and `hostedSessions` running now) and the current
48
+ Tailscale state. It answers HTTP 200 once `startup.state` is `ready`; while the
49
+ state is `pending` or `degraded` (see the LaunchAgent notes below) it answers
50
+ HTTP 503 with the same body. While Tailscale runs, it also carries `route`, read from
34
51
  `tailscale serve status --json` on each request: `routed` with the HTTPS
35
52
  `port` that proxies to the server, `funneled` with the Funnel `ports` that do,
36
53
  `missing`, or `unknown` with a `reason`; the last three carry the `port` the
37
- setup command would use. Stim Desktop uses it to find a running server and
38
- show its route. A request through
39
- `tailscale serve`, on a Tailscale address, or with a `Host` other than
40
- `127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request.
54
+ setup command would use. When the server runs under Stim Host, it also carries
55
+ `host`: `{ name, screenRecording, accessibility }`, the grants macOS gives that
56
+ app (checked at most every 5 seconds), or `null` when the check fails. Stim
57
+ Desktop uses it to find a running server and show its route. A request through `tailscale serve` or on a Tailscale address
58
+ without an `Origin` or `Sec-Fetch-Site` header, which a web page's request
59
+ carries, gets only `{ "server": "stim-server", "version", "protocol" }`, which Stim
60
+ Desktop's Build machines list uses to find stim-server on the other Macs of the
61
+ tailnet. A request from this Mac with a `Host` other than `127.0.0.1` or
62
+ `localhost` gets HTTP 426, like any other plain HTTP request.
63
+
64
+ `GET /setup/<hash>` returns a setup journal over the tailnet HTTP route. The
65
+ hash is exactly 64 lowercase hex characters: SHA-256 of the optional
66
+ `auth.setupTicket` on a build or device-host request (43 base64url characters).
67
+ The registry stores only `setupTicketHash` and retains it after approval;
68
+ missing or invalid tickets leave ordinary access requests unchanged.
69
+
70
+ The journal lives at `$STIM_HOME/server/setup/<hash>.json`, written atomically
71
+ only by stim-server and read through core's state reader. It contains the
72
+ client node, ISO `expiresAt`, requested capabilities, steps, granted request
73
+ ids, completion state and optional exit code; it contains no tokens. Only the
74
+ matching tailnet node can read an unexpired journal. Browser requests, loopback,
75
+ 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.
76
+ The route returns 503 until startup is ready, caches peer identity for 30 seconds
77
+ and returns 429 after repeated identity failures. All responses use
78
+ `cache-control: no-store`. Steps whose ids start with `tools` appear only after
79
+ a build grant. The server prunes journals more than one hour past expiry at
80
+ startup and hourly.
41
81
 
42
82
  `stim-server` runs the `stim` version this package was released with, not the
43
83
  one on your PATH. It reads the login shell's environment once at start, so
44
84
  `PATH`, `ANDROID_HOME`, and `STIM_*` variables match your terminal even when
45
85
  another app starts it.
46
86
 
87
+ ## Set up a worker Mac
88
+
89
+ A person on the worker runs the command generated for their client Mac:
90
+
91
+ ```bash
92
+ stim-server setup --client <node-id> --ticket <43-base64url-characters> --expires <ISO-time> \
93
+ --build --device-host [--port <n>] [--label <name>] \
94
+ [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json]
95
+ ```
96
+
97
+ If the server is not installed globally, use `npx --yes --package @stim-cli/server@<version> stim-server`
98
+ in place of `stim-server`, with the exact version from the client Mac.
99
+ At least one capability is required. The expiry must be in the future and at
100
+ most two hours ahead. The defaults are port 7787 and label `dev.stim.server`.
101
+ Argument and preflight refusals change nothing. Preflight requires macOS,
102
+ Node 22.12+, running Tailscale, the client node on this tailnet, and this user's
103
+ GUI login session.
104
+
105
+ Running setup on the worker is the worker-side approval. It pre-approves at
106
+ most one request per chosen capability, from this node, carrying this ticket,
107
+ until this expiry. A terminal asks y/N for each grant (default No); use `--yes`
108
+ to approve without those questions, including without a terminal. Without
109
+ a terminal or `--yes`, setup refuses before installing anything unless every
110
+ chosen capability already has a matching approval. A person on
111
+ the worker still approves; an SSH-driven run is not offered. Build and
112
+ device-host requests are separate grants. Build never includes read or control.
113
+ A rerun before expiry reports matching approved requests as already approved
114
+ and never grants a second request. After expiry it refuses with exit 2 before
115
+ any change. Requests themselves lapse after 15 minutes.
116
+
117
+ Setup installs its exact server release into
118
+ `~/Library/Application Support/Stim/services/<label>/versions/`, installs
119
+ Stim Host, installs or reuses the managed LaunchAgent, and creates or reuses a
120
+ tailnet-only HTTPS route on 7443 or the next free port. Older managed services
121
+ use the service update busy-wait and rollback behavior. Newer versions stay
122
+ installed; tools report a Stim build difference. A reused server must be at
123
+ least **1.16.0** (ignoring its prerelease suffix for this floor), the first version with setup ticket storage and the journal
124
+ route. An older app server refuses immediately with an update remedy.
125
+
126
+ When a Desktop-run server already answers on the port, setup reuses it and
127
+ installs no LaunchAgent. It requires an existing tailnet route and refuses
128
+ to create one it cannot record. Its permissions belong to that app. The server must
129
+ use the same Stim home. Export a scratch `STIM_HOME` in the shell before
130
+ running setup; `--env` cannot set it. Repeatable `--env KEY=VALUE` and
131
+ `--path-prepend <absolute-dir>` have the same validation and environment order
132
+ as service install. A new LaunchAgent inherits only `STIM_HOME` and `SHELL`;
133
+ pass paths and variables needed for CocoaPods, Java or Android explicitly.
134
+
135
+ For device hosting, setup checks screen recording and device control, requests
136
+ normal macOS prompts, and opens the matching Privacy & Security panes after
137
+ about ten seconds without a grant. Prompts appear on this Mac's screen. A
138
+ terminal accepts `s` to skip each permission. Without a terminal, each missing
139
+ permission waits at most five minutes and remains pending in the journal.
140
+ Missing screen recording prevents viewing hosted simulators; missing device
141
+ control prevents controlling them. For a Desktop-run server, allow its app
142
+ in the same panes. Setup never changes TCC or enables Funnel. Funneled or
143
+ unreadable routes refuse with a remedy. Tool checks print fixes for Xcode,
144
+ simulator runtimes, CocoaPods, JDK, Android SDK and the Stim build; they install
145
+ nothing. Android and CocoaPods checks are not needed for hosting alone.
146
+
147
+ Plain progress goes to stdout; errors use `stim-server: <message>` on stderr.
148
+ `--json` sends progress to stderr and prints one final payload with `ok`,
149
+ `label`, `port`, `route` (state, DNS name, HTTPS port), `server` (version and
150
+ Stim build), `managed`, separate `granted` request ids and client identities,
151
+ `permissions`, `tools` and `warnings`. Final permission values are `granted`,
152
+ `denied` (not authorized), `skipped` or `not-needed`; a timed-out wait remains
153
+ `pending` in the journal and names its unavailable feature. Exit codes come
154
+ from the final journal:
155
+
156
+ | Exit | Meaning |
157
+ | ---- | --------------------------------------------------------------------------------------------------------------------- |
158
+ | 0 | Every chosen capability, needed permission and tool is ready. |
159
+ | 1 | A refusal or failed step. Completed earlier steps remain in place. |
160
+ | 2 | No grant before expiry, or an already expired command. |
161
+ | 3 | At least one grant, but another chosen capability, permission or tool is missing; the output names affected features. |
162
+
163
+ Setup writes `$STIM_HOME/server/setup/<sha256(ticket)>.json` from its first
164
+ step and after every change, including completion and the exit code. The
165
+ node-bound `GET /setup/<hash>` route described above mirrors it until expiry,
166
+ hiding tools until a build grant. Each valid setup run prunes journals more
167
+ than one hour past expiry. A dedicated `server/setup.claims` ownership claim
168
+ serializes runs; the service update claim is released before waiting for
169
+ requests and permissions. A competing setup refuses with the exact claim
170
+ and removal command. Ctrl-C or SIGTERM finishes the running step as failed
171
+ with detail `interrupted`, completes the journal, releases the setup claim,
172
+ and exits 1. A typed N also exits 1; a question timeout follows ticket expiry
173
+ and exits 2.
174
+
175
+ The summary lists what is ready and the undo commands:
176
+
177
+ ```bash
178
+ stim-server devices revoke <build-request-id>
179
+ stim-server devices revoke <device-host-request-id>
180
+ stim-server service uninstall --label <label>
181
+ ```
182
+
183
+ If route verification or recording fails, setup removes the route it just
184
+ created; a failed removal prints the exact `tailscale serve --https=<port> off`
185
+ remedy. Uninstall removes only a route the managed install created. A reused app's
186
+ server and route remain managed by that app. If its health response omits
187
+ permission grants, setup keeps those checks pending or lets a terminal skip
188
+ them; Stim Host's grants do not stand in for another app's permissions. Pairings, Stim Host and its macOS
189
+ permissions remain; remove permission grants in System Settings if desired.
190
+
47
191
  ## Tailscale
48
192
 
49
193
  Tailscale carries and encrypts the traffic between the phone and the Mac, on
@@ -52,7 +196,15 @@ cryptography of its own. You need Tailscale on the Mac and on the phone, in the
52
196
  same tailnet or with the Mac shared to the phone's user.
53
197
 
54
198
  `stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
55
- addresses, never on every interface. Run this once so clients can use
199
+ addresses, never on every interface. It re-reads the Tailscale state in the
200
+ background, so a server that started before Tailscale was up, or while it did
201
+ not answer, starts listening on the Tailscale addresses once it runs, and stops
202
+ when it goes away. In Stim Desktop, choose **Set up connection** in Settings >
203
+ Phones to create and verify the private route without a terminal command. A
204
+ missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may
205
+ require Tailscale browser approval. Existing routes stay unchanged.
206
+
207
+ For manual server setup, run this once so clients can use
56
208
  `wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
57
209
 
58
210
  ```bash
@@ -73,6 +225,15 @@ Funnel port makes it public, and `pair` refuses.
73
225
  When Tailscale is not running, `stim-server` listens on loopback only and
74
226
  says so on stderr. Start Tailscale, then restart `stim-server`.
75
227
 
228
+ Desktop uses `route.setup` with no parameters on its authenticated loopback
229
+ control connection. The result is a verified `ServeRoute`; an existing route is a
230
+ no-op. The server refuses forwarded, browser-origin or tailnet connections,
231
+ read-only clients, unknown configuration and any route exposing this server
232
+ through Funnel. Only the fixed `tailscale serve --bg --https=<free port>
233
+ http://127.0.0.1:<server port>` invocation runs, followed by route verification.
234
+ No LaunchAgent or Funnel configuration is created. Failure output remains
235
+ available for an explicit retry, including Tailscale's HTTPS approval link.
236
+
76
237
  ## Pairing
77
238
 
78
239
  `stim-server pair` prints the JSON that the pairing QR code encodes:
@@ -102,20 +263,944 @@ A connection must send `hello` within 5 seconds. Five failed attempts from the
102
263
  same peer within a minute block new connections from it for up to a minute.
103
264
 
104
265
  Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
105
- closes its open connections.
266
+ closes its open connections. The server checks registrations on file changes
267
+ and once a second, so a missed filesystem notification cannot leave a revoked
268
+ connection authorized.
106
269
 
107
270
  ## Scopes
108
271
 
109
272
  A paired device has the `read` capability, which serves state, or also
110
- `control`, which runs [actions](#actions) and [controls devices](#control). Pairing grants `read` only, unless
111
- the pairing code came from `stim-server pair --control`. On the Mac,
273
+ `control`, which runs [actions](#actions) and [controls devices](#control).
274
+ With the `workspace-diff` feature, `read` also serves changed and untracked
275
+ text file contents in registered workspaces, including any non-ignored
276
+ untracked text file such as an unignored `.env`. Pairing grants `read` only,
277
+ unless the pairing code came from `stim-server pair --control`. On the Mac,
112
278
  `stim-server devices grant <id> --control` adds control to a paired device and
113
279
  `--read` takes it away. Nothing a client sends changes its own capabilities.
114
280
  The server checks the device's capabilities in `devices.json` on every action
115
281
  and control session, and ends a device's control sessions when it loses
116
- `control`, so taking control away applies to open connections at once. `hello` reports
282
+ `control`, so taking control away applies to open connections on the next
283
+ registration check. `hello` reports
117
284
  the capabilities and actions of the connection's device when it connects; a
118
- connection sees a new grant after it reconnects.
285
+ connection sees a new grant after it reconnects. Under Stim Host, an approved
286
+ `hello` also carries the same `host` grants as `/health`, which `stim doctor`
287
+ reports for hosting machines.
288
+
289
+ Every method other than `hello` needs `read`. A device with only `build`
290
+ gets `forbidden` for all of them.
291
+
292
+ ## Build access
293
+
294
+ `build` lets another Mac on the tailnet run its project's code on this Mac to
295
+ build for it: config plugins, CocoaPods hooks, Xcode script phases and Gradle
296
+ plugins run as the user `stim-server` runs as. It never comes with `read` or
297
+ `control`, and `devices grant` never turns a paired device into a build
298
+ client. Grant it only to Macs you trust with that. A connection from this Mac,
299
+ over loopback, cannot get it: it has no tailnet node to bind the token to.
300
+ Because the server trusts `X-Forwarded-For` on loopback (see
301
+ [Pairing](#pairing)), a process on this Mac can still claim a tailnet peer's
302
+ address. Run `stim-server` as a user no one else can run processes as when
303
+ other people use this Mac, and approve a request only when you expect it.
304
+
305
+ A Mac gets `build` only by asking and being approved on this Mac. The client
306
+ sends `hello` with `auth` set to `{ "request": "build", "deviceName" }`, a
307
+ name of at most 64 UTF-16 code units with no control or format characters.
308
+ The server records a pending build client bound to the peer's node, answers
309
+ with a `deviceToken`, no capabilities and
310
+ `approval: { "state": "pending", "expiresAt" }`, and closes the connection.
311
+ `stim-server devices` lists it as `pending build`. On this Mac,
312
+ `stim-server devices grant <id> --build` approves it, or a person runs
313
+ [`stim-server setup`](#set-up-a-worker-mac) on this Mac with its node and ticket, and
314
+ `stim-server devices revoke <id>` denies it; Stim Desktop's **Allow** and
315
+ **Deny** run those commands. Until then, `hello` with its token
316
+ fails with `approval-pending`, which does not count as a failed attempt, while
317
+ the request itself does, so a peer cannot send more requests than failed
318
+ attempts. A request lapses after 15 minutes. Each node has at most one pending
319
+ request, the newest, and the server holds at most 8; more fail with
320
+ `limit-exceeded`. There is no pairing code for `build`: `stim-server pair`
321
+ never grants it.
322
+
323
+ `devices grant` never gives `build` to a paired device, or `read` or
324
+ `control` to a build client. Build clients live in
325
+ `$STIM_HOME/server/build-clients.json`, apart from `devices.json`, so a
326
+ `stim-server` release without `build` never reads them and refuses their
327
+ tokens.
328
+
329
+ ## Device-host approval
330
+
331
+ Device hosting has a separate `device-host` capability. An approved client can
332
+ reserve, boot, reconnect to and stop its own iOS simulator or Android emulator
333
+ through the protocol. It can deliver, install and launch a compatible iOS app
334
+ bundle, Android APK or prebuilt macOS app, stream the iOS simulator or macOS app and control it. The hosted iOS
335
+ app connects back to Metro on the client Mac. Automatic CLI placement and
336
+ Android Metro and viewing remain in [#2266](https://github.com/appandflow/stim/issues/2266).
337
+
338
+ The Stim client can name expected hosts with
339
+ `stim settings set hosting.machines '["<mac>"]'` and request access with
340
+ `stim doctor --fix`. It stores a separate private credential in
341
+ `$STIM_HOME/device-host-machines.json`, pins the worker's tailnet node, and
342
+ reports approval under `deviceHosts` in doctor JSON. Plain doctor makes no
343
+ approval request. This setup does not yet place CLI sessions remotely.
344
+
345
+ A client on the tailnet sends `hello` with
346
+ `auth: { "request": "device-host", "deviceName": "Laptop" }`. As with a build
347
+ request, the server returns a token and pending approval, then closes the
348
+ connection. It binds the token to the peer's tailnet node. Requests expire
349
+ after 15 minutes; each node keeps only its newest pending request of this kind,
350
+ and at most eight device-host requests can be pending. Build requests have a
351
+ separate limit. The same name validation and failed-attempt limit apply.
352
+
353
+ On the hosting Mac, inspect `stim-server devices`, then approve the matching
354
+ request with `stim-server devices grant <id> --device-host`, run
355
+ [`stim-server setup`](#set-up-a-worker-mac) there with its node and ticket, or use **Allow**
356
+ in Stim Desktop. Approve only an expected request: it authorizes that Mac to
357
+ reserve session-owned simulators or emulators and run native app code. **Deny**
358
+ or `stim-server devices revoke <id>` removes it; revocation also closes its
359
+ open authenticated connections. The local-process trust boundary described in
360
+ [Build access](#build-access) applies here too.
361
+
362
+ Hosting clients live in `$STIM_HOME/server/device-host-clients.json`, separate
363
+ from phone pairings and build clients. A hosting token grants no `read`,
364
+ `control` or `build` access, including access to unrelated workspaces. Existing
365
+ read, control and build tokens cannot gain hosting through `devices grant`.
366
+ Loopback requests and `pair --device-host` are refused.
367
+
368
+ ### Hosted availability offers
369
+
370
+ Before reserving, an approved hosting client can send:
371
+
372
+ ```json
373
+ { "id": 1, "method": "device-host.offer", "params": { "platform": "ios", "runtime": "27.0" } }
374
+ ```
375
+
376
+ Use `platform: "android"` with optional `systemImage` and `deviceProfile`, or
377
+ `ios` with optional `deviceType` and `runtime`. Use `macos` without selectors
378
+ for the host architecture and macOS version. Omit selectors for the same
379
+ installed defaults used by reserve. The response includes `platform`, the
380
+ selected SDK `choice` (runtime/model/image and host architecture), `resources`,
381
+ `capacity` and a nullable `declined` reason. An unavailable SDK choice is `null`
382
+ with a reason; unknown or elevated memory pressure declines. The query creates
383
+ no session, simulator, AVD or ownership claim.
384
+
385
+ `capacity.running` counts every reservation not confirmed stopped, including
386
+ unresolved sessions. `max` is `concurrency.maxDevices`; zero means uncapped and
387
+ `available` is then `null`. Android offers also decline when all supported
388
+ console ports are already recorded as occupied. macOS offers decline when all
389
+ 64 app slots are reserved. Resource values are advisory:
390
+ `cpus`, five-minute `loadPerCore`, `memoryFreeBytes`, `memoryPressure` (nullable
391
+ when unknown), and `workerDiskFreeBytes` (nullable when unreadable). Disk space
392
+ is measured on the Stim home volume, which can differ from Android AVD storage.
393
+
394
+ An offer is a snapshot, not a reservation or a boot guarantee. Capacity,
395
+ memory, installed SDKs and local device producers can change before reserve;
396
+ reserve still takes atomic admission and revalidates native preflight. A
397
+ non-null choice with a non-null `declined` reason is currently unavailable.
398
+ This protocol does not select a host for `stim ios` or `stim android`.
399
+
400
+ ### Agent driver route
401
+
402
+ On the hosting Mac, `hosting.agentDriver` names the tool that lets a client's
403
+ coding agent drive the macOS app hosted for that client. `none` (the default)
404
+ starts nothing. `stim-server` reference-counts the driver's daemon on running
405
+ hosted macOS apps: it starts with the first, stops after the last, on
406
+ revocation and on server shutdown, and restarts if it exits while apps run. The
407
+ daemon is held under an ownership claim at `server/agent-device.claims` whose
408
+ child is the daemon, and its state lives in `server/agent-device`.
409
+
410
+ Each hosted app gets a grant scoped to one client and one app. The grant's
411
+ token is kept in memory only, is returned to that client in `app.launch` and
412
+ `app.attach` results, and is never journaled or logged. The route
413
+
414
+ ```text
415
+ ANY /device-host/agent/<session>/<driver path>
416
+ ```
417
+
418
+ on the tailnet `serve` route is forwarded to the loopback daemon, with the
419
+ daemon's own token, only when all of these hold: the request carries that
420
+ session's token as a bearer token, it comes from the tailnet node pinned to the
421
+ session's approved device-host client, the client still holds `device-host`, and
422
+ the session's app is running. Loopback requests, browser requests and other
423
+ sessions' tokens are refused with 403, an unknown or stopped session with 404.
424
+
425
+ The `agent-device` adapter runs the binary that `STIM_AGENT_DEVICE_BIN` names
426
+ in `stim-server`'s environment, or else the first of
427
+ `~/.local/bin/agent-device`, `/opt/homebrew/bin/agent-device` and
428
+ `/usr/local/bin/agent-device`; it never searches `PATH`. It starts
429
+ `agent-device proxy` with the native macOS app backend and a daemon policy that
430
+ admits only requests under a `macos-app` lease and only the commands that drive
431
+ one app. It starts only when the daemon's `/health` lists the `macos-app` lease
432
+ backend (callstack/agent-device#3229); otherwise the grant is
433
+ `{ "driver": "none" }` with a notice, and Stim never hands out the Mac's desktop.
434
+
435
+ For each running hosted app, the adapter allocates a `macos-app` lease for
436
+ `<bundleId>@<pid>` over agent-device's loopback `/admin/leases` route with the
437
+ daemon token, renews it while the app runs and releases it when the app stops
438
+ or the grant is revoked. The grant carries the lease id and owner scope. The
439
+ forward passes only `POST /rpc`, `GET /health` and `GET /artifacts/...`, and it
440
+ rewrites every command and lease call to that session's lease, tenant and
441
+ tenant session isolation, so a client cannot name another lease or session. It
442
+ refuses commands outside the daemon policy's list (also inside a `batch`),
443
+ drops device selectors and runtime hints, and sets `platform` to `macos`.
444
+
445
+ ### Hosted iOS session protocol
446
+
447
+ An approved client sends `device-host.reserve` with an opaque attempt ID and
448
+ its workspace/slot identity:
449
+
450
+ ```json
451
+ {
452
+ "id": 1,
453
+ "method": "device-host.reserve",
454
+ "params": {
455
+ "workspace": "/client/app",
456
+ "slot": "default",
457
+ "platform": "ios",
458
+ "attempt": "run-1",
459
+ "deviceType": "iPhone 17 Pro",
460
+ "runtime": "27.0"
461
+ }
462
+ }
463
+ ```
464
+
465
+ Omit `deviceType` and `runtime` to use the worker's installed defaults. The
466
+ response names an opaque session and its `preparing`, `ready`, `stopping`,
467
+ `stopped` or `unknown` state. Native work runs in a separate bounded child, so
468
+ the connection remains available. Poll `device-host.attach` with
469
+ `{"session":"<id>"}` or `{"attempt":"run-1"}`; a ready session includes the
470
+ exact simulator, runtime and architecture selected on the worker.
471
+
472
+ After a lost reply or connection, replay the same reserve request or attach to
473
+ its attempt. That resolves the same session; a changed request with that attempt
474
+ is refused. Another attempt cannot replace an occupied workspace/slot. The
475
+ client never supplies a worker filesystem path or another client's session.
476
+ `device-host.stop` with `{"session":"<id>"}` deletes only its recorded,
477
+ ledger-owned simulator. Poll attach for completion. A new attempt may reserve
478
+ after the previous one is confirmed stopped. Stopped session homes and device records remain for reconciliation. At start,
479
+ stim-server retires leftover stopped iOS and Android devices through their exact
480
+ private ledger ownership.
481
+
482
+ The worker persists the journal under `$STIM_HOME/server/device-host-sessions/`
483
+ and chooses an isolated worker home under `$STIM_HOME/device-host/sessions/`.
484
+ An uncertain create, child exit, journal or deletion outcome retains the slot
485
+ as `unknown`. Explicit stop can reconcile a complete record after a server
486
+ restart; a live or unverifiable owner is refused. Missing ownership records
487
+ need operator investigation, never a replacement inferred from a simulator name.
488
+ Revoking this client's approval stops its sessions without granting access to
489
+ the worker's other devices.
490
+
491
+ `concurrency.maxDevices` bounds hosted reservations atomically, including
492
+ unresolved sessions. Ordinary local producers do not join that reservation
493
+ transaction, so this is not a machine-wide hard capacity guarantee. Unknown
494
+ inventory or elevated/unknown memory pressure refuses native creation. This
495
+ protocol slice does not change `stim ios` placement.
496
+
497
+ ### Hosted Android session protocol
498
+
499
+ The same reserve/attach/stop methods accept `platform: "android"` with optional
500
+ `systemImage` (an installed `system-images;android-<api>;<tag>;<abi>` package)
501
+ and `deviceProfile` (an installed avdmanager profile). iOS selectors refuse on
502
+ Android requests. The image must match the host architecture. Omitted selectors
503
+ use Stim's existing compatible installed-image and default-profile choice.
504
+ The worker process needs an Android SDK and a JDK that `avdmanager` can use.
505
+
506
+ ```json
507
+ {
508
+ "id": 1,
509
+ "method": "device-host.reserve",
510
+ "params": {
511
+ "workspace": "/client/worktree",
512
+ "slot": "default",
513
+ "platform": "android",
514
+ "attempt": "android-1",
515
+ "systemImage": "system-images;android-30;google_apis;arm64-v8a",
516
+ "deviceProfile": "pixel_6"
517
+ }
518
+ }
519
+ ```
520
+
521
+ A preparing Android result includes a server-selected `consolePort`. The journal
522
+ reserves distinct ports among hosted sessions and excludes the ports in the
523
+ host's local config. The worker refuses an observed occupied port before
524
+ creation and before boot. Local producers do not participate in this reservation,
525
+ so a racing local launch can still refuse a hosted boot; this is not a
526
+ machine-wide hard port or device-capacity guarantee.
527
+
528
+ A ready Android `device` has `avdName`, `serial`, `consolePort`, `systemImage`,
529
+ `deviceProfile` and `architecture` (`arm64-v8a` or `x86_64`). The worker verifies
530
+ its exact AVD name and running ABI before reporting ready. Creation remains
531
+ inside the worker's claimed process group. The worker opens no emulator viewer.
532
+ Stop and revocation use centralized teardown only for its private ledger's exact
533
+ AVD. They delete that AVD and retain the session home and record. At start,
534
+ stim-server retires leftover stopped sessions; unknown native outcomes retain
535
+ the reservation and require explicit reconciliation. Android Metro
536
+ and screen/input routing remain follow-ups in #2266. Android sessions
537
+ refuse the iOS Metro, view and input routes.
538
+
539
+ ### Hosted iOS app delivery
540
+
541
+ App offers, chunks and launches require a ready session held by this server.
542
+ After its owner disappears, session attach reports `unknown`; app operations
543
+ refuse until explicit stop reconciles the retained session. A ready journal
544
+ entry alone does not authorize another native operation.
545
+
546
+ Send `device-host.app.offer` with the ready session, a new opaque app `attempt`,
547
+ the expected `bundleId`, `mode: "development"|"release"`, and
548
+ `manifest: {"sha256":"<digest>","size":<bytes>}`. The manifest is a UTF-8 JSON
549
+ array of `{path, kind, size, sha256}` entries, where `kind` is `file`, `exec` or
550
+ `link`. Paths are relative to the `.app` root. A link's content is its relative
551
+ target; it must resolve inside the bundle. No entry may sit below a file or
552
+ link. Duplicate paths, including Unicode/case aliases, are refused.
553
+
554
+ macOS offers may also include an optional `arguments` string array: at most 32
555
+ entries, 1024 characters per entry and 8192 characters total, with no NUL, CR or
556
+ LF. Empty strings are allowed; an empty array is omitted. Other session platforms
557
+ refuse non-empty arguments. The host passes them as plain executable arguments,
558
+ without environment injection, stores them in the app receipt and echoes them in
559
+ delivery replies. They are visible in process listings, so do not put secrets
560
+ there. Older hosts ignore this field; Stim warns to update `stim-server` on the
561
+ host when its delivery does not report the requested arguments.
562
+
563
+ An offer returns `{delivery, missing}`. Upload each missing digest with
564
+ `device-host.app.chunk` and `{session, attempt, sha256, offset, data}`; `data` is
565
+ base64 for at most 32 KiB of raw bytes. The reply names the next byte `offset`.
566
+ Replay of the same bytes is safe after a lost reply. Re-offer to learn received
567
+ offsets. Upload the manifest first, then re-offer for its missing file content.
568
+ The manifest is limited to 8 MiB and 20,000 entries, with at most 1 GiB per file,
569
+ 1 KiB per link, and 4 GiB of declared bundle content. An app attempt cannot
570
+ change its identity, mode, arguments or manifest. Complete a receiving attempt or stop the
571
+ session before starting another transfer. Manifest, file and partial blobs are shared
572
+ by attempts of one session in its content-addressed store; offers recheck complete
573
+ blob digests and request only missing bytes.
574
+
575
+ An iOS simulator or macOS session can take its content from a build this Mac ran instead: after
576
+ the manifest arrives, send `device-host.app.handoff` with
577
+ `{session, attempt, build: {handoff, sha256}}`, where `handoff` is the token
578
+ and `sha256` the archive digest from that job's `build.artifact` answer. The
579
+ server hands the build over only when the build client that ran it still has
580
+ `build`, the caller has `device-host`, and both approvals belong to the same
581
+ tailnet node. It copies only regular files, and links where the manifest
582
+ declares links, whose directory resolves inside the staged bundle and whose
583
+ bytes match their manifest digest; it answers `{files, bytes}` for what it
584
+ took. The token is spent once the handoff starts. Re-offer and upload whatever
585
+ is still missing; an older server answers `unknown-method`. For iOS, check the
586
+ `hosted-ios-data` hello feature before requesting handoff; without it, upload.
587
+
588
+ After every digest is verified, call `device-host.app.launch` with
589
+ `{session, attempt}`. Poll `device-host.app.attach` for `installed` or `unknown`.
590
+ Reconnect to the same app attempt to reconcile a lost launch reply; replay
591
+ does not install or launch twice. Session attach includes the latest
592
+ `appAttempt`. Development offers may include `devClientScheme` for an Expo
593
+ development client; the attempt cannot change that scheme. The worker verifies the plist identity and simulator metadata,
594
+ the executable's Mach-O platform, architecture and minimum OS, then rechecks
595
+ the exact private device ledger before install and launch.
596
+
597
+ Development launch reports `launched: "unverified"`: a bridge or native process
598
+ alone does not prove bundle delivery. Release launch reports `true` only after observing a live native app
599
+ process; absent evidence remains `"unverified"`. Stop and approval revocation
600
+ cancel an in-flight install before shutting down the owned simulator. Uncertain
601
+ native outcomes retain the session as `unknown`; explicitly stop it before
602
+ retrying. Receipts and artifacts remain in the server-chosen session area;
603
+ artifact retention and session reuse/retirement remain under
604
+ [#2266](https://github.com/appandflow/stim/issues/2266) and
605
+ [#2348](https://github.com/appandflow/stim/issues/2348).
606
+
607
+ ### Hosted Android app delivery
608
+
609
+ Android sessions use the same app offer/chunk/launch/attach methods, with
610
+ `bundleId` naming the expected Android package. Their manifest has exactly one
611
+ `file` entry named `App.apk`, with the APK's byte size and SHA-256. Upload the
612
+ manifest first, then the APK through the same resumable 32 KiB chunks.
613
+
614
+ The worker verifies both digests, inspects the APK's package identity, minimum
615
+ SDK and native library ABIs with installed Android build-tools, and rechecks the
616
+ private ledger, exact running AVD and ABI before installation and launch. It
617
+ resolves adb from that SDK even when the worker PATH does not include it. A
618
+ signature or installation conflict refuses; hosting does not uninstall an
619
+ existing app to resolve it.
620
+
621
+ Release launch reports `true` only after observing a live package process.
622
+ Development launch remains `unverified`; this driver launches the activity but
623
+ does not wire an Android Metro bridge. Replaying an installed app attempt does
624
+ not install or launch again. Owner loss, stop, revocation and uncertain outcomes
625
+ retain the same reconciliation rules as iOS. Android viewing, Metro and client
626
+ placement remain under [#2266](https://github.com/appandflow/stim/issues/2266).
627
+
628
+ ### Hosted macOS app sessions
629
+
630
+ The same offer/reserve/attach/stop methods accept `platform: "macos"` without
631
+ selectors. A macOS reservation counts toward `concurrency.maxDevices` and takes
632
+ the lowest free `appSlot` from 1 through 64. Unresolved sessions retain their
633
+ slot; only a confirmed stop frees it. The ready device describes the host's
634
+ `architecture` (`arm64` or `x86_64`), `macosVersion` and `appSlot`. No simulator,
635
+ emulator or created-device ledger entry is needed.
636
+
637
+ ```json
638
+ {
639
+ "id": 1,
640
+ "method": "device-host.reserve",
641
+ "params": {
642
+ "workspace": "/client/app",
643
+ "slot": "default",
644
+ "platform": "macos",
645
+ "attempt": "macos-1"
646
+ }
647
+ }
648
+ ```
649
+
650
+ Deliver a prebuilt `.app` using the same digest manifest and resumable chunks.
651
+ The manifest must include `Contents/Info.plist` as a file and an executable under
652
+ `Contents/MacOS/`. Only release offers are accepted, with no `devClientScheme`
653
+ and no `com.apple.` bundle identity. The host verifies the plist identity,
654
+ contained executable, architecture slice, MACOS Mach-O platform and minimum OS.
655
+ URL registrations (`CFBundleURLTypes`) and update feeds (`SUFeedURL`) refuse.
656
+
657
+ Before launch the worker stamps `<bundleId>.hosted<appSlot>` and ad hoc signs
658
+ its frameworks and bundle. That identity must fit within 249 characters so its
659
+ `.plist` preferences filename fits within the 255-byte filename limit. The
660
+ existing macOS supervisor owns the app process, with `HOME`, `CFFIXED_USER_HOME`
661
+ and `TMPDIR` pointing into the session's private app home. This is home and
662
+ identity isolation, not an OS sandbox for the client's native code.
663
+ The supervisor and app receive only the host's set `PATH`, `LANG`, `LC_ALL`,
664
+ `LC_CTYPE`, `USER`, `LOGNAME`, `SHELL` and `TERM`, plus the worker `STIM_HOME`
665
+ and isolated home and temporary paths.
666
+ `launched: true` requires matching live app and supervisor process identities.
667
+ Stop and revocation verify and stop those processes, then delete only the last
668
+ recorded hosted bundle identity's preferences domain and plist. A replacement
669
+ app with a different identity removes the prior hosted identity's preferences
670
+ after stopping its processes. macOS preferences use the real user's preferences
671
+ service despite the isolated home. Stop removes the app home and delivered bytes
672
+ from every app attempt, keeping logs and receipts so `app.attach` can still report
673
+ the recorded state.
674
+
675
+ Once a macOS app is `installed`, `device-host.app.launch` and
676
+ `device-host.app.attach` return the agent grant the host's driver issued for that
677
+ session, or `agent: { driver: 'none' }` while no driver runs or it cannot scope
678
+ the app; the driver's reason, if any, is the result's `notice`. The field is
679
+ built for the response and never stored in the receipt or journal; it is absent
680
+ before installation, after stop and for iOS and Android sessions. The worker
681
+ reports the app's pid, `DeviceHost` registers the running app with the agent
682
+ driver before the receipt reads `installed`, and ends that registration when the
683
+ session stops, is revoked or the server closes.
684
+
685
+ `device-host.logs.query` with `{session, cursor?}` returns `{records, cursor, more, checkpoint?}`:
686
+ the NDJSON records the session's macOS app wrote to its captured log (stdout and
687
+ stderr as `client` records, and its exit). iOS sessions capture app-filtered native
688
+ logs as `device` records through bounded `simctl log show` worker queries, with
689
+ a persisted time checkpoint in the isolated home. Windows adapt to the backlog,
690
+ overlap by five seconds and de-duplicate event identities. `checkpoint` is the last
691
+ completed iOS window in epoch milliseconds. `log show` reads persisted entries;
692
+ info-level entries and persistence delayed beyond the overlap may be unavailable.
693
+ The worker holds a separate child-aware log claim. Queries coalesce onto an
694
+ in-flight collection or read captured files; collection starts at most once every
695
+ three seconds per session. App offers, chunks, handoff, launch admission, view and
696
+ control stay available. Installation and stop cancel and settle collection under
697
+ bounded worker and termination deadlines before native work. Each native collection has a
698
+ 10-second step budget inside its 15-second worker deadline. The client drain has
699
+ a 30-second bound, reports progress on stderr and stops on a stalled cursor and
700
+ checkpoint. The host collects once more before deletion on stop, revocation or
701
+ server close, preferring the recent tail if the remaining backlog will not fit.
702
+ If the final collection drops a backlog interval and eventually succeeds, one
703
+ `device` warning record names the interval. Missing or damaged checkpoints are
704
+ ignored and rebuilt; they never prevent reading collected logs.
705
+ The client waits up to 180 seconds for stop. The worker paths fit within that
706
+ wait: the 90-second stop worker,
707
+ 15-second final collection and up to three 10-second worker group-settle bounds
708
+ leave 45 seconds for polling and transport. An in-flight handoff copy and closing
709
+ Metro or view transports are outside those worker bounds.
710
+ The client pulls that final collection after stop. Stop removes the session blob
711
+ store and materialized apps, retaining receipts and collected logs. `cursor` maps each log file name to the
712
+ byte offset after the last complete line read; pass the previous result's cursor to
713
+ receive only newer records, and repeat while `more` is true. Without a cursor, each
714
+ file starts at most 4 MiB before its end. A page holds at most 1 MiB; a file that
715
+ rotated since the cursor is read from the end of its previous generation. The
716
+ session's own client only, for iOS and macOS sessions, and also after the session stopped,
717
+ because stop keeps captured logs. iOS clients check hello feature `hosted-ios-data`;
718
+ without it, they show an update note and copied logs. Servers that predate macOS queries answer `forbidden` or
719
+ `unknown-method`.
720
+
721
+ macOS sessions refuse Metro. Viewing and control are supported while the hosted
722
+ app is running. Automatic CLI placement remains a follow-up in [#2403](https://github.com/appandflow/stim/issues/2403).
723
+
724
+ ### Private hosted Metro
725
+
726
+ The client keeps its verified workspace Metro on loopback. It creates a
727
+ `createMetroGateway` from `@stim-cli/core`, binds it only to its own Tailscale
728
+ address, and supplies the pinned worker's literal tailnet address, local Metro
729
+ port and a fresh 32-byte secret encoded as 64 lowercase hex characters. The
730
+ gateway accepts only that worker address and authenticates each connection
731
+ before forwarding to the fixed local Metro port. It never targets a client
732
+ supplied URL. Close the gateway when its session ends.
733
+
734
+ Call `device-host.metro.open` with `{session, gatewayPort, secret}` on the
735
+ approved hosted connection. The server connects only to that connection's
736
+ authenticated tailnet peer and returns `{port}` for the worker's loopback
737
+ endpoint. Development installation uses that port for `RCT_jsLocation` and,
738
+ when offered, the Expo development-client deep link. HTTP and WebSocket bytes
739
+ stream over WireGuard with socket backpressure; no public tunnel, Funnel or
740
+ Tailscale serve configuration change is needed. The 64 KiB server message
741
+ limit remains unchanged.
742
+
743
+ Replaying the same open request keeps the port. Client disconnection leaves
744
+ the bridge available for the same session while its server owner lives. Call
745
+ `device-host.metro.close` to replace a gateway, then reopen on the same port;
746
+ an occupied port refuses rather than sending the app to another listener.
747
+ Closing a bridge interrupts its active streams. Stop, revocation and server
748
+ shutdown close its sockets before device retirement. After a server owner
749
+ disappears, the retained session requires explicit stop, as app delivery does.
750
+
751
+ Expo dev-launcher and CLI versions that send and honor the `Forwarded` header
752
+ resolve relative manifest URLs against the worker origin. Older versions may
753
+ embed the client's local port instead; this slice does not rewrite manifests
754
+ or claim that those versions work through a different worker port. Client
755
+ placement still needs to check that contract before selecting hosted Metro.
756
+ Bare React Native uses the worker `RCT_jsLocation`. Bridge readiness and
757
+ manifest requests are not launch proof; development remains `unverified` until
758
+ the workspace observes the app's own bundle delivery.
759
+ This is a protocol API for approved clients; automatic iOS CLI placement,
760
+ Android Metro/viewing remains in [#2266](https://github.com/appandflow/stim/issues/2266).
761
+
762
+ ### Hosted viewer relay
763
+
764
+ The client's stim-server relays a hosted workspace's iOS simulator or macOS view and input to
765
+ its host using the client's approved device-host credential over the pinned
766
+ tailnet connection. Stim Desktop and phones keep talking only to their own
767
+ server. The `macos-hosted` and `ios-hosted` features advertise these relays.
768
+ iOS resolves `ios.host` by the requested slot; macOS retains its default slot.
769
+ The local platform and slot replace the host's private target fields in events
770
+ and iOS control audit records. H.264 packets retain their flags and payload and
771
+ carry the local subscription ID, which identifies the platform and slot.
772
+ The relay forwards iOS device artwork and orientation metadata. Hosted targets
773
+ refuse replay (`at`/`rate`), `duoFrame` and `physical` with `bad-request`.
774
+ All of the client's relayed subscriptions and control sessions for one host share one connection,
775
+ whichever local client opened them. Each request still checks the credential
776
+ and the pinned node; a changed credential or endpoint opens a new connection.
777
+ Ending a subscription or session sends `device-host.unsubscribe` or
778
+ `device-host.control.end`, and the connection closes when the last one ends.
779
+ The host's per-connection limits, such as its 32 subscriptions and input
780
+ budgets, therefore apply to all of the client's relayed use of that host.
781
+ While a local client is behind on a relayed H.264 subscription, the relay drops
782
+ its packets until the next keyframe and, when the host advertises the
783
+ `hosted-congestion` feature, sends `device-host.frames.congested` at most every
784
+ 250 ms, so the host lowers that app's bitrate as it does for a local subscriber
785
+ whose socket backs up.
786
+ Hosted macOS frames and control require the default slot; hosted iOS supports named slots.
787
+ `control.begin` still needs the local `control` grant. For macOS apps, Screen Recording for
788
+ viewing and Accessibility for control are granted on the host, not the client.
789
+
790
+ ### Hosted iOS and macOS view and input
791
+
792
+ An approved hosting client can subscribe to its ready session's exact owned
793
+ iOS simulator or running macOS app without access to the worker's registered
794
+ workspaces:
795
+
796
+ ```json
797
+ {
798
+ "id": 8,
799
+ "method": "device-host.frames.subscribe",
800
+ "params": { "session": "<hosted-session-id>", "fps": 5, "maxEdge": 1280 }
801
+ }
802
+ ```
803
+
804
+ The result contains a subscription ID. JPEG delivery uses the existing `frame`
805
+ events; `video: ["h264"]` selects the existing H.264 binary stream and its
806
+ backpressure/keyframe rules. `device-host.frames.keyframe`,
807
+ `device-host.frames.congested` and `device-host.unsubscribe` take that
808
+ subscription ID as `params.subscription`. `device-host.frames.congested`
809
+ (feature `hosted-congestion`) reports that a client further downstream is
810
+ behind on that H.264 subscription; it lowers the bitrate the same way a backed-up
811
+ socket does.
812
+ Hosted capture requires the compiled `stim-frames` helper and does not support
813
+ replay, device artwork or screenshot fallback. macOS frames show only the one
814
+ window of the hosted app. Viewing is refused before launch and after the app
815
+ exits. On the hosting Mac, `stim-server service install` runs the server and
816
+ `stim-frames` under Stim Host, whose Screen Recording and control grants they
817
+ need. Install shows macOS's own requests; `stim-server service status` shows the
818
+ grants. Servers launched by Stim Desktop keep Desktop's grants. Missing Screen &
819
+ System Audio Recording ends the stream; missing Device Control and Data Access
820
+ (Accessibility on macOS 26 and earlier) leaves viewing available.
821
+ See [Run as a service](#run-as-a-service) for the settings panes and Dev caveat.
822
+
823
+ Start control with `device-host.control.begin` and
824
+ `{"session":"<hosted-session-id>"}`. Its result returns a connection-bound
825
+ control session ID and `lease: null`: the hosted lifetime claim protects this
826
+ private device. Only one controller can drive the device; `takeOver: true`
827
+ replaces the previous controller. Use the returned control ID with
828
+ `device-host.input.touch|text`, using the same parameters as ordinary input.
829
+ iOS also supports `device-host.input.button|rotate|posture`. macOS also supports
830
+ `device-host.input.scroll|key`; these two methods are for macOS sessions only.
831
+ Touch coordinates range from 0 to 1 on the streamed display.
832
+ End it with `device-host.control.end` and `{"session":"<control-id>"}`.
833
+
834
+ The worker derives the workspace, slot and device or app identity from its owned
835
+ session; callers cannot select arbitrary worker devices. Every begin and input rechecks session
836
+ ownership and current approval. Hosting approval grants neither ordinary
837
+ `frames.subscribe` nor ordinary `control.begin` access. Disconnecting releases
838
+ that connection's capture and input; reconnect to the same hosted session and
839
+ subscribe again.
840
+
841
+ Installation, stop, revocation and server close end capture and input before
842
+ native work reuses the session claim. In-flight native input settles before
843
+ teardown even after disconnect or takeover; a timeout waits for its child to
844
+ terminate. Known capture closes even if the journal
845
+ is unreadable or unwritable; unresolved native state and claims remain retained.
846
+ Hosted posture commands hold a separate child-aware input claim. A surviving
847
+ command or unresolved child identity blocks replacement ownership, install and
848
+ stop even after the server and capture helper exit; the refusal names the claim
849
+ and its manual cleanup command. An unknown or lost owner requires explicit stop
850
+ before replacement. This worker
851
+ protocol does not add CLI placement or a local viewer relay; those remain in
852
+ [#2266](https://github.com/appandflow/stim/issues/2266).
853
+
854
+ ## Run as a service
855
+
856
+ `stim-server service install` runs stim-server as a per-user LaunchAgent on
857
+ macOS, so a build machine or a phone-serving Mac keeps it running without a
858
+ terminal:
859
+
860
+ ```bash
861
+ stim-server service install [--port <n>] [--label <name>] [--serve]
862
+ [--env KEY=VALUE]... [--path-prepend <dir>]...
863
+ stim-server service status [--label <name>] [--json]
864
+ stim-server service update [--label <name>] --release <version>|--from <dir>
865
+ stim-server service rollback [--label <name>]
866
+ stim-server service uninstall [--label <name>]
867
+ ```
868
+
869
+ `install` writes `~/Library/LaunchAgents/<label>.plist` (label `dev.stim.server`
870
+ by default) and starts it with `launchctl bootstrap gui/<uid>`. The job starts
871
+ Stim Host, which stays alive as the parent of the absolute `node` and
872
+ `stim-server.mjs` of the install that ran the command, on
873
+ `--port` (default 7787), with `RunAtLoad`, `KeepAlive` and a 30 second
874
+ `ThrottleInterval`, and logs to `~/Library/Logs/Stim/<label>.log`. When
875
+ `STIM_HOME` or `SHELL` is set in the installing shell, the job carries it. It
876
+ takes the `node` from PATH when that path resolves to the running binary, so a
877
+ Homebrew Node upgrade does not break the plist. Running `install` again
878
+ rewrites the plist and restarts the job; if the new job cannot start, it puts
879
+ the old plist and job back. `install` and `uninstall` act only on a plist
880
+ that `install` wrote, and refuse a port that another stim-server (Stim
881
+ Desktop's, for example) already answers on. Install from a permanent
882
+ installation, not from an `npx` cache, because the plist stores its paths. It never touches pairings, anything
883
+ under `$STIM_HOME/server` or settings. `install` waits up to 15 seconds for a
884
+ `/health` answer that is no longer `pending`. An installed LaunchAgent with no
885
+ answer reports readiness as unavailable, and one that answers `degraded` reports
886
+ the reason; both point to `status` and its log. Installation success alone does
887
+ not prove that the server is ready.
888
+
889
+ Install downloads the Stim Host release this package pins from the
890
+ `host-v<version>` GitHub release, checks the pinned SHA-256 and App & Flow's
891
+ Developer ID signature, and installs **Stim Host** (`dev.stim.host`) at
892
+ `~/Applications/Stim Host.app`; grants then survive Stim Host updates. Install
893
+ keeps an identical bundle untouched. A Mac that ran the earlier **Stim Host Dev**
894
+ keeps `~/Applications/Stim Host Dev.app`; its owner can delete it and its System
895
+ Settings entries.
896
+
897
+ After starting the service, install shows macOS's own permission requests on
898
+ this Mac's screen, one at a time: Stim Host keeps running after install returns,
899
+ asks for Device Control and Data Access once the Screen Recording request is
900
+ answered (it waits up to two minutes for each), and opens that System Settings
901
+ pane with Stim Host listed when macOS shows no request for it. You only
902
+ approve; over SSH, use Screen Sharing to see that screen. If a request does not
903
+ appear, turn Stim Host on in **System
904
+ Settings > Privacy & Security > Screen & System Audio Recording** (**Screen
905
+ Recording** on macOS 14) and **Device Control and Data Access** (**Accessibility**
906
+ on macOS 26 and earlier). Stim never changes these settings itself. Servers
907
+ launched by Stim Desktop keep Desktop's grants. For an older node-first service,
908
+ run `stim-server service install` again to use Stim Host.
909
+
910
+ The server binds its port before it touches the Stim home. It then reads the
911
+ Stim home, its `server` and `workspaces` directories and the recording
912
+ ownership directories in a read-only child process, so a read that never
913
+ returns, such as one on a stalled external volume, cannot block the server's
914
+ event loop. Until that read returns, `/health` answers HTTP 503 with
915
+ `startup: { "state": "pending" }`, WebSocket upgrades and device-host agent
916
+ requests get 503, and no status follower, native helper, file watcher or
917
+ recorder starts. A read that does not return within 10 seconds, plus up to one
918
+ second to stop the child, leaves the server listening with
919
+ `startup: { "state": "degraded", "reason": "..." }` and HTTP 503 on `/health`,
920
+ logs the reason once, and reads again every 30 seconds. When a read returns,
921
+ the server starts its watchers and recorder, `/health` answers 200 with
922
+ `startup: { "state": "ready" }`, and no restart is needed. `service status`
923
+ prints `health: degraded` or `health: starting` and the reason, and `service
924
+ install` reports a listening but degraded server instead of success. Returned
925
+ filesystem errors still go through the recorder's existing claim refusal;
926
+ missing directories are created by that protocol. The check preserves claims and
927
+ recordings. It does not restore an inaccessible volume or change the server's
928
+ permissions, and a stall that starts after the check passes still blocks the
929
+ server until the operating system returns.
930
+
931
+ The job runs in your GUI login session, so it starts when you log in and not at
932
+ boot. On a Mac with no one at the screen, turn on automatic login. Moving the
933
+ install, or changing its Node, needs `install` again.
934
+
935
+ `--serve` adds the tailnet-only route from [Tailscale](#tailscale) on the port
936
+ `stim-server` would suggest. It refuses when Funnel is on for a port that
937
+ reaches the server, and it never enables Funnel. When a route already reaches
938
+ the port, `install` records that it did not create it. `uninstall` removes a
939
+ route only when `install` created it and it still points at this port. Without
940
+ `--serve`, `install` prints the command to run. A route that exists only in a
941
+ foreground `tailscale serve` session counts as present. To move a service that
942
+ created a route to another `--port`, run `uninstall` first.
943
+
944
+ `--env KEY=VALUE` and `--path-prepend <dir>` pin variables and PATH entries for
945
+ the server. stim-server replaces its environment with the login shell's at
946
+ start, so a `PATH` or `GEM_HOME` in the plist alone is lost and an entry in
947
+ `~/.zshrc` changes the terminal too. These flags apply after that capture, to
948
+ the server and to the builds it starts, and the plist stores them as
949
+ `ProgramArguments`. `--path-prepend` puts directories in front of PATH in the
950
+ order given; `--env` cannot set `STIM_HOME`. The values sit in plain text in the plist and in the process arguments, so do not pass secrets. For a private CocoaPods:
951
+
952
+ ```bash
953
+ stim-server service install --serve \
954
+ --path-prepend /Volumes/SSD/gems/bin \
955
+ --env GEM_HOME=/Volumes/SSD/gems --env GEM_PATH=/Volumes/SSD/gems \
956
+ --env LANG=en_US.UTF-8 --env LC_ALL=en_US.UTF-8
957
+ ```
958
+
959
+ `status` prints whether launchd loaded the job, its state, pid, run count and
960
+ last exit code (a server that exits at start, for example on a port in use,
961
+ restarts every 30 seconds), the `/health` answer, the serve route and whether
962
+ `install` created it, the host app and its permission grants, the log path, and the digest of the bundled Stim's build
963
+ next to the `stim` on PATH. Offload needs the same digest on the client, so a
964
+ mismatch there is not an offload match either. `--json` prints the same fields
965
+ as one object.
966
+
967
+ `update` installs another stim-server beside the one the job runs and switches
968
+ the job to it. `--release <version>` takes an exact version from the public npm
969
+ registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and
970
+ npm checks each package against the registry's integrity. `--from <dir>`
971
+ installs the `.tgz` packages in that directory, such as `pnpm pack` output of
972
+ the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and
973
+ `@stim-cli/server` packages of a checkout, and records each file's sha256.
974
+ Either way, `npm audit signatures` must verify the registry signature of every
975
+ package that came from the registry, and the provenance attestation of each one
976
+ that has it, or the update stops before it touches the job. Install scripts
977
+ never run. Each install goes to
978
+ `~/Library/Application Support/Stim/services/<label>/versions/`, in a directory
979
+ named by its version, Stim build and server code, and must start with the job's
980
+ `node` before the switch. `update` then waits up to 30 minutes for `/health` to
981
+ report no offloaded build and no hosted session (a server older than `busy` in
982
+ `/health` restarts without waiting), rewrites only the server script in the
983
+ plist (recording the previous one), restarts the job and waits up to 90 seconds
984
+ for `/health` to answer with the new version and Stim build. When it does not,
985
+ `update` puts the previous plist back and restarts it. Interrupt signals wait
986
+ for the switch to finish. A second update to the same build reuses its
987
+ directory, and after a switch `update` removes the installs neither the current
988
+ nor the previous script uses. `rollback` switches the job back to the previous
989
+ script under the same checks, and running it again returns to the newer one.
990
+ Neither touches the node, the host app, the `--env` and `--path-prepend` pins,
991
+ pairings, approvals, `$STIM_HOME` or the serve route. Install, update, rollback
992
+ and uninstall of one label run one at a time.
993
+
994
+ `uninstall` boots the job out, removes the plist, the servers `update`
995
+ installed and, when `install` created it, the serve route. Logs, pairings and the host app stay; other labels may use the app. Use `--label` and `--port` to run a
996
+ second service beside the first, for example with another `STIM_HOME`.
997
+
998
+ To set up a build machine: install Stim and stim-server on the Mac, run
999
+ `stim-server service install --serve` (with the pins its toolchain needs), set
1000
+ `offload.workerRoot` there, and on each client run
1001
+ `stim settings set offload.machines '["<mac>"]'` and `stim doctor --fix`.
1002
+ Approve the request on the build machine with
1003
+ `stim-server devices grant <id> --build`.
1004
+
1005
+ ## Remote update
1006
+
1007
+ A Mac this one approved for builds (`--build`) or device hosting
1008
+ (`--device-host`) can update this Mac's `stim-server service` over its tailnet
1009
+ connection, so the build machine keeps up with the client without ssh. A
1010
+ server that offers it lists `server-update` in its `hello` features. A paired
1011
+ phone, a `read` or `control` device and a connection from this Mac cannot. The
1012
+ methods need that approval, not `read`:
1013
+
1014
+ - `server.update.status` returns `server` (`version` and `stimBuild`),
1015
+ `service` (the label of the LaunchAgent this server runs under, or `null` when
1016
+ it does not run as a `stim-server service` and so cannot update itself),
1017
+ `acceptsClientBuilds`, `running` (the update in progress, with its `state`,
1018
+ `missing` package bytes and the last lines of its `log`) and `last` (how the
1019
+ label's last `service update` ended, whoever ran it).
1020
+ - `server.update.start` takes `{ "release": "<version>" }`, an exact version
1021
+ that `stim-server service update --release` installs, or
1022
+ `{ "packages": [{ "name", "size", "sha256" }] }`, at most 8 `.tgz` packages
1023
+ of the client's own build. Packages need `server.acceptClientBuilds` set to
1024
+ `true` on this Mac (`stim settings set server.acceptClientBuilds true`); a
1025
+ release needs no setting. One update runs at a time.
1026
+ - `server.update.chunk` takes `{ id, name, offset, data }`, at most 32 KiB of
1027
+ base64 at the package's next offset. A package whose bytes do not match its
1028
+ `sha256` ends the update. The upload lapses after 10 minutes without a chunk.
1029
+
1030
+ Once the release is named or the last package arrives, the server stops taking
1031
+ new offloaded builds and hosted sessions, and runs
1032
+ `stim-server service update --label <label> --release|--from` with its own
1033
+ node and script as a detached process, which outlives the restart it causes
1034
+ and writes to `~/Library/Application Support/Stim/services/<label>/update.log`.
1035
+ The connection closes when the job restarts. The client reconnects and reads the
1036
+ outcome from `hello` and `server.update.status`. An update that ends while this
1037
+ server still runs (a refused upload, a failed install) takes new work again.
1038
+ The action log records each update as `server.update.started`, and as
1039
+ `server.update.ended` when it ends while this server still runs. A chunk that
1040
+ arrives once every package is in gets `action-busy`, and a retried reservation
1041
+ of an existing hosted session still answers during the drain.
1042
+
1043
+ Stim Desktop starts these updates through its own local stim-server with two
1044
+ methods that only the local Desktop control connection may call:
1045
+ `machines.update.start` takes `{ "machine" }`, an approved `offload.machines`
1046
+ or `hosting.machines` entry. It connects to that machine's pinned node with
1047
+ the stored credential and asks it for this server's npm release. When this
1048
+ server runs from a Stim checkout, it instead packs the checkout's workspace
1049
+ packages as `npm pack` would and sends them. `machines.update.status` takes
1050
+ the same `{ "machine" }` and returns `remote` (the machine's
1051
+ `server.update.status`), `unreachable` (why it could not be read, also while it
1052
+ restarts) and `upload` (`sent`, `total` and `error` of the packages).
1053
+
1054
+ ## Offloaded builds
1055
+
1056
+ A client with `build` runs its iOS simulator builds and Android emulator
1057
+ debug builds and macOS SwiftPM Debug builds here with these methods.
1058
+ They need `build`, not `read`; a device without `build` gets `forbidden`.
1059
+ The server re-reads the build clients on every call, and revoking a client
1060
+ closes its connections and cancels its builds.
1061
+
1062
+ - `build.offer` takes `repo` (the client's name for its repository: letters,
1063
+ digits, `.`, `_` and `-`, at most 80) and an optional `lockfile` sha256. It
1064
+ returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1065
+ `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1066
+ `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
1067
+ runtimes with an iPhone simulator to build for; `jdk`, the major version of
1068
+ the JDK in `JAVA_HOME` or the macOS default; and `androidSdk`, the `ndk`,
1069
+ `buildTools` and `platforms` directories of the SDK in `ANDROID_HOME` or
1070
+ `~/Library/Android/sdk`, null without one), `capacity` and `warm`
1071
+ (`checkout`, `dependencies` when the last install used that lockfile, and
1072
+ `build` once a build ran there, including a non-empty repository `macos/`
1073
+ scratch directory) for that repository. `capacity` holds
1074
+ `running` and `max` offloaded builds, `diskFreeBytes` of the worker root's
1075
+ volume and `minDiskFreeBytes`, `cpus`, `loadPerCore` (the 5-minute load
1076
+ average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
1077
+ compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
1078
+ `concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
1079
+ `offload.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
1080
+ build now, or null. It declines while it runs its limit of offloaded builds,
1081
+ while the worker root's volume has less than 10 GiB free, while `builds`
1082
+ reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
1083
+ `maxLoadPerCore`. The toolchain is read at most once a minute; capacity on
1084
+ every offer.
1085
+ - `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
1086
+ manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
1087
+ a link's blob being its target. Pages accumulate until `done`; the next
1088
+ `build.sync` starts a new manifest. A path is relative, with no `.`, `..`,
1089
+ empty or `.git` component (in any case). The result's `missing` lists the digests of the
1090
+ page this Mac lacks. The client then sends each as binary frames: 32 bytes of
1091
+ the sha256, then the next bytes of that blob, one blob after another. A frame
1092
+ for a blob that was not asked for, one that overruns its size, or bytes that
1093
+ do not match the digest close the connection with 4400. Blobs are kept per
1094
+ client and shared by all its repositories.
1095
+ - `build.start` takes `repo`, `project` (the app directory in the repository),
1096
+ `platform` (`ios`, `android` or `macos`), `configuration`, `scheme`, `runtime` (a
1097
+ simulator runtime identifier, required for `ios`, null for `android`),
1098
+ `fingerprint`, `packageName`, `isExpo`, `optimizations`, `android` (for
1099
+ `android`: `variant`, `abi`, `gradleBuildCache`, `pch` and `compilerCache`,
1100
+ `ccache` or `none`) and `stimBuild`, after the whole manifest of `repo` was
1101
+ synced on this connection. It returns `{ "job" }`. It fails with
1102
+ `build-busy` while the offer would be declined, naming the reason, or while
1103
+ another build of the same client and repository runs, or while every one
1104
+ of this Mac's `concurrency.maxBuilds` slots is held, and with
1105
+ `build-refused` when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1106
+ `STIM_HOME` set to the repository's area. It makes the area's checkout
1107
+ hold exactly the manifest (a file git lists as untracked and not ignored,
1108
+ and not in the manifest, is deleted; ignored dependencies and generated
1109
+ projects stay), installs JavaScript dependencies when the lockfile changed,
1110
+ runs prebuild and `pod install` when needed, refuses unless the fingerprint
1111
+ equals `fingerprint`, and runs `xcodebuild` for one of this Mac's iPhone
1112
+ simulators on `runtime`, which it never boots, or Gradle's
1113
+ `assemble<variant>` for `abi` with ccache under the area's Stim home. A
1114
+ Gradle daemon leaves the build's process group, so no claim or slot tracks
1115
+ it. An Android build keeps the client's daemon warm, with Gradle's idle
1116
+ timeout set to `offload.gradleDaemonIdleMinutes` (default 30; 0 stops it
1117
+ when the build ends), and stops it when the build gets SIGTERM. Gradle
1118
+ fixes a daemon's idle timeout when the daemon starts, so a new value
1119
+ applies to the next daemon. Once no build of that client runs, stim-server
1120
+ sends SIGTERM to each daemon whose command line runs from the client's
1121
+ Gradle home when a build of the client was cancelled, when the client
1122
+ loses `build`, and, for every client, while this Mac has less than 2 GB of
1123
+ available memory (total memory minus Activity Monitor's Memory Used). It
1124
+ checks every minute and on each change under its server directory. A daemon
1125
+ whose Gradle home is deleted stops itself within seconds, because Gradle
1126
+ expires a daemon whose registry file is gone.
1127
+ A macOS request supplies `macos: { product, infoPlist, bundleId }` and
1128
+ optionally `resources` (up to 256 destination-to-source entries) and
1129
+ `assetCatalog` (a string or null), omitting runtime, configuration, scheme, packageName, isExpo, optimizations and android.
1130
+ Product matches `^[A-Za-z0-9_.-]{1,100}$`; infoPlist is a valid relative build
1131
+ path within the project; bundleId matches `^[A-Za-z0-9][A-Za-z0-9.-]{0,199}$`.
1132
+ Each `resources` destination must be a contained path under
1133
+ `Contents/Resources` (no empty, `.` or `..` segments, no backslash, at most
1134
+ 1024 characters) and each source, like `assetCatalog`, a valid build path
1135
+ relative to the repository root, not the project. Missing or malformed
1136
+ options get `bad-request`. Its fingerprint is sha256 of
1137
+ every manifest entry sorted by path, encoded as `path NUL kind NUL sha256 LF`.
1138
+ The worker recomputes it before building and refuses a mismatch. It skips
1139
+ JavaScript installation, prebuild, pods and project fingerprinting. It runs
1140
+ `swift build -c debug --product <product> --scratch-path <scratch> --cache-path <cache>`,
1141
+ then `swift build -c debug --scratch-path <scratch> --cache-path <cache> --show-bin-path`,
1142
+ without a jobs limit. Scratch is `repos/<repo>/macos/<project with / replaced by _>`,
1143
+ or `root` for an empty project. The job JSON includes `swiftpmCache` alongside
1144
+ `blobs`, pointing at the client's `cache/swiftpm`. Swift stays in the job's
1145
+ process group so cancellation stops it. A failed Swift command reports
1146
+ `swift-failed` with the first error or last three output lines. The worker
1147
+ stages the executable, frameworks and resource bundles in
1148
+ `out/<job>/<Product>.app`, copies each declared resource from its checkout
1149
+ (refusing a source whose realpath leaves it), compiles `assetCatalog` with
1150
+ `xcrun actool`, stamps the requested bundle ID and signs ad hoc. A bad
1151
+ resource entry also reports `swift-failed`, before Swift runs.
1152
+ It returns that app as a tar archive, its manifest fingerprint, timings and
1153
+ `compilationCache: {}`. The client verifies the digest, identity, executable
1154
+ and signature before promotion and launches locally. Every offload failure
1155
+ falls back locally; macOS artifacts are not cached. Matching Xcode and macOS
1156
+ SDK are required, and the worker needs network access for the first SwiftPM
1157
+ dependency fetch; the dependency cache is shared only within that client.
1158
+ - `build.progress` events `{ "event": "build.progress", "job", ... }` carry a
1159
+ `phase` and `msg`, a build-log `record`, and last the `outcome`: `ok`, and
1160
+ on success `artifact` (`name`, `size`, `sha256` of a tar of the `.app` or
1161
+ `.apk`),
1162
+ `fingerprint`, `compilationCache` and `timings`, otherwise `code` and
1163
+ `message`.
1164
+ - `build.cancel` takes `job` and stops it. Closing the connection with a
1165
+ close frame cancels its jobs too. The build's process group gets SIGTERM,
1166
+ then SIGKILL 5 seconds later. A connection that ends without a close frame
1167
+ (1006, for example when the network drops) leaves its jobs running for 5
1168
+ minutes; a job no connection takes back by then is cancelled, and so is one
1169
+ whose client is revoked meanwhile. A job whose `build.start` answer was lost
1170
+ with the connection is only reclaimed this way.
1171
+ - `build.attach` takes the `job` of this client that another connection
1172
+ holds or held, such as one whose connection dropped or stalled, and moves it
1173
+ to this connection: its later `build.progress` events come here, and
1174
+ `build.cancel` and `build.artifact` take it. It answers `{ "outcome" }`,
1175
+ the job's outcome when it already ended, else null. Progress sent while no
1176
+ connection held the job is not replayed. A job that is gone, or belongs to
1177
+ another client, gets `bad-request`.
1178
+ - `build.artifact` takes the `job` of a successful build and sends the archive
1179
+ as binary frames, each 32 bytes of its sha256 and then the next bytes, then
1180
+ answers `{ "name", "size", "sha256" }`, and deletes it here. For an iOS simulator or macOS
1181
+ job the answer also carries `handoff`, a single-use token, and the staged
1182
+ `.app` stays for 10 minutes so a hosted session on this Mac can take it with
1183
+ `device-host.app.handoff`. A client keeps at most one such bundle; its next
1184
+ fetched iOS or macOS build or a revoked approval deletes it. An archive nobody fetched is deleted when its job
1185
+ is cancelled; one left by a server that crashed stays under
1186
+ `repos/<repo>/out/` until you delete it.
1187
+
1188
+ The worker root is `offload.workerRoot` in this Mac's Stim settings, or
1189
+ `$STIM_HOME/build-worker`. Each client gets `<root>/<device id>/`, with its
1190
+ blobs, caches (`CP_HOME_DIR`, `CP_CACHE_DIR`, the pnpm store and
1191
+ `GRADLE_USER_HOME` and SwiftPM) and one area
1192
+ per repository under `repos/<repo>/`: the checkout, its own Stim home with
1193
+ DerivedData, the compilation cache and ccache, and the output. A build never reads or
1194
+ writes this Mac's own Stim home. An ownership claim at
1195
+ `repos/<repo>.claims`, whose child is the build's process group, guards each
1196
+ area; it is released only once that group is gone, and a claim whose server
1197
+ and build are both gone is recovered by the next build. With a non-zero
1198
+ `concurrency.maxBuilds`, the build also holds one of this Mac's build slots
1199
+ (`$STIM_HOME/build-slots/`), the same claims local Stim builds take, with the
1200
+ same child, so a local build waits for it and `builds` counts it. Each finished build
1201
+ appends a `build` record to the [action log](#actions) with the repository as
1202
+ `workspace`. The worker root keeps growing with each repository; delete a
1203
+ client's directory to reclaim it.
119
1204
 
120
1205
  ## Protocol
121
1206
 
@@ -124,40 +1209,152 @@ answered by `{ "id", "result" }` or `{ "id", "error": { "code", "message" } }`.
124
1209
  Events are `{ "event", "subscription", ... }`.
125
1210
 
126
1211
  - `hello` must come first. Params: `protocol` (1), `client` (`name`,
127
- `version`), and `auth`, either `{ "pairingToken", "deviceName" }` or
128
- `{ "deviceToken" }`. The result carries the server name and versions, the
129
- device's `capabilities` (see [Scopes](#scopes)), the `actions` it may run
1212
+ `version`), and `auth`, one of `{ "pairingToken", "deviceName" }`,
1213
+ `{ "deviceToken" }`, or `{ "request": "build", "deviceName" }` (see
1214
+ [Build access](#build-access)). The result carries the server name and versions, the
1215
+ device's `capabilities` (see [Scopes](#scopes)), the server's `features`
1216
+ (`physical-ios` and `physical-android` when it serves `physical: true` for
1217
+ that platform: see `frames.subscribe` below for an iPhone and
1218
+ [Physical Android devices](#physical-android-devices), and `notifications`
1219
+ for `notifications.list` and the `notification` event),
1220
+ the `actions` it may run
130
1221
  (none without `control`), the paired device, and the new `deviceToken` when
131
1222
  the hello paired. `server.home` is the home folder
132
1223
  of the user the server runs as, so clients can show paths under it as
133
1224
  `~/...`.
134
1225
  - `status.subscribe` returns a subscription id. Each `status` event carries a
135
- full payload as `stim status --watch --json` prints it. All subscribers share
1226
+ full payload as `stim status --watch --json` prints it, including each
1227
+ environment's `physicalDevices`, the phones it leases, and its `agents`, the
1228
+ Claude Code and Codex sessions working in it. Those carry the session id,
1229
+ working directory, the short session name the tool keeps, and times, never
1230
+ prompts or conversation. `openUrl` there is a `claude://` or `codex://` link
1231
+ that only the Mac can open; the server has no action that opens it. `webUrl`
1232
+ is a Claude Code session's `https://claude.ai/code/` link while Remote Control
1233
+ is connected, which a phone opens in the Claude app or a browser.
1234
+ `endedAgents` lists the sessions that stopped running there in the last 3
1235
+ days, with the same fields as last found, no `pid`, and `endedAt`. The server
1236
+ drops `CLAUDE_CODE_SESSION_ID` and `CODEX_THREAD_ID` from the environment of
1237
+ the `stim` commands it runs, so a phone's reload is not recorded as the work
1238
+ of the agent session that started the server. All subscribers share
136
1239
  one `stim status --watch --json` child, which stops with the last
137
- subscriber.
1240
+ subscriber. While any status subscription is open, the server keeps a CPU
1241
+ and memory history from each payload's `machine.owners`, and every `status`
1242
+ event carries it beside the payload as `usage`, once it holds a reading:
1243
+ `{ "intervalMs", "endAt", "environments", "devices" }`. It covers the last 10
1244
+ minutes in 15-second slots, the cadence at which `status --watch` rereads
1245
+ machine usage, oldest first, at most 40 points: point `i` of `n`
1246
+ is at `endAt - (n - 1 - i) * intervalMs`, and a slot no payload fell in is
1247
+ `null`. Each `environments` entry is `{ "workspace", "cpuPercent",
1248
+ "memoryMb" }` and sums every machine owner of that environment path; each
1249
+ `devices` entry is `{ "kind", "id", "workspace", "slot"?, "cpuPercent",
1250
+ "memoryMb" }` for a simulator (`id` its UDID) or emulator (`id` its AVD name).
1251
+ `cpuPercent` is ps %CPU, where 100 is one core. The history lives in the
1252
+ server's memory only. While the server holds `stim device lock` leases for
1253
+ phones that control a device, every `status` event also carries their
1254
+ `grantedAt` times as `ownLeases`, so a client can tell a person controlling a
1255
+ device from an agent driving it: an `activity.driver` of `stim device lock`
1256
+ whose `since` is in `ownLeases` is a phone.
138
1257
  - `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
139
1258
  events: first the last `tail` matching records, then new ones in batches.
140
- Both take the Stim Desktop log viewer's filters: `workspace` (required),
141
- `sources` (`metro`, `client`, `device`, `build`, `agent`), `slot`, `level` (the
1259
+ Both take the Stim Desktop log viewer's filters: exactly one of `workspace`
1260
+ (an environment path) or `archive` (an `archived[].id` from status),
1261
+ `sources` (`metro`, `client`, `device`, `build`, `agent`, `maintenance`), `slot`, `level` (the
142
1262
  minimum), `grep` (a regular expression), `errors`, and `tail` (1 to 5000,
143
1263
  5000 by default). Without `sources`, `errors` keeps the CLI's default error
144
1264
  scope. They run `stim logs --json` and `stim logs --json --follow` in the
145
1265
  workspace. Subscribers with the same workspace and filters share one
146
- `--follow` child, which stops with the last of them.
147
- - `stats.get` and `settings.get` return the payload of `stim stats --json` and
1266
+ `--follow` child, which stops with the last of them. Archive requests read
1267
+ retained logs and agent-device session actions locally through core, with the same JSON filters, ordering,
1268
+ markers and error context. Archive reads are synchronous and queued per
1269
+ connection, yielding to the event loop between requests. Pending archive
1270
+ reads share the four-request limit with commands. Archived session actions
1271
+ retain available platform and device attribution; device ids and Stim slot
1272
+ assignments absent from retained files are unavailable after removal. They never follow files or use a hosted relay.
1273
+ An archive subscription sends its records in the same batches and limits,
1274
+ then `{ "event": "logs-ended", "subscription": "s3" }` and releases the
1275
+ subscription, including when no records match. These reads need `read`.
1276
+ `archive` requires an updated server: omit `workspace` so older servers
1277
+ refuse with `bad-request` instead of reading a live replacement at that path.
1278
+ Both or neither selector is `bad-request`; traversal ids are `bad-request`,
1279
+ and unknown, invalid or symlinked archive directories are `unknown-workspace`
1280
+ with a message naming the archive id.
1281
+ - `stats.get` returns the same payload as `stim stats --json`, using the shared
1282
+ core reader in a bounded, cancellable server child. The stats part of
1283
+ `machine.details` uses the same reader. `settings.get` runs
148
1284
  `stim settings --json`, which masks sensitive values. Without `workspace`,
149
- they run in the home directory and cover the machine only.
150
- - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`),
1285
+ these reads use the home directory as their project context.
1286
+ - `frames.subscribe` takes `workspace`, `platform` (`ios`, `android`, `web` or `macos`),
151
1287
  `slot` (`default` when absent), `fps` (1 to 30, 5 by default) and `maxEdge`
152
1288
  (240 to 2048 pixels, 1280 by default), and sends `frame` events: a JPEG,
153
1289
  base64 in `data`, with `width`, `height` and `capturedAt`, at most `fps` a
154
1290
  second and only when the screen changed. It serves only a booted simulator
155
- or a running emulator that `stim status` lists as owned by that workspace;
1291
+ or a running emulator that `stim status` lists as owned by that workspace,
1292
+ or with `web` the page of the workspace's running Stim-owned Chrome from
1293
+ `stim web` (default slot only), or with `physical: true` the physical
1294
+ iPhone (see below) or Android phone (see
1295
+ [Physical Android devices](#physical-android-devices)) the workspace leases
1296
+ in that slot;
156
1297
  any other device ends the subscription with a `frames-failed` `error`
157
1298
  event, and so does a device that stops or changes owner. A client whose
158
1299
  socket has more than two frames unsent skips frames and gets the newest
159
1300
  once it catches up.
160
1301
 
1302
+ A server advertising `device-frames` accepts `deviceFrame: true` for live
1303
+ ordinary iOS simulators and Android emulators. The existing read permission
1304
+ and registered-workspace/owned-device checks apply. `device-frame` events
1305
+ carry `artwork` (or null): PNG `background` and `foreground` layers, outer
1306
+ `width` and `height`, screen `aperture` (`x`, `y`, `width`, `height`),
1307
+ `cornerRadius`, and clockwise `quarterTurns`. Pixels come from installed
1308
+ DeviceKit or Android skins at runtime; no artwork is bundled and no local
1309
+ file paths are exposed. Layers are sent only to opt-in subscriptions and
1310
+ cleared on replay or device changes. The screen bytes stay unchanged.
1311
+ JPEG `artworkTurns` and live H.264 flag bit 5 with quarter-turns in bits 3-4
1312
+ bind housing to its capture, including same-size rotations. A client shows
1313
+ housing only when that rotation and aperture aspect match its screen.
1314
+ Missing, oversized or unsupported artwork falls back to the existing screen:
1315
+ Duo, Android foldables/circular displays, web and physical devices have no
1316
+ housing in this path. Artwork notices stay within the helper's 16 MiB message
1317
+ limit; the combined PNG layers are limited to 10 MiB before base64 encoding.
1318
+
1319
+ With `macos`, it serves the front standard window of the workspace's verified
1320
+ running native app, in the default slot, when hello advertises `macos-window`.
1321
+ The helper verifies the recorded PID, process start time, executable, bundle
1322
+ identifier and bundle path before starting and while capturing. ScreenCaptureKit
1323
+ captures only that app's windows, never the desktop or another process. The
1324
+ helper follows the app's main standard window (else its focused, else its
1325
+ frontmost), with any attached sheet, by matching Accessibility windows to the
1326
+ process's on-screen windows; it moves the stream when the app opens, switches,
1327
+ closes or resizes windows. Without Device Control and Data Access permission it
1328
+ serves only an app whose one window contains the others. A subscription fails
1329
+ when the app has no open window at start; after the app closes its last window
1330
+ the subscription gets `frame-delayed` until another opens. When hello
1331
+ advertises `macos-windows`, the subscription also gets
1332
+ `{ event: "macos-windows", subscription, current, windows, pinned }` after it
1333
+ starts and whenever the windows or the pin change: `current` is the captured
1334
+ window or null, `pinned` says whether `input.window` holds the view on it, and
1335
+ `windows` lists the app's on-screen standard windows front to back, each
1336
+ `{ id, title, frame: { x, y, width, height } }` in points with a top-left global
1337
+ origin. A hosted app's relay forwards the event with the local subscription. It requires existing Screen & System Audio Recording permission (Screen Recording on macOS 14) and
1338
+ never requests or resets grants. The local health payload sets `nativeViewerOpened` after a verified native view attaches, allowing the Desktop host to present its first-use Screen & System Audio Recording and Device Control and Data Access (Accessibility on macOS 26 and earlier) setup. Only explicit buttons in that host invoke normal OS requests. Capture refusal names setup and System Settings guidance; status and logs remain available. Native windows need the
1339
+ helper, have no screenshot fallback, recording or replay, and consume
1340
+ only the paired device's `read` capability.
1341
+
1342
+ A server advertising `duo-frames` also accepts `duoFrame: true` for a live local
1343
+ owned iPhone Duo. The helper composes the installed DeviceKit V68 model with
1344
+ both display surfaces and a current `devicectl` hinge-angle reading. It sends
1345
+ JPEG `frame` events with `duo: { revision, screenID, angle, orientation }` bound
1346
+ to the image. This path does not offer H.264 or replay; other live subscriptions
1347
+ and raw recordings keep their existing capture. Missing model or angle data
1348
+ falls back to the raw screen. The hinge reader runs only while composition is
1349
+ requested and stops on helper stdin EOF or parent exit.
1350
+
1351
+ Clients acknowledge actual image display locally before accepting input and
1352
+ keep the image stable during a drag. `input.touch` takes the matching
1353
+ `duoRevision` instead of `display`; the helper raycasts against the original
1354
+ posed screen, refuses unknown revisions and ignores bezel or hinge touch-down.
1355
+ Move and up stay bound to that pose. Ending Control releases any held touch,
1356
+ even when read subscribers keep the helper alive.
1357
+
161
1358
  Frames come from the `stim-frames` helper. When it starts, the server
162
1359
  compiles it with `xcrun swiftc` from the Swift sources shipped in
163
1360
  `dist/stim-frames/` (its own `main.swift` and the frame and input code it
@@ -191,8 +1388,8 @@ Events are `{ "event", "subscription", ... }`.
191
1388
  the encoder, and the next access unit is a keyframe with the new SPS and
192
1389
  PPS. The helper encodes with VideoToolbox in real time: Main
193
1390
  profile, a keyframe at least every 2 seconds, straight from the
194
- simulator's IOSurface or from the emulator's RGBA frames, only when the
195
- screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
1391
+ simulator's IOSurface, from the emulator's RGBA frames, or from the page's
1392
+ screencast JPEGs, only when the screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
196
1393
  with its `subscription` asks for another one, such as after its decoder
197
1394
  lost state; a device sends at most one requested keyframe every 250 ms.
198
1395
  A subscriber whose socket holds more than 256 KB unsent drops frames
@@ -224,8 +1421,136 @@ Events are `{ "event", "subscription", ... }`.
224
1421
  second while it changes, backing off to one capture per second while it
225
1422
  does not, with capturing taking at most half of each device's time, and at
226
1423
  most two captures run at once. An emulator Stim booted before it passed
227
- `-grpc` has no endpoint on either path.
1424
+ `-grpc` has no endpoint on either path. A web page is captured with
1425
+ `Page.captureScreenshot`.
1426
+
1427
+ A web page's frames and input go through the owned Chrome's DevTools
1428
+ endpoint, `web.cdpEndpoint` in `stim status`. Both the helper and the
1429
+ screenshot path connect only when `SystemInfo.getProcessInfo` names the
1430
+ Chrome pid status reports, then attach to the page's `targetId`. The
1431
+ helper runs `Page.startScreencast` sized to `maxEdge` and acks every
1432
+ frame; JPEG subscribers get Chrome's JPEG as is, and video decodes it for
1433
+ the encoder. Chrome draws a frame only when the page changes, so a
1434
+ keyframe request re-encodes the last one.
1435
+
1436
+ With `physical: true` and `platform: "ios"`, frames come from the
1437
+ physical iPhone whose unexpired lease `stim status` lists under
1438
+ `deviceLeases` for the workspace and slot. `stim ios --device` holds that
1439
+ lease only while it runs, so `stim device lock ios <udid>` keeps the
1440
+ iPhone watchable between runs. A lease on the workspace's own simulator
1441
+ is skipped. The iPhone must be cabled over USB and trust the Mac; over
1442
+ Wi-Fi it has no screen to capture. The helper, run as
1443
+ `stim-frames iphone <udid> <name>` with the lease's device name, sets
1444
+ CoreMediaIO's `kCMIOHardwarePropertyAllowScreenCaptureDevices`, which
1445
+ makes macOS list cabled iPhones as capture devices, the ones QuickTime
1446
+ Player's New Movie Recording shows. Such a device's unique ID is a
1447
+ random UUID that names neither the UDID nor the USB device, so the
1448
+ helper first checks that an iOS device on USB has the UDID, without
1449
+ dashes, as its serial number. It then opens the iOS capture device
1450
+ named like the lease's device name, or the only one when the lease
1451
+ records no name and one iPhone is cabled. With several iPhones cabled,
1452
+ it waits until each shows a screen and refuses when none or several
1453
+ carry the name. It
1454
+ captures only while a subscriber asks for frames. macOS lets several
1455
+ processes capture the iPhone at once. Frames and video go through the
1456
+ same JPEG and H.264 paths as a simulator. A physical iPhone has no
1457
+ screenshot fallback: a subscription without the helper, or whose helper
1458
+ fails, ends with `frames-failed`, such as when the iPhone is not cabled
1459
+ or is unplugged. While macOS is asking for Camera access, or frames
1460
+ stop, such as while the iPhone is locked, the subscription gets
1461
+ `frame-delayed` with `delayed: true` and a `reason`, keeps its last
1462
+ frame, and gets `delayed: false` once frames can arrive again.
1463
+
1464
+ macOS treats the iPhone's screen as a camera and attributes the helper's
1465
+ request to the app that started stim-server. Stim asks for it with its
1466
+ own Camera usage description and entitlement, so the first capture
1467
+ shows the Camera prompt for Stim. An app without them, such as `node`
1468
+ started from a shell, is denied without a prompt, and the subscription
1469
+ ends with a `frames-failed` that names the Camera pane of System
1470
+ Settings. A physical iPhone is view only: `control.begin` with
1471
+ `physical: true` refuses with `action-failed`. It is never recorded, so
1472
+ `at` and `frames.seek` on it fail with `no-recording`, and it counts
1473
+ toward no idle check.
1474
+
1475
+ - **Replay.** `replay.range` takes exactly one of `workspace` or `archive`, plus `platform` and `slot`
1476
+ (`default` when absent), like `frames.subscribe`, and returns what can be
1477
+ replayed of that device slot's [recording](#recording):
1478
+ - `enabled`: the workspace's `recording.enabled`, as the last status showed
1479
+ it.
1480
+ - `recording`: true while the server records the device now.
1481
+ - `spans`: the recorded time ranges `{ start, end }`, in epoch milliseconds on
1482
+ the Mac's clock, oldest first. Segments less than 1.5 seconds apart form
1483
+ one span; the gaps between spans are time nothing was recorded, such as
1484
+ after `stim stop`.
1485
+ - `markers`: `{ at, kind, command?, label }` from the start of the first span
1486
+ on, oldest first: the newest 400 actions and 100 errors. They come from `stim logs --json` in the
1487
+ workspace. `action` markers are the agent's actions on that device, failed
1488
+ ones included, from agent-device's session log and the owned Chrome page's
1489
+ agent input, with their `command`, such as `press`, `fill`, `open` or
1490
+ `click`. An agent-device action is placed when the command started, since
1491
+ it logs it when it finished, after any `--settle` wait. `error` and
1492
+ `crash` markers are error and fatal records of the workspace. Metro,
1493
+ client and build errors name no device, so they appear on every device of
1494
+ the workspace. `label` is the record's first line.
228
1495
 
1496
+ It needs only `read`. A device with no recording gets empty `spans` and
1497
+ `markers`, and runs no `stim` command.
1498
+
1499
+ Archive replay reads only closed segments under the archive's recordings
1500
+ directory. Its `enabled` and `recording` are false; spans and markers come
1501
+ from those segments, archived logs and agent-device session actions. It never requires the former checkout
1502
+ or reads a live replacement at that path, and never uses a hosted relay.
1503
+ `frames.subscribe` with `archive` requires `at` and `video: ["h264"]`,
1504
+ rejects physical targets, and supports `frames.seek` on that subscription.
1505
+ `frames.live` refuses with `frames-failed`: an archive cannot go live.
1506
+ Empty archives return empty ranges and `no-recording` for keyframes or frames.
1507
+ These reads need the same `read` capability as workspace replay.
1508
+
1509
+ `replay.keyframe` takes the same `workspace` or `archive`, `platform` and `slot`, and
1510
+ `at` (epoch milliseconds), and returns one still frame for a scrubber
1511
+ preview without touching any subscription. The recording is stored in
1512
+ segments of about 5 seconds that each start at a keyframe; the server picks
1513
+ the segment `frames.seek` would show `at` from (the first one that ends at
1514
+ or after `at`, or the newest one) and reads only its first record. The
1515
+ result has the segment's `start` and `end`, the keyframe's capture time
1516
+ `at`, `width`, `height`, `posture` on an iPhone Duo, and `data`, the
1517
+ base64 Annex-B H.264 access unit with its SPS and PPS, so a client decodes
1518
+ it on its own; the server decodes nothing. The frame is the segment's first,
1519
+ so it can be up to about 5 seconds before `at`. It needs only `read`. It
1520
+ fails with `no-recording` when the device has no footage or that segment's
1521
+ first record is not a whole keyframe yet, as while it is being written. A
1522
+ connection can have 8 of these reads in flight; more fail with
1523
+ `limit-exceeded`.
1524
+
1525
+ A video subscription can replay the recording instead of the live screen.
1526
+ `frames.seek` takes the `subscription`, `at` (epoch milliseconds) and `rate`
1527
+ (0, 1 or 2). The server sends the access units from the keyframe at or
1528
+ before `at` through the frame at `at`, or the newest frame when `at` is past
1529
+ it, as binary video messages right away, then plays on at `rate` times real
1530
+ time; 0 stays paused. The result's `at`
1531
+ is the capture time of the frame shown. Playback skips time nothing was
1532
+ recorded, and at the newest recorded frame it sends a `replay-ended` event
1533
+ with `subscription` and `at` and stays paused there. `frames.live` returns
1534
+ the subscription to the live screen, starting at a keyframe, or fails with
1535
+ `frames-failed` when the device is not running. While a subscription
1536
+ replays, the server sends no live frames on it, and `frames.keyframe`
1537
+ resends the frame shown from its keyframe. A seek on a device with no
1538
+ recording fails with `no-recording` and leaves the subscription live, and a
1539
+ JPEG subscription cannot seek.
1540
+
1541
+ `frames.subscribe` with `at`, and optionally `rate`, starts the subscription
1542
+ replaying, and needs `video: ["h264"]`. It needs no running device, so the
1543
+ footage of a stopped workspace can be replayed; it fails with
1544
+ `no-recording` when nothing was recorded, and sends `replay-ended` at once
1545
+ when that footage holds no frame to show. Clients that never send these
1546
+ messages see no change.
1547
+
1548
+ - `recording.set` takes `enabled` and runs
1549
+ `stim settings set recording.enabled <enabled> --scope machine --json` in
1550
+ the home directory. It needs `control`, is logged like an
1551
+ [action](#actions) with `action` `recording.set`, and returns `enabled` and
1552
+ `recordingsDeleted`, the workspaces whose recordings turning recording off
1553
+ deleted.
229
1554
  - `build.plan` takes `workspace`, `platform` (`ios` or `android`) and `slot`
230
1555
  (`default` when absent), and returns the payload of
231
1556
  `stim <platform> --plan --json` run in the workspace: the fingerprint, the
@@ -239,6 +1564,33 @@ Events are `{ "event", "subscription", ... }`.
239
1564
  computed is a `stim-failed` error. One plan runs per workspace at a time,
240
1565
  across all connections; later requests wait their turn, and the 150
241
1566
  seconds start when the plan starts.
1567
+ - `workspace.files` takes `{ "workspace", "group": "changed" | "untracked" }`
1568
+ and returns `{ "files", "truncated" }`; each file has a repo-relative `path`,
1569
+ `status`, and `staged`, `unstaged`, `untracked` flags. It needs `read` and the
1570
+ `workspace-diff` feature. The registered app project resolves to its canonical
1571
+ Git worktree, including changes elsewhere in that worktree. At most 200 files
1572
+ and 256 KiB are returned; raw Git status exceeding 1 MiB refuses.
1573
+ Changed lists include submodules only when their recorded commit differs;
1574
+ uncommitted edits inside submodules are not listed. Each Git call has a
1575
+ 5-second deadline; a request runs several calls in sequence, with no overall
1576
+ deadline. Closing the connection stops a running request.
1577
+ - `workspace.diff` takes `{ "workspace", "path" }` for a current changed file
1578
+ and returns `{ "path", "patches" }`. Each patch has a `section` (`staged`,
1579
+ `unstaged`, `untracked`), `kind` (`text`, `binary`, `too-large`, `unavailable`)
1580
+ and `text`. Text previews total at most 256 KiB. New files must be regular
1581
+ UTF-8 files within the worktree; symlinks, submodules and conflicts report
1582
+ unavailable. Each Git call has a 5-second deadline; a request runs several
1583
+ calls in sequence, with no overall deadline. Raw status output is capped
1584
+ at 1 MiB and patches at 256 KiB. Closing the connection stops a running request.
1585
+ Git reads disable color, external diff, text conversion, fsmonitor
1586
+ commands and lazy fetches, use literal paths and never edit or stage files.
1587
+ Partial clones require Git 2.45 or later so lazy-fetch protection applies
1588
+ across subprocesses; an older or unparseable Git version refuses these reads.
1589
+ Tracked files attributed to configured clean or process filters refuse before
1590
+ status or diff. Unused filters are allowed within the preflight limits: each
1591
+ request checks filter attributes for all tracked paths. The internal path
1592
+ list has a 64 MiB cap; each Git call keeps its 5-second deadline.
1593
+ Git LFS previews are unavailable in this version.
242
1594
  - `machine.get` returns cheap machine usage, read in the server process
243
1595
  without running `stim`: `volumes`, one per volume that holds a Stim
244
1596
  workspace, Stim home, or the simulators, with `mount`, `holds`, `freeBytes`
@@ -255,13 +1607,55 @@ Events are `{ "event", "subscription", ... }`.
255
1607
  sample), `memoryUsedBytes`, `memoryPressure` (0 normal, 1 warning, 2
256
1608
  critical) and `diskFreeBytes` of the startup volume; a field is null when it
257
1609
  cannot be read. `sinceMs` returns only the samples taken after it.
1610
+ - `machine.details` returns `{ "gc", "stats", "measuredAt" }`: the payloads of
1611
+ `stim gc --json` and `stim stats --json`, both run in the home directory, so
1612
+ a phone can show what Stim Desktop's Machine page shows about disk: every
1613
+ simulator, AVD, iOS runtime and system image, shared caches, recordings,
1614
+ workspace build outputs and logs, and what `stim gc --delete` would free. The
1615
+ `gc` run is always the dry run with no other flag; the server never deletes
1616
+ anything for this request. A part is null when its command failed, and
1617
+ `gcError` or `statsError` says why. It needs only `read`. The server keeps
1618
+ one result for 60 seconds, shared by every connection: a request while the
1619
+ commands run waits for them, and `measuredAt` says when they started. The
1620
+ commands fail after 150 seconds. Servers that predate it answer
1621
+ `unknown-method`. It also carries `buildMachines`, the `buildMachines` list
1622
+ of `stim doctor --json --platform ios` (never with `--fix`), so a phone can
1623
+ show whether each build machine takes builds. The server runs doctor only
1624
+ when `offload.machines` names a machine, else the list is empty; it runs it
1625
+ in the registered workspace that ran doctor for iOS most recently, because
1626
+ doctor judges a machine against an app. Like a doctor run from a terminal,
1627
+ that records the run for the workspace. `buildMachines` is null with
1628
+ `buildMachinesError` when doctor failed, the config could not be read, or no
1629
+ registered workspace exists. The reply never waits for doctor, whose offer
1630
+ to an unreachable machine can take about 13 seconds: it carries the last
1631
+ settled `buildMachines`/`buildMachinesError` (with `buildMachinesAt`, when
1632
+ they settled) while doctor refreshes them in the background, at most one run
1633
+ at a time, and sets `buildMachinesPending` while that background run has not
1634
+ settled a result yet, or the settled one is 60 seconds old or older. A client
1635
+ that wants the refreshed result asks `machine.details` again. It also
1636
+ carries `buildClients`, the builds this Mac ran as a build machine, one entry
1637
+ per client Mac read from the `build` records of the audit log, with `id`,
1638
+ `name`, `builds`, `failed`, `buildMs`, `lastAt` and the same three counts
1639
+ for `today`, most recent client first, with `today` on this Mac's local
1640
+ calendar day. Servers that predate it leave it out.
258
1641
  - `unsubscribe` ends a subscription.
259
1642
  - `push.register` takes `token`, an Expo push token, `events`, one or more
260
- [push notifications](#push-notifications) the phone wants (`build-failed`,
261
- `log-errors`, `disk`, `app-stopped`, `slow-build`), an optional
262
- `agentOnly`, and `ref`, an opaque string of up to 128 characters that every
263
- push carries back as `data.ref`. It needs only `read`. Registering again
264
- replaces the device's registration; `push.unregister` removes it.
1643
+ [push notifications](#push-notifications) the phone wants (`started`,
1644
+ `stuck`, `looping`, `finished`, `machine`, `control`, `attention`), `ref`, an opaque
1645
+ string of up to 128 characters that every push carries back as `data.ref`,
1646
+ and optionally `stuckMinutes` (1 to 240, default 15), `quietHours`
1647
+ (`{ "start", "end", "timeZone" }`, minutes after midnight in an IANA time
1648
+ zone) and `levels`, each event's [delivery level](#delivery-levels) (for
1649
+ example `{ "stuck": "silent", "machine": "alert" }`). It needs only `read`. Registering again replaces the device's
1650
+ registration; `push.unregister` removes it. Phones from before these events
1651
+ may still send `build-failed`, `log-errors`, `disk`, `app-stopped`,
1652
+ `slow-build` and `agentOnly`: `disk` counts as `machine`, and the rest are
1653
+ accepted and ignored.
1654
+ - `notifications.list` returns the [notification history](#notification-history)
1655
+ as `{ "log", "cursor", "notifications" }`, newest first. `since` (a `cursor`
1656
+ from an earlier list) returns only the entries after it. From then on, the
1657
+ connection gets a `notification` event, `{ "event": "notification", "log",
1658
+ "notification" }`, for each entry the server logs. It needs only `read`.
265
1659
  - `action` runs an [action](#actions) and returns
266
1660
  `{ "action", "workspace", "output" }`.
267
1661
  - An `error` event ends a subscription whose source failed, or whose client
@@ -270,61 +1664,211 @@ Events are `{ "event", "subscription", ... }`.
270
1664
  `workspace` is an environment `path` from a status payload. Any other path is
271
1665
  refused with `unknown-workspace` and runs nothing. A connection holds at most
272
1666
  32 subscriptions and runs at most 4 `logs.query`, `stats.get`,
273
- `settings.get` and `build.plan` requests at a time. Those requests fail after
274
- 60 seconds, `build.plan` after 150, or at 32 MiB of output, and closing the
275
- connection stops them. When the command refuses with Stim's error contract on
276
- stdout, the `stim-failed` message is its code, message and remedy; otherwise
277
- it is the exit status and the end of stderr. A `stim` child that
1667
+ `settings.get`, `build.plan`, `workspace.files` and `workspace.diff` requests at
1668
+ a time. `logs.query`, `stats.get` and `settings.get` fail after 60 seconds,
1669
+ `build.plan` after 150; these four fail at 32 MiB of output. Closing the
1670
+ connection stops running requests. When the command refuses with Stim's error
1671
+ contract on stdout, the `stim-failed` message is its code, message and remedy;
1672
+ otherwise it is the exit status and the end of stderr. A `stim` child that
278
1673
  ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
279
1674
  4 MiB unsent gets no more batches until it catches up; past 20,000
280
1675
  waiting records the server ends that subscription with `slow-client`.
281
1676
 
1677
+ ## Recording
1678
+
1679
+ The server records owned simulators, emulators and the Stim-owned Chrome page
1680
+ so clients can replay what happened while nobody watched. It records a device
1681
+ while `stim status` shows an automation tool driving it (`activity.state` is
1682
+ `driven`), or while a client has a `frames.subscribe` subscription to it. It
1683
+ records nothing else, holds no device awake and changes no device setting. To
1684
+ see drivers, the server keeps one `stim status --watch --json` child running
1685
+ for as long as it runs, shared with status subscribers.
1686
+
1687
+ Recording goes through the device's `stim-frames` helper, shared with live
1688
+ subscribers. The helper runs a second H.264 encoder for it, at 720 pixels on
1689
+ the long edge, 1 Mbps and at most 10 frames a second, so live video keeps its
1690
+ own bitrate. Live frames are captured at no less than 720 pixels and 10 frames
1691
+ a second while the device is recorded. A device on screenshots, without the
1692
+ helper, is not recorded, and a helper that fails is started again after 5
1693
+ seconds, doubling up to 5 minutes. One stim-server records and prunes a Stim
1694
+ home at a time, under an exclusive ownership claim at
1695
+ `$STIM_HOME/server/recorder`; a second one serves replays and records once the
1696
+ claim frees.
1697
+
1698
+ Footage is stored under `$STIM_HOME/workspaces/<id>/recordings/<platform>-<slot>/`
1699
+ as segments of about 5 seconds, each starting at a keyframe; the server asks
1700
+ the helper for a keyframe once a segment is 5 seconds old, so a screen that
1701
+ does not change still gets new segments, and a keyframe request also restarts
1702
+ the recording stream at a keyframe. The segment being
1703
+ written is `<start>.part`, and a closed segment is `<start>-<end>.seg`, in epoch
1704
+ milliseconds. A segment is a sequence of records: a u32 big-endian length of
1705
+ the rest, u8 flags (bit 0 keyframe, bit 1 folded, bit 2 unfolded, as in a video
1706
+ packet), f64 capture time in milliseconds since the epoch, u16 width, u16
1707
+ height, then one Annex-B access unit. The server creates a segment only while
1708
+ the workspace directory has its `workspace.json`, so it never fills a directory
1709
+ that `stim worktree remove` or `stim gc` emptied.
1710
+
1711
+ Every 30 seconds it keeps the last 15 minutes of footage of each device,
1712
+ counting only recorded time, and at most 1 GiB of footage across every
1713
+ workspace, deleting the oldest segments first. It closes, at their last write,
1714
+ the `.part` segments of a server that stopped. When a status payload shows
1715
+ `recording.enabled` false for a workspace (the `recording.enabled` setting, or
1716
+ `STIM_RECORDING` in the server's environment), it stops recording that
1717
+ workspace within seconds and deletes its recordings. `stim stop` ends recording
1718
+ and keeps the footage; `stim worktree remove` and `stim gc` delete it. A gc that
1719
+ deletes the segment being written loses the rest of that segment; the next one
1720
+ is written as usual. Recordings stay on the Mac and are served only to paired
1721
+ clients.
1722
+
282
1723
  ## Push notifications
283
1724
 
284
- A paired phone that sends `push.register` gets attention notifications while
285
- its app is in the background or closed. The server keeps the registration
286
- with the pairing in `devices.json`, so revoking the device drops it. A token
287
- belongs to one pairing: registering it from a new pairing of the same phone
288
- removes it from the old one.
289
-
290
- While at least one device is registered, the server keeps its own
291
- `stim status --watch --json` child running, even with no client connected,
292
- and reads the free space of Stim's volumes every minute. It pushes, per
293
- device and only for the events the device chose:
294
-
295
- - `build-failed`: a workspace's last iOS or Android build failed, in an
296
- active workspace or within the last day;
297
- - `log-errors`: new errors in an active workspace's logs, once the count has
298
- held for 10 seconds (at most a minute after the first), then at most every
299
- 5 minutes;
300
- - `disk`: a volume holding Stim state has less than 5 GB free;
301
- - `app-stopped`: the app is not running on a booted device of a live
302
- workspace;
303
- - `slow-build`: a build has run more than twice its usual duration.
304
-
305
- These are the rules of the phone app's home attention strip
306
- (`apps/mobile/src/lib/attention.ts`), copied into `src/attention.ts`. Each problem pushes once; a later failed build or a
307
- problem that clears for two minutes and returns pushes again. What is already
308
- wrong when the server starts or a device registers does not push. With
309
- `agentOnly`, workspace events push only while a device of the workspace is
310
- driven by an agent, so a workspace whose devices are gone, such as after a
311
- failed first build, does not push.
312
-
313
- A device gets at most 20 pushes an hour. More than three at once become one
314
- summary push that opens the phone's home screen.
1725
+ A paired phone that sends `push.register` gets notifications while its app is
1726
+ in the background or closed. The server keeps the registration with the
1727
+ pairing in `devices.json`, so revoking the device drops it. A token belongs to
1728
+ one pairing: registering it from a new pairing of the same phone removes it
1729
+ from the old one.
1730
+
1731
+ A notification means that your attention changes the outcome, or that work you
1732
+ wait on started or finished. A failed build, new log errors, a stopped app or a
1733
+ slow build on their own are normal agent iteration and do not push; the phone
1734
+ shows them in its attention strip. The server pushes, per device and only for
1735
+ the events the device chose:
1736
+
1737
+ - `started`: a workspace began warming (`phase` `warming`), or an agent first
1738
+ drove one of its devices. It is grouped per Mac, and opens the workspace, or the device viewer
1739
+ once an agent drives it. An agent driving the workspace updates the warming
1740
+ notification in place.
1741
+ - `stuck`: an agent drove the workspace, its devices are still up, and nothing
1742
+ happened for `stuckMinutes`: no agent action, build, Stim run, Metro bundle
1743
+ request or new log error. App log records do not count, because an idle app keeps
1744
+ logging: an idle Stim app writes about 200 UIKit info records a minute. The
1745
+ owned Chrome page counts as a device: an attached tool's input there is an
1746
+ agent action, and while an agent drives it its page log also counts, since an
1747
+ agent's navigations and scripts are not agent actions. It opens the device viewer.
1748
+ - `looping`: the newest three or more iOS or Android builds failed the same
1749
+ way, at the same first compiler diagnostic `file:line`, or with the same
1750
+ error code when there is none, such as three failed launches
1751
+ (`STIM_LAUNCH_FAILED`, which includes an app that exits at launch). It says,
1752
+ for example, `Same Swift error 3x at AppDelegate.swift:71` and opens the
1753
+ build details.
1754
+ - `finished`: the agent stopped driving after a green build and nothing
1755
+ happened for 5 minutes, or stopped the workspace after a green build; the
1756
+ workspace's pull
1757
+ request became ready for review, or merged, which opens the pull request; or,
1758
+ when GitHub cannot be asked, git finds the branch merged into the default
1759
+ branch.
1760
+ - `machine`: a volume holding Stim state has less than 5 GB free (the episode
1761
+ ends once 6 GB are free again), or memory pressure stayed critical for a
1762
+ minute. It opens the machine sheet.
1763
+ - `control`: another client took over a device this phone controls, or an
1764
+ agent started driving it. It opens the device viewer.
1765
+ - `attention`: something only a person can fix, from the needs-attention rule
1766
+ (`needsAttention` in `@stim-cli/core/oversight`): a port held by another app, a
1767
+ supervisor or owned Chrome Stim cannot verify, an owned AVD it could not
1768
+ check, a failed run with a signing or provisioning error code, a physical
1769
+ device lease that expired, or an EAS session running for 30 minutes with no
1770
+ agent driving the workspace. It opens the workspace, or the build for a
1771
+ signing failure. A stuck agent, a loop and a full disk notify through their
1772
+ own categories.
1773
+
1774
+ Stuck and finished are read from device activity, so two cases blur them. An
1775
+ agent that finishes without closing its agent-device session still holds the
1776
+ device, so it gets `stuck`, whose text then names the green build it stopped
1777
+ after. With a `stim` whose status has no `activity.recent`, every app log
1778
+ record counts as activity, so an app that keeps logging never looks stuck or
1779
+ finished.
1780
+
1781
+ Each workspace notifies once per episode: a stuck agent notifies again only
1782
+ after new activity and a new quiet stretch, a loop only after a success or a
1783
+ different failure. A push carries a collapse id for its workspace and
1784
+ category, so a later one replaces the earlier notification on the phone
1785
+ instead of stacking. What is already true when the server starts or a device
1786
+ registers does not push. During the phone's quiet hours nothing pushes: a
1787
+ problem that still holds when they end pushes then, and what started or
1788
+ finished during them does not.
1789
+
1790
+ While at least one device is paired, the server keeps its own
1791
+ `stim status --watch --json` child running, even with no client connected, and
1792
+ reads the free space of Stim's volumes and the memory pressure every minute.
1793
+ While a device wants `finished`, it looks up the pull requests of worktree
1794
+ branches that have an upstream every 5 minutes, with one `gh api graphql` call
1795
+ per repository, the lookup `stim gc` uses. Without `gh`, or when it is signed
1796
+ out or does not answer, that round relies on git, and the next round asks
1797
+ again.
1798
+
1799
+ A device gets at most 20 alerting pushes an hour; silent pushes do not
1800
+ count. More than three at once become one summary push that opens the
1801
+ phone's home screen.
315
1802
 
316
1803
  Pushes go to the Expo push service, `https://exp.host/--/api/v2/push/send`,
317
- which forwards them to Apple. No APNs key or other secret lives on the Mac.
318
- A workspace push carries the workspace title, a short reason such as `iOS
319
- build failed (STIM_BUILD_FAILED)` and the Mac's name as the subtitle; a disk
320
- or summary push has the Mac's name as its title. In `data` it carries the
321
- `ref`, the screen to open (`home`, `machine`, `workspace` or `logs`) and, for
322
- a workspace, its absolute path, which the phone needs to open that workspace
323
- before it has reconnected. It carries no logs and no other paths. The
324
- server checks the push receipts 15 minutes later and drops a token that Expo
325
- reports as `DeviceNotRegistered`, and prints any other refusal, such as
326
- missing APNs credentials, on stderr. Pushes are not retried, and nothing is
327
- pushed while the server is not running.
1804
+ which forwards them to Apple. No APNs key or other secret lives on the Mac. A
1805
+ workspace push carries the workspace title, a one-line cause and the Mac's name
1806
+ as the subtitle; a machine or summary push has the Mac's name as its title. In
1807
+ `data` it carries the `ref`, the screen to open (`home`, `machine`,
1808
+ `workspace`, `device`, `build` or `url`), and the workspace's absolute path,
1809
+ with the platform and slot for a device, the platform for build details and
1810
+ the pull request's URL for `url`. The phone needs the path to open a workspace
1811
+ before it has reconnected. It carries no logs. The server checks the push
1812
+ receipts 15 minutes later and drops a token that Expo reports as
1813
+ `DeviceNotRegistered`, and prints any other refusal, such as missing APNs
1814
+ credentials, on stderr. Pushes are not retried, and nothing is pushed while
1815
+ the server is not running.
1816
+
1817
+ ### Delivery levels
1818
+
1819
+ Each event the device registers is delivered at one of two levels; an event it
1820
+ leaves out of `events` is off and never pushes.
1821
+
1822
+ - `alert`: a banner and sound. The push has `sound: "default"`,
1823
+ `interruptionLevel: "active"` and `channelId: "attention"`, the phone's
1824
+ high-importance Android channel.
1825
+ - `silent`: no banner or sound, only the notification list.
1826
+ The push has no sound, `interruptionLevel: "passive"` and
1827
+ `channelId: "updates"`, the phone's low-importance Android channel.
1828
+
1829
+ `levels` in `push.register` sets each event's level. An event it leaves out,
1830
+ and every event of a phone that sends no `levels`, is silent. A summary push is
1831
+ silent only when every notification it sums up is. An older server ignores
1832
+ `levels` and alerts for every event but `started`.
1833
+
1834
+ The server and phone import the same pure rules from `@stim-cli/core/oversight`.
1835
+ The phone formats home attention messages locally from the facts the shared rule selects.
1836
+ `__tests__/oversight-agreement.test.ts` checks their categories, workspace names
1837
+ and needs-attention vectors. Notification delivery remains local to each.
1838
+
1839
+ ## Notification history
1840
+
1841
+ The server logs the notifications the rules produce for the Mac, whether or not
1842
+ a phone was pushed, so a phone can list what it missed. While any device is
1843
+ paired, it runs the rules once more for the Mac itself, with every category, no
1844
+ quiet hours and the lowest `stuckMinutes` a registered device asked for (15
1845
+ without one). Each notification it produces is an entry; a `control` conflict is
1846
+ an entry too, listed only to the phone that lost the device. Pull request
1847
+ entries need the GitHub lookup, which runs only while a device wants
1848
+ `finished`.
1849
+
1850
+ An entry is `{ "seq", "at", "id", "category", "title", "body", "quiet",
1851
+ "target", "suppressed"? }`. `seq` grows by one per entry. `id` names the
1852
+ workspace or machine and category, the key a push's collapse id is made from,
1853
+ so a later episode of the same problem shares it. `target` is the screen the
1854
+ push opens, as `{ "kind", ... }` with the fields of the push's `data`.
1855
+ `quiet` is the event's default delivery, always silent; a device's
1856
+ [levels](#delivery-levels) decide how its push was actually delivered.
1857
+ `suppressed` says why no registered device got the notification when it was
1858
+ logged: `muted` when none wants its category, `quiet-hours` when those that do
1859
+ were in quiet hours. A problem that still holds when quiet hours end is pushed
1860
+ then, so a `quiet-hours` entry may still have reached the phone. Without
1861
+ `suppressed`, a registered device was due to get it, or none is registered; a
1862
+ device's hourly budget or a summary push can still stand in for the push
1863
+ itself. A push for a logged notification carries the entry's `seq` as `data.notification`, so the
1864
+ phone can mark it read.
1865
+
1866
+ The log keeps the last 200 entries, none older than 7 days, in
1867
+ `$STIM_HOME/server/notifications.json`, which only the server writes, under
1868
+ `notifications.json.lock`. `log` in `notifications.list` is a random id made
1869
+ with the file: a new one means the history started over, so a cursor or read
1870
+ state kept for the old one no longer applies. What is already true when the
1871
+ server starts is not logged, as it is not pushed.
328
1872
 
329
1873
  ## Actions
330
1874
 
@@ -365,10 +1909,12 @@ replaced by `?`.
365
1909
  ## Control
366
1910
 
367
1911
  A device with `control` can drive a simulator or emulator that `stim status`
368
- lists as owned by a workspace. Nothing it sends reaches any other device.
1912
+ lists as owned by a workspace, or a physical Android phone the workspace
1913
+ leases (see [Physical Android devices](#physical-android-devices)). Nothing it
1914
+ sends reaches any other device.
369
1915
 
370
1916
  - `control.begin` takes `workspace`, `platform`, `slot` (`default` when
371
- absent) and `takeOver`, and returns `{ "session", "platform", "lease",
1917
+ absent), `physical` and `takeOver`, and returns `{ "session", "platform", "lease",
372
1918
  "postures" }`. `postures` lists what `input.posture` takes for the device:
373
1919
  `folded` and `unfolded` for an iPhone Duo, `folded`, `half-open` and
374
1920
  `unfolded` for an emulator with a hinge, such as a `pixel_fold` AVD, and
@@ -390,15 +1936,88 @@ lists as owned by a workspace. Nothing it sends reaches any other device.
390
1936
  it.
391
1937
  - `input.touch` takes `session`, `phase` (`down`, `move`, `up`), `x` and `y`
392
1938
  as fractions of the upright screen, and `display` (0, the main display).
1939
+ Without `display`, a touch on an iPhone Duo goes to the panel its latest
1940
+ frame showed, and before any frame to the cover.
393
1941
  `input.text` takes up to 256 printable ASCII characters, where `\n` presses
394
1942
  Return, `\t` Tab and `\b` Delete. `input.button` takes `home` or `lock`,
395
1943
  and on Android also `back` or `app-switch`. `input.rotate` takes
396
- `direction` (`left` or `right`) and turns the device a quarter turn.
397
- `input.posture` takes one of the session's `postures`. Each answers `{}` once the input
1944
+ `direction` (`left` or `right`) and turns the device a quarter turn. An
1945
+ iPhone Duo rotates through its Virtualization provider in folded, half-open
1946
+ and unfolded postures. Apps keep their supported orientations, and its home
1947
+ screen stays portrait, as on other iPhones.
1948
+ `input.posture` takes one of the session's `postures`. A web page takes
1949
+ only `back`, its history back, and refuses rotation and posture. Each answers `{}` once the input
398
1950
  is handed to the device: when it goes through the helper, that is when the
399
- helper receives it, so a failure there shows only in the server's log. A connection may send 120 inputs a second and type 40
1951
+ helper receives it, so native macOS input failures end Control with the reason while its view stays available; other helper failures are logged. A connection may send 120 inputs a second and type 40
400
1952
  characters a second, with a burst of 256, and rotate or change posture twice a
401
1953
  second; more fail with `limit-exceeded`.
1954
+ - When hello advertises `macos-window-control`, `control.begin` accepts
1955
+ `platform: "macos"` for the verified native app in the default slot. It needs
1956
+ `control` and takes one exclusive server session per app, with the same
1957
+ takeover, disconnect, revocation and five-minute idle rules; `lease` is null
1958
+ because CLI device locks do not cover macOS apps. Existing Device Control and
1959
+ Data Access permission (Accessibility on macOS 26 and earlier) is required, without permission requests or resets. Before each
1960
+ action the helper verifies PID/start time/executable/bundle and that the
1961
+ captured window ID and size are still the app's front standard window, or the
1962
+ window `input.window` pinned. The app's
1963
+ other windows are allowed. A modal dialog window and changed ownership refuse
1964
+ input. Input that arrives while capture moves to another window is dropped with a
1965
+ logged reason and Control continues. A sheet attached to the captured window
1966
+ takes focus and pointer input. A sheet larger than the captured window is not
1967
+ supported. Events are
1968
+ posted only to that PID; the desktop and other apps receive no input.
1969
+ `input.touch` maps normalized captured-window coordinates to mouse events.
1970
+ `input.scroll` takes normalized `x`, `y` and `deltaX`, `deltaY` in pixels,
1971
+ each from -1000 to 1000. When hello advertises `macos-window-select`,
1972
+ `input.window` takes `window`, an id from the `macos-windows` event, to pin the
1973
+ view and input to that window of the app: the helper verifies it is one of the
1974
+ app's on-screen standard windows, makes it the app's main window and raises it
1975
+ among the app's windows without activating the app, and capture stays on it
1976
+ while another window comes to the front.
1977
+ `window: null` follows the front window again. A pin also ends when its window
1978
+ closes or leaves the screen, or when the Control session that set it ends,
1979
+ including takeover, disconnect and the idle timeout. A window that is gone by
1980
+ then, or a modal dialog in the app, drops the input with a logged reason
1981
+ without ending Control. The hosted relay forwards it as `device-host.input.window`.
1982
+ `input.key` accepts Escape, Tab, Return, Backspace,
1983
+ arrows or `a-z` and `0-9`, with unique optional `command/shift/option/control`
1984
+ modifiers. Hello advertises `macos-keyboard-extended` for the expanded keys;
1985
+ older servers accept only `a/c/v/x/z/s/f` plus navigation. Shortcuts for `a-z` and `0-9`
1986
+ use the key that types the character in the Mac's current keyboard layout,
1987
+ using the Command layer when Command is held. Dvorak and Dvorak-QWERTY Command
1988
+ are supported. On Russian and similar layouts (Cyrillic, Greek, Hebrew, Arabic),
1989
+ Latin-letter shortcuts work with Command; Control-only or Option-only letter
1990
+ shortcuts are refused. With Control, the layout's Control table must also yield
1991
+ the requested character or, for letters, its C0 control character; otherwise
1992
+ the shortcut is refused. The Mac's selected input source is read on each key,
1993
+ so switching layouts takes effect on the next key.
1994
+ Letters and digits available only with Shift or Option, or through a dead key
1995
+ (for example digits on AZERTY), are refused with a reason naming the layout,
1996
+ and Control ends. Stim posts the requested modifiers unchanged; it does not
1997
+ add modifiers to reach those characters. Ordinary typing and navigation do
1998
+ not depend on the layout. Symbols such as comma remain unsupported key names.
1999
+ The helper posts input to the owned process without activating it or raising its window,
2000
+ except that `input.window` raises the window it pins among the app's windows.
2001
+ Only when the captured window is not the app's key window does it activate the app (waiting up to one
2002
+ second for focus) and send a `controlActivated` notice, which stim-server logs. Clicks on views that
2003
+ reject the first mouse, such as custom views and SwiftUI `onTapGesture` regions, do not land while the app
2004
+ is in the background. `input.text` retains the printable ASCII contract,
2005
+ and navigation keys do not depend on that host layout. Native windows
2006
+ reject simulator buttons, rotation and posture. The helper dynamically resolves
2007
+ private CoreGraphics `CGEventSetWindowLocation` to annotate PID-targeted pointer
2008
+ events. This is a macOS compatibility limit outside the phone and Mac App Store
2009
+ binaries: a missing symbol refuses Control while read-only capture remains
2010
+ available. An input refusal sends
2011
+ `control-ended` with its reason without ending read-only capture.
2012
+ - On an owned iOS simulator, `control.begin` also reports optional `simulator`
2013
+ capabilities: `canShake` and `slowAnimations` (a boolean, or `null` when
2014
+ unavailable). `input.simulator` takes the session and `action: "shake"`,
2015
+ `action: "read"`, or `action: "slow-animations"` with an explicit boolean
2016
+ `enabled`. It returns the confirmed capabilities and state. Unavailable
2017
+ controls are refused. The native operation times out after 8 seconds and
2018
+ stops when its control session ends; only one option changes per simulator
2019
+ at a time. These controls use the same CoreSimulator guest notifications as
2020
+ Stim Desktop, without opening Device Hub.
402
2021
  - `control.end` ends a session. The server also ends it with a
403
2022
  `control-ended` event `{ "session", "reason", "message" }` after 5 minutes
404
2023
  without input (`idle`), when another client takes the device over
@@ -417,6 +2036,13 @@ streams its frames:
417
2036
  Hub does, and from then until the simulator reboots it ignores tools that
418
2037
  still use SimulatorKit's legacy HID client. Text is typed key by key on a US
419
2038
  layout. `lock` is the side button.
2039
+ - A web page takes DevTools input on the page's `targetId`: touches as
2040
+ `Input.dispatchTouchEvent` (a drag scrolls, on a desktop page too), text as
2041
+ key events, `\n`, `\t` and `\b` as Enter, Tab and Backspace. A web
2042
+ session holds no lease, since `stim device lock` covers devices; a
2043
+ DevTools client other than Stim's attached to the browser (`web.activity`
2044
+ in status, such as Playwright MCP) makes `control.begin` answer
2045
+ `device-busy` unless it takes over.
420
2046
  - Emulators take touches through the emulator's gRPC `sendTouch`. Text and
421
2047
  buttons go through gRPC `sendKey` when the emulator reports a hardware
422
2048
  keyboard, which AVDs Stim creates have. An emulator without one drops key
@@ -437,7 +2063,62 @@ appends a line to the action log, with `action` set to `control.begin`,
437
2063
  ended or whom it took the device from. Inputs are not logged.
438
2064
 
439
2065
  The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
440
- refuse the client until it pairs again or updates; clients retry the others.
2066
+ refuse the client until it pairs again or updates; `approval-pending` refuses
2067
+ a build client until the Mac approves it; clients retry the others.
441
2068
 
442
2069
  The package exports the message types, and the build writes their JSON Schema
443
2070
  to `dist/protocol.schema.json`, exported as `@stim-cli/server/protocol.schema.json`.
2071
+ The wire types are shared through `@stim-cli/core/protocol`. The phone derives
2072
+ its receiving types from that contract and checks JSON results against the
2073
+ pending method, and known events before dispatch. Its validators are compiled
2074
+ during development, not on app startup. Compatible additional fields and
2075
+ unknown future events remain supported; malformed known data closes the socket
2076
+ and enters the normal reconnect flow without logging the payload.
2077
+
2078
+ ## Physical Android devices
2079
+
2080
+ A phone reached with `stim android --device <serial>` (or held with
2081
+ `stim device lock android <serial>`) is used, not owned. With `physical: true`,
2082
+ `frames.subscribe` and `control.begin` pick the phone the workspace holds an
2083
+ unexpired lease on in `slot`, as `deviceLeases` in `stim status` reports it,
2084
+ instead of the Stim-owned emulator. A workspace without that lease gets
2085
+ `frames-failed` or `action-failed`, and so does a slot whose leased device is
2086
+ an emulator. Watching needs `read`, as for an emulator. Control needs
2087
+ `control` and that lease: the session never takes, renews or releases a
2088
+ phone's lease, and `takeOver` cannot move one between workspaces. When the
2089
+ lease is released or expires, the control session ends with `device-gone` and
2090
+ frame subscriptions end with `frames-failed`. Both follow `stim status
2091
+ --watch`: a release reaches the server when status next reports it, usually
2092
+ within seconds and at most about 30 seconds plus one refresh. Input after an
2093
+ expiry is refused at once. A physical Android device is not recorded, so `at`
2094
+ and `frames.seek` answer `no-recording`, and a watched phone is not listed as
2095
+ viewed for Stim's idle checks.
2096
+
2097
+ The `stim-frames` helper reaches the phone over adb only, with the scrcpy
2098
+ server 4.1 (Apache-2.0), shipped in `dist/scrcpy/` with its `LICENSE` and a
2099
+ `NOTICE`. Before every push, the helper checks the jar's sha256 against the one
2100
+ pinned in its source. It then pushes the jar to
2101
+ `/data/local/tmp/stim-scrcpy-<id>.jar`, starts it with `app_process` as the
2102
+ shell user, and connects through an `adb forward` port. The server's cleanup
2103
+ process deletes the jar as soon as it runs; when the helper stops, it removes
2104
+ the forward and deletes the jar again. It installs nothing, and it asks
2105
+ scrcpy for no settings change: no `show_touches`, no `stay_awake` and no
2106
+ screen power change (`power_on=false`), with clipboard sync off. The phone
2107
+ encodes H.264 of its screen as it is oriented, scaled to fit 2048 pixels;
2108
+ the helper decodes it with VideoToolbox and feeds the same encoder, JPEG path,
2109
+ keyframe requests and bitrate adaptation as a simulator or emulator. The
2110
+ stream has no time limit and restarts only with the helper.
2111
+
2112
+ Input goes over scrcpy's control socket: touches as finger events in the
2113
+ current frame's pixels, text as injected text, and `\n`, `\t`, `\b`,
2114
+ `home`, `back`, `app-switch` and `lock` as key events. `input.rotate` and
2115
+ `input.posture` fail with `bad-request`, because a phone turns only in hand.
2116
+
2117
+ Some Android 15 and 16 devices send no frame until their screen changes
2118
+ (scrcpy #6500, #6546), so a tile can stay blank until then. With its screen
2119
+ off, a phone streams what its display shows, such as a Samsung always-on
2120
+ display, or black; the stream never wakes it.
2121
+
2122
+ For testing without a phone, `STIM_SERVER_TEST_ADB_EMULATORS=1` in the
2123
+ server's environment lets a `physical: true` target resolve to an emulator the
2124
+ workspace leases, which then streams and takes input over adb the same way.