@stim-cli/server 1.10.1 → 1.12.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.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # @stim-cli/server
2
2
 
3
- `stim-server` serves Stim state to paired read-only clients, such as the Stim
4
- phone app. It runs on the Mac, next to Stim, and changes no Stim state: it
5
- writes only its own pairing state under `$STIM_HOME/server/`.
3
+ `stim-server` serves Stim state to paired clients, such as the Stim phone app,
4
+ and lets the clients the Mac grants control run `stim reload` and `stim stop`
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
8
 
7
9
  The design is in
8
10
  [`docs/specs/2026-09-25-stim-server-design.md`](../../docs/specs/2026-09-25-stim-server-design.md).
@@ -11,14 +13,20 @@ The design is in
11
13
 
12
14
  ```bash
13
15
  stim-server [--port <n>] # serve paired clients, port 7787 by default
14
- stim-server pair [--port <n>] # print a single-use pairing payload
16
+ stim-server pair [--port <n>] [--control]
17
+ # print a single-use pairing payload
15
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
16
21
  stim-server devices revoke <id> # revoke a paired device
22
+ stim-server log # list the actions paired devices ran
17
23
  ```
18
24
 
19
25
  `pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
20
26
  `devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
21
27
  `identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash.
28
+ `log --json` prints `{ "actions": [...] }`, the records described under
29
+ [Actions](#actions).
22
30
 
23
31
  `GET http://127.0.0.1:7787/health` answers requests from this Mac with the
24
32
  server's name, versions, protocol, `stimHome`, and the Tailscale state it
@@ -96,6 +104,19 @@ same peer within a minute block new connections from it for up to a minute.
96
104
  Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
97
105
  closes its open connections.
98
106
 
107
+ ## Scopes
108
+
109
+ 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,
112
+ `stim-server devices grant <id> --control` adds control to a paired device and
113
+ `--read` takes it away. Nothing a client sends changes its own capabilities.
114
+ The server checks the device's capabilities in `devices.json` on every action
115
+ 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
117
+ the capabilities and actions of the connection's device when it connects; a
118
+ connection sees a new grant after it reconnects.
119
+
99
120
  ## Protocol
100
121
 
101
122
  JSON messages over a WebSocket. Requests are `{ "id", "method", "params" }`,
@@ -105,8 +126,11 @@ Events are `{ "event", "subscription", ... }`.
105
126
  - `hello` must come first. Params: `protocol` (1), `client` (`name`,
106
127
  `version`), and `auth`, either `{ "pairingToken", "deviceName" }` or
107
128
  `{ "deviceToken" }`. The result carries the server name and versions, the
108
- granted capabilities (`read` only in protocol 1), the paired device, and the
109
- new `deviceToken` when the hello paired.
129
+ device's `capabilities` (see [Scopes](#scopes)), the `actions` it may run
130
+ (none without `control`), the paired device, and the new `deviceToken` when
131
+ the hello paired. `server.home` is the home folder
132
+ of the user the server runs as, so clients can show paths under it as
133
+ `~/...`.
110
134
  - `status.subscribe` returns a subscription id. Each `status` event carries a
111
135
  full payload as `stim status --watch --json` prints it. All subscribers share
112
136
  one `stim status --watch --json` child, which stops with the last
@@ -114,7 +138,7 @@ Events are `{ "event", "subscription", ... }`.
114
138
  - `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
115
139
  events: first the last `tail` matching records, then new ones in batches.
116
140
  Both take the Stim Desktop log viewer's filters: `workspace` (required),
117
- `sources` (`metro`, `client`, `device`, `build`), `slot`, `level` (the
141
+ `sources` (`metro`, `client`, `device`, `build`, `agent`), `slot`, `level` (the
118
142
  minimum), `grep` (a regular expression), `errors`, and `tail` (1 to 5000,
119
143
  5000 by default). Without `sources`, `errors` keeps the CLI's default error
120
144
  scope. They run `stim logs --json` and `stim logs --json --follow` in the
@@ -123,37 +147,233 @@ Events are `{ "event", "subscription", ... }`.
123
147
  - `stats.get` and `settings.get` return the payload of `stim stats --json` and
124
148
  `stim settings --json`, which masks sensitive values. Without `workspace`,
125
149
  they run in the home directory and cover the machine only.
126
- - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`) and
127
- `slot` (`default` when absent) and sends `frame` events: a JPEG screenshot,
128
- base64 in `data`, with `width`, `height` and `capturedAt`. It serves only a
129
- booted simulator or a running emulator that `stim status` lists as owned by
130
- that workspace; any other device ends the subscription with a
131
- `frames-failed` `error` event, and so does a device that stops or changes
132
- owner. Simulators are captured with `xcrun simctl io <udid> screenshot`, of
133
- the primary display, or of the default display when that `simctl` does not
134
- accept `primary`. Emulators are captured through their gRPC `getScreenshot`,
150
+ - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`),
151
+ `slot` (`default` when absent), `fps` (1 to 30, 5 by default) and `maxEdge`
152
+ (240 to 2048 pixels, 1280 by default), and sends `frame` events: a JPEG,
153
+ base64 in `data`, with `width`, `height` and `capturedAt`, at most `fps` a
154
+ 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;
156
+ any other device ends the subscription with a `frames-failed` `error`
157
+ event, and so does a device that stops or changes owner. A client whose
158
+ socket has more than two frames unsent skips frames and gets the newest
159
+ once it catches up.
160
+
161
+ Frames come from the `stim-frames` helper. When it starts, the server
162
+ compiles it with `xcrun swiftc` from the Swift sources shipped in
163
+ `dist/stim-frames/` (its own `main.swift` and the frame and input code it
164
+ shares with Stim Desktop), which takes a few seconds, and keeps it in
165
+ `$STIM_HOME/server/helpers/`, named by a hash of the sources and the
166
+ compiler version. For a simulator it renders the display's framebuffer
167
+ (CoreSimulator's IOSurface) when the display reports damage, turned
168
+ upright; for an emulator it keeps one gRPC `streamScreenshot` call open,
135
169
  found through the discovery file and token the emulator writes when Stim
136
- boots it, scaled to fit 1280 pixels and converted with `sips`. An emulator
137
- Stim booted before it passed `-grpc` has no endpoint. A frame is sent only
138
- when the screen changed: up to 5 per second while it changes, backing off to
139
- one capture per second while it does not, with capturing taking at most half
140
- of each device's time. All subscribers of a device share one capture loop,
141
- which sends a new subscriber the latest frame and stops with the last
142
- subscriber, and at most two captures run at once. A client whose socket has
143
- more than 1 MiB unsent skips frames and gets the newest once it catches up.
170
+ boots it. It scales frames to fit the largest `maxEdge` and paces them to
171
+ the highest `fps` its subscribers asked for. All subscribers of a device
172
+ share one helper, which sends a new subscriber the latest frame and exits
173
+ with the last subscriber or when the server's end of its stdin closes.
174
+
175
+ A client that decodes H.264 adds `video: ["h264"]`. When the helper is
176
+ built, the result carries `video: "h264"`, `fps` may go up to 60, and
177
+ frames arrive as binary WebSocket messages instead of `frame` events: a
178
+ big-endian header (u8 version 1, u8 flags with bit 0 set on a keyframe,
179
+ u16 header length, u32 sequence number of the messages sent on this
180
+ subscription, f64 capture time in milliseconds since the epoch on the
181
+ Mac's clock, u16 width, u16 height, u8 subscription id length and the
182
+ ASCII id), then one Annex-B access unit. Every keyframe carries its SPS
183
+ and PPS, and the stream has no B-frames, so each access unit is shown as
184
+ it arrives. The helper encodes with VideoToolbox in real time: Main
185
+ profile, a keyframe at least every 2 seconds, straight from the
186
+ simulator's IOSurface or from the emulator's RGBA frames, only when the
187
+ screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
188
+ with its `subscription` asks for another one, such as after its decoder
189
+ lost state; a device sends at most one requested keyframe every 250 ms.
190
+ A subscriber whose socket holds more than 256 KB unsent drops frames
191
+ until it drains, then gets a keyframe. While it is behind, the device's
192
+ bitrate halves every 2 seconds, from 3 Mbps down to 0.25 Mbps; it climbs
193
+ back by a quarter every 2 seconds without congestion, up to 8 Mbps. All
194
+ video subscribers of a device share one encoder and its bitrate, and JPEG
195
+ subscribers of the same device still get at most the `fps` they asked
196
+ for. Watching video needs only `read`. Without the helper, the result has
197
+ no `video`, `fps` above 30 is lowered to 30, and `frame` events arrive as
198
+ before; an iPhone Duo and a helper that fails before its first frame also
199
+ fall back to `frame` events within a video subscription.
200
+
201
+ Without the helper (the compiler is missing or fails, which the server
202
+ retries every 5 minutes, or the helper fails before its first frame), for a
203
+ subscription made while it is still being built, and for an iPhone Duo,
204
+ frames come from screenshots, and `fps` and `maxEdge` only cap the rate. Simulators are
205
+ captured with `xcrun simctl io <udid> screenshot`, of the primary display,
206
+ or of the default display when that `simctl` does not accept `primary`. An
207
+ iPhone Duo lights one of two panels: the capture follows the lit one
208
+ (`primary`, the cover, or `primary-1`, the inner panel) and the frame
209
+ carries `posture`, `folded` or `unfolded`. Emulators are captured through
210
+ their gRPC `getScreenshot`, scaled to fit 1280 pixels and converted with
211
+ `sips`. An emulator whose gRPC POSTURE physical model reports a
212
+ posture, such as a `pixel_fold` AVD, is a foldable: its frames carry
213
+ `posture`, `folded` while the screenshot reports a folded display and
214
+ `unfolded` otherwise. The server asks once per capture session, and a
215
+ failed query counts as no hinge until the session restarts. A screenshot is sent only when the screen changed: up to 5 per
216
+ second while it changes, backing off to one capture per second while it
217
+ does not, with capturing taking at most half of each device's time, and at
218
+ most two captures run at once. An emulator Stim booted before it passed
219
+ `-grpc` has no endpoint on either path.
220
+
221
+ - `build.plan` takes `workspace`, `platform` (`ios` or `android`) and `slot`
222
+ (`default` when absent), and returns the payload of
223
+ `stim <platform> --plan --json` run in the workspace: the fingerprint, the
224
+ cache result the next build would get (`local`, `remote` or `false`), the
225
+ prebuild decision, `expectedMs` with its `basis`, and on a predicted cold
226
+ build its `missReason`. It builds, boots and
227
+ installs nothing and writes no Stim state; a remote cache check can
228
+ download the artifact into a temporary directory, so it gets 150 seconds
229
+ instead of 60. A plan predicting that the build would refuse is a result
230
+ whose `refusal` holds the code, message and remedy. A plan that cannot be
231
+ computed is a `stim-failed` error. One plan runs per workspace at a time,
232
+ across all connections; later requests wait their turn, and the 150
233
+ seconds start when the plan starts.
234
+ - `machine.get` returns cheap machine usage, read in the server process
235
+ without running `stim`: `volumes`, one per volume that holds a Stim
236
+ workspace, Stim home, or the simulators, with `mount`, `holds`, `freeBytes`
237
+ (free space without purgeable space, which Stim's disk budget measures) and
238
+ `totalBytes`; `memory` with `totalBytes`, `usedBytes` (the Mac's memory in
239
+ use as Activity Monitor's "Memory Used" counts it: app memory, wired and
240
+ compressed; null off macOS) and the macOS `pressure` level (`normal`,
241
+ `warning`, `critical`, or null); `load` with the 1, 5 and 15
242
+ minute load averages and `cpus`; and `sampledAt`.
243
+ - `machine.history` returns `{ "intervalMs", "samples" }`: machine usage
244
+ sampled every 5 seconds while at least one client is connected, the last
245
+ 720 samples (an hour of connected time), kept in memory only. Each sample
246
+ has `at` (epoch milliseconds), `cpu` (busy fraction since the previous
247
+ sample), `memoryUsedBytes`, `memoryPressure` (0 normal, 1 warning, 2
248
+ critical) and `diskFreeBytes` of the startup volume; a field is null when it
249
+ cannot be read. `sinceMs` returns only the samples taken after it.
144
250
  - `unsubscribe` ends a subscription.
251
+ - `action` runs an [action](#actions) and returns
252
+ `{ "action", "workspace", "output" }`.
145
253
  - An `error` event ends a subscription whose source failed, or whose client
146
254
  fell behind (`slow-client`); subscribe again.
147
255
 
148
256
  `workspace` is an environment `path` from a status payload. Any other path is
149
257
  refused with `unknown-workspace` and runs nothing. A connection holds at most
150
- 32 subscriptions and runs at most 4 `logs.query`, `stats.get` and
151
- `settings.get` requests at a time. Those requests fail after 60 seconds or
152
- 32 MiB of output, and closing the connection stops them. A `stim` child that
258
+ 32 subscriptions and runs at most 4 `logs.query`, `stats.get`,
259
+ `settings.get` and `build.plan` requests at a time. Those requests fail after
260
+ 60 seconds, `build.plan` after 150, or at 32 MiB of output, and closing the
261
+ connection stops them. When the command refuses with Stim's error contract on
262
+ stdout, the `stim-failed` message is its code, message and remedy; otherwise
263
+ it is the exit status and the end of stderr. A `stim` child that
153
264
  ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
154
265
  4 MiB unsent gets no more batches until it catches up; past 20,000
155
266
  waiting records the server ends that subscription with `slow-client`.
156
267
 
268
+ ## Actions
269
+
270
+ A device with `control` can send `action` with params `{ "action", "workspace" }`:
271
+
272
+ | Action | Params | Runs in the workspace |
273
+ | -------- | -------------------------------------------------------------------- | ------------------------------- |
274
+ | `reload` | `platform` (`ios` or `android`), optional; needed when both are live | `stim reload [platform] --json` |
275
+ | `stop` | none | `stim stop --json` |
276
+
277
+ Each action is one fixed argument list passed to the bundled `stim`, never
278
+ through a shell. `workspace` must be a project path Stim has registered, the
279
+ `path` a status payload lists; the command runs there. The result's `output`
280
+ is the JSON the command printed. The server refuses the request and runs
281
+ nothing with:
282
+
283
+ - `forbidden` when the device has only `read`;
284
+ - `unknown-action` for any other action;
285
+ - `bad-request` for a missing `workspace`, a `platform` that is not `ios` or
286
+ `android`, or any other param;
287
+ - `unknown-workspace` for a path Stim has not registered or that no longer
288
+ exists;
289
+ - `action-busy` while another action runs in that workspace. The server runs
290
+ one action per workspace at a time, across all connections.
291
+
292
+ A command that exits with an error fails with `action-failed` and the message
293
+ and remedy it printed. An action fails with `action-failed` after 120 seconds,
294
+ and its `stim` child gets SIGTERM. An action keeps running when its client
295
+ disconnects, and stopping `stim-server` stops it.
296
+
297
+ Every `action` request from a paired device, refused or run, appends one line
298
+ to `$STIM_HOME/server/actions.ndjson`: `at`, `device` (`id` and `name`),
299
+ `action`, `workspace`, `platform` when given, `ok`, `error` when it failed,
300
+ and `durationMs` when it ran. Strings from the client and error messages are
301
+ cut to 256 characters. `stim-server log` prints them, with control characters
302
+ replaced by `?`.
303
+
304
+ ## Control
305
+
306
+ A device with `control` can drive a simulator or emulator that `stim status`
307
+ lists as owned by a workspace. Nothing it sends reaches any other device.
308
+
309
+ - `control.begin` takes `workspace`, `platform`, `slot` (`default` when
310
+ absent) and `takeOver`, and returns `{ "session", "platform", "lease",
311
+ "postures" }`. `postures` lists what `input.posture` takes for the device:
312
+ `folded` and `unfolded` for an iPhone Duo, `folded`, `half-open` and
313
+ `unfolded` for an emulator with a hinge, such as a `pixel_fold` AVD, and
314
+ none otherwise.
315
+ The server refuses with `device-busy` when status reports the device driven
316
+ (agent-device, a `stim device lock`, a test runner) or another client
317
+ controls it, naming the driver. With `takeOver: true` it proceeds anyway;
318
+ another client's session then ends with `taken-over`.
319
+ - The session holds a `stim device lock <platform> <id> --for 2m` lease,
320
+ renewed every minute, so `stim status` shows the device as driven by
321
+ `stim device lock` and agents leave it alone. `lease` carries its
322
+ `grantedAt`, the `since` of that driver, so a client can tell its own lease
323
+ from an agent's. The server releases the lease with `stim device unlock`
324
+ when the session ends, unless the lease was already held before the session
325
+ began, as when it took the device over from an agent in the same workspace;
326
+ it renews only a lease it took. Leases belong to the workspace, not to the
327
+ session: an agent in the same workspace that runs `stim device lock` on the
328
+ device during a session shares the lease, and the session's end releases
329
+ it.
330
+ - `input.touch` takes `session`, `phase` (`down`, `move`, `up`), `x` and `y`
331
+ as fractions of the upright screen, and `display` (0, the main display).
332
+ `input.text` takes up to 256 printable ASCII characters, where `\n` presses
333
+ Return, `\t` Tab and `\b` Delete. `input.button` takes `home` or `lock`,
334
+ and on Android also `back` or `app-switch`. `input.rotate` takes
335
+ `direction` (`left` or `right`) and turns the device a quarter turn.
336
+ `input.posture` takes one of the session's `postures`. Each answers `{}` once the input
337
+ is handed to the device: when it goes through the helper, that is when the
338
+ 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
339
+ characters a second, with a burst of 256, and rotate or change posture twice a
340
+ second; more fail with `limit-exceeded`.
341
+ - `control.end` ends a session. The server also ends it with a
342
+ `control-ended` event `{ "session", "reason", "message" }` after 5 minutes
343
+ without input (`idle`), when another client takes the device over
344
+ (`taken-over`), when the device stops or changes owner (`device-gone`),
345
+ when the paired device loses `control` (`forbidden`), or when input cannot
346
+ reach the device (`failed`). Closing the connection ends its sessions.
347
+
348
+ Input goes through the device's `stim-frames` helper, the process that
349
+ streams its frames:
350
+
351
+ - Simulators take touches, keys and buttons through the simulator's
352
+ CoreDevice HID service (`dtuhidd`), the one Xcode's Device Hub uses, so
353
+ input keeps working while Device Hub or Siniulator shows the simulator.
354
+ With an Xcode whose simulators have no such service, input goes through
355
+ SimulatorKit's legacy HID client instead. The first input starts that service, as Device
356
+ Hub does, and from then until the simulator reboots it ignores tools that
357
+ still use SimulatorKit's legacy HID client. Text is typed key by key on a US
358
+ layout. `lock` is the side button.
359
+ - Emulators take touches through the emulator's gRPC `sendTouch`. Text and
360
+ buttons go through gRPC `sendKey` when the emulator reports a hardware
361
+ keyboard, which AVDs Stim creates have. An emulator without one drops key
362
+ events, so for it text and buttons go through `adb -s <serial> shell input`.
363
+ - Rotation and posture go the way Stim Desktop sends them: a simulator turns
364
+ through the orientation message Simulator.app sends it, and an emulator
365
+ through gRPC `setPhysicalModel` for rotation and `setPosture` for its hinge.
366
+ An iPhone Duo folds with `sim-fold`, which the server builds from Stim
367
+ Desktop's sources on the first fold and runs inside the simulator with
368
+ `xcrun simctl spawn`; it answers `{}` once the fold finished. `sim-fold`
369
+ swaps the posture, so the server runs it only when the Duo's latest frame
370
+ shows the other posture, and refuses the request until a frame showed one.
371
+
372
+ Every session start, takeover and end, and every refused `control.begin`,
373
+ appends a line to the action log, with `action` set to `control.begin`,
374
+ `control.take-over` or `control.end`, and a `reason` that says why the session
375
+ ended or whom it took the device from. Inputs are not logged.
376
+
157
377
  The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
158
378
  refuse the client until it pairs again or updates; clients retry the others.
159
379