@stim-cli/server 1.10.0 → 1.11.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,18 +13,29 @@ 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
25
- started with. Stim Desktop uses it to find a running server. A request through
33
+ started with. While Tailscale runs, it also carries `route`, read from
34
+ `tailscale serve status --json` on each request: `routed` with the HTTPS
35
+ `port` that proxies to the server, `funneled` with the Funnel `ports` that do,
36
+ `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
26
39
  `tailscale serve`, on a Tailscale address, or with a `Host` other than
27
40
  `127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request.
28
41
 
@@ -40,12 +53,23 @@ same tailnet or with the Mac shared to the phone's user.
40
53
 
41
54
  `stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
42
55
  addresses, never on every interface. Run this once so clients can use
43
- `wss://<mac>.<tailnet>.ts.net` with a valid certificate:
56
+ `wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
44
57
 
45
58
  ```bash
46
- tailscale serve --bg http://127.0.0.1:7787
59
+ tailscale serve --bg --https=7443 http://127.0.0.1:7787
47
60
  ```
48
61
 
62
+ The dedicated port 7443 keeps the server tailnet only and leaves port 443 to
63
+ other apps. Do not serve `stim-server` on a port where Tailscale Funnel is on:
64
+ Funnel makes every handler on that port reachable from the public internet.
65
+
66
+ `stim-server` reads `tailscale serve status --json` at start and on every
67
+ `pair`. It uses the HTTPS port whose `/` handler proxies to its loopback port,
68
+ preferring 7443. Without such a route, it assumes 7443 and prints the command
69
+ above, or the next free port when 7443 is taken. It never suggests a Funnel
70
+ port. Any handler, at any path, or TCP forward that reaches the server on a
71
+ Funnel port makes it public, and `pair` refuses.
72
+
49
73
  When Tailscale is not running, `stim-server` listens on loopback only and
50
74
  says so on stderr. Start Tailscale, then restart `stim-server`.
51
75
 
@@ -54,9 +78,13 @@ says so on stderr. Start Tailscale, then restart `stim-server`.
54
78
  `stim-server pair` prints the JSON that the pairing QR code encodes:
55
79
 
56
80
  ```json
57
- { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net", "pairingToken": "..." }
81
+ { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }
58
82
  ```
59
83
 
84
+ The endpoint names the route's port, and omits it for 443. When a route to
85
+ the server is on a port with Funnel on, `pair` refuses and exits 1 without
86
+ creating a token.
87
+
60
88
  The pairing token works once and expires after 5 minutes. A client spends it in
61
89
  `hello` and receives a random device token, which it presents on every later
62
90
  connection. The server stores only the SHA-256 hash of each token.
@@ -76,6 +104,18 @@ same peer within a minute block new connections from it for up to a minute.
76
104
  Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
77
105
  closes its open connections.
78
106
 
107
+ ## Scopes
108
+
109
+ A paired device has the `read` capability, which serves state, or also
110
+ `control`, which runs [actions](#actions). 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
+ so taking control away applies to open connections at once. `hello` reports
116
+ the capabilities and actions of the connection's device when it connects; a
117
+ connection sees a new grant after it reconnects.
118
+
79
119
  ## Protocol
80
120
 
81
121
  JSON messages over a WebSocket. Requests are `{ "id", "method", "params" }`,
@@ -85,8 +125,11 @@ Events are `{ "event", "subscription", ... }`.
85
125
  - `hello` must come first. Params: `protocol` (1), `client` (`name`,
86
126
  `version`), and `auth`, either `{ "pairingToken", "deviceName" }` or
87
127
  `{ "deviceToken" }`. The result carries the server name and versions, the
88
- granted capabilities (`read` only in protocol 1), the paired device, and the
89
- new `deviceToken` when the hello paired.
128
+ device's `capabilities` (see [Scopes](#scopes)), the `actions` it may run
129
+ (none without `control`), the paired device, and the new `deviceToken` when
130
+ the hello paired. `server.home` is the home folder
131
+ of the user the server runs as, so clients can show paths under it as
132
+ `~/...`.
90
133
  - `status.subscribe` returns a subscription id. Each `status` event carries a
91
134
  full payload as `stim status --watch --json` prints it. All subscribers share
92
135
  one `stim status --watch --json` child, which stops with the last
@@ -94,7 +137,7 @@ Events are `{ "event", "subscription", ... }`.
94
137
  - `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
95
138
  events: first the last `tail` matching records, then new ones in batches.
96
139
  Both take the Stim Desktop log viewer's filters: `workspace` (required),
97
- `sources` (`metro`, `client`, `device`, `build`), `slot`, `level` (the
140
+ `sources` (`metro`, `client`, `device`, `build`, `agent`), `slot`, `level` (the
98
141
  minimum), `grep` (a regular expression), `errors`, and `tail` (1 to 5000,
99
142
  5000 by default). Without `sources`, `errors` keeps the CLI's default error
100
143
  scope. They run `stim logs --json` and `stim logs --json --follow` in the
@@ -103,37 +146,157 @@ Events are `{ "event", "subscription", ... }`.
103
146
  - `stats.get` and `settings.get` return the payload of `stim stats --json` and
104
147
  `stim settings --json`, which masks sensitive values. Without `workspace`,
105
148
  they run in the home directory and cover the machine only.
106
- - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`) and
107
- `slot` (`default` when absent) and sends `frame` events: a JPEG screenshot,
108
- base64 in `data`, with `width`, `height` and `capturedAt`. It serves only a
109
- booted simulator or a running emulator that `stim status` lists as owned by
110
- that workspace; any other device ends the subscription with a
111
- `frames-failed` `error` event, and so does a device that stops or changes
112
- owner. Simulators are captured with `xcrun simctl io <udid> screenshot`, of
113
- the primary display, or of the default display when that `simctl` does not
114
- accept `primary`. Emulators are captured through their gRPC `getScreenshot`,
149
+ - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`),
150
+ `slot` (`default` when absent), `fps` (1 to 30, 5 by default) and `maxEdge`
151
+ (240 to 2048 pixels, 1280 by default), and sends `frame` events: a JPEG,
152
+ base64 in `data`, with `width`, `height` and `capturedAt`, at most `fps` a
153
+ second and only when the screen changed. It serves only a booted simulator
154
+ or a running emulator that `stim status` lists as owned by that workspace;
155
+ any other device ends the subscription with a `frames-failed` `error`
156
+ event, and so does a device that stops or changes owner. A client whose
157
+ socket has more than two frames unsent skips frames and gets the newest
158
+ once it catches up.
159
+
160
+ Frames come from the `stim-frames` helper. When it starts, the server
161
+ compiles it with `xcrun swiftc` from the Swift sources shipped in
162
+ `dist/stim-frames/` (its own `main.swift` and the frame and input code it
163
+ shares with Stim Desktop), which takes a few seconds, and keeps it in
164
+ `$STIM_HOME/server/helpers/`, named by a hash of the sources and the
165
+ compiler version. For a simulator it renders the display's framebuffer
166
+ (CoreSimulator's IOSurface) when the display reports damage, turned
167
+ upright; for an emulator it keeps one gRPC `streamScreenshot` call open,
115
168
  found through the discovery file and token the emulator writes when Stim
116
- boots it, scaled to fit 1280 pixels and converted with `sips`. An emulator
117
- Stim booted before it passed `-grpc` has no endpoint. A frame is sent only
118
- when the screen changed: up to 5 per second while it changes, backing off to
119
- one capture per second while it does not, with capturing taking at most half
120
- of each device's time. All subscribers of a device share one capture loop,
121
- which sends a new subscriber the latest frame and stops with the last
122
- subscriber, and at most two captures run at once. A client whose socket has
123
- more than 1 MiB unsent skips frames and gets the newest once it catches up.
169
+ boots it. It scales frames to fit the largest `maxEdge` and paces them to
170
+ the highest `fps` its subscribers asked for. All subscribers of a device
171
+ share one helper, which sends a new subscriber the latest frame and exits
172
+ with the last subscriber or when the server's end of its stdin closes.
173
+
174
+ A client that decodes H.264 adds `video: ["h264"]`. When the helper is
175
+ built, the result carries `video: "h264"`, `fps` may go up to 60, and
176
+ frames arrive as binary WebSocket messages instead of `frame` events: a
177
+ big-endian header (u8 version 1, u8 flags with bit 0 set on a keyframe,
178
+ u16 header length, u32 sequence number of the messages sent on this
179
+ subscription, f64 capture time in milliseconds since the epoch on the
180
+ Mac's clock, u16 width, u16 height, u8 subscription id length and the
181
+ ASCII id), then one Annex-B access unit. Every keyframe carries its SPS
182
+ and PPS, and the stream has no B-frames, so each access unit is shown as
183
+ it arrives. The helper encodes with VideoToolbox in real time: Main
184
+ profile, a keyframe at least every 2 seconds, straight from the
185
+ simulator's IOSurface or from the emulator's RGBA frames, only when the
186
+ screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
187
+ with its `subscription` asks for another one, such as after its decoder
188
+ lost state; a device sends at most one requested keyframe every 250 ms.
189
+ A subscriber whose socket holds more than 256 KB unsent drops frames
190
+ until it drains, then gets a keyframe. While it is behind, the device's
191
+ bitrate halves every 2 seconds, from 3 Mbps down to 0.25 Mbps; it climbs
192
+ back by a quarter every 2 seconds without congestion, up to 8 Mbps. All
193
+ video subscribers of a device share one encoder and its bitrate, and JPEG
194
+ subscribers of the same device still get at most the `fps` they asked
195
+ for. Watching video needs only `read`. Without the helper, the result has
196
+ no `video`, `fps` above 30 is lowered to 30, and `frame` events arrive as
197
+ before; an iPhone Duo and a helper that fails before its first frame also
198
+ fall back to `frame` events within a video subscription.
199
+
200
+ Without the helper (the compiler is missing or fails, which the server
201
+ retries every 5 minutes, or the helper fails before its first frame), for a
202
+ subscription made while it is still being built, and for an iPhone Duo,
203
+ frames come from screenshots, and `fps` and `maxEdge` only cap the rate. Simulators are
204
+ captured with `xcrun simctl io <udid> screenshot`, of the primary display,
205
+ or of the default display when that `simctl` does not accept `primary`. An
206
+ iPhone Duo lights one of two panels: the capture follows the lit one
207
+ (`primary`, the cover, or `primary-1`, the inner panel) and the frame
208
+ carries `posture`, `folded` or `unfolded`. Emulators are captured through
209
+ their gRPC `getScreenshot`, scaled to fit 1280 pixels and converted with
210
+ `sips`. An emulator whose gRPC POSTURE physical model reports a
211
+ posture, such as a `pixel_fold` AVD, is a foldable: its frames carry
212
+ `posture`, `folded` while the screenshot reports a folded display and
213
+ `unfolded` otherwise. The server asks once per capture session, and a
214
+ failed query counts as no hinge until the session restarts. A screenshot is sent only when the screen changed: up to 5 per
215
+ second while it changes, backing off to one capture per second while it
216
+ does not, with capturing taking at most half of each device's time, and at
217
+ most two captures run at once. An emulator Stim booted before it passed
218
+ `-grpc` has no endpoint on either path.
219
+
220
+ - `build.plan` takes `workspace`, `platform` (`ios` or `android`) and `slot`
221
+ (`default` when absent), and returns the payload of
222
+ `stim <platform> --plan --json` run in the workspace: the fingerprint, the
223
+ cache result the next build would get (`local`, `remote` or `false`), the
224
+ prebuild decision, and `expectedMs` with its `basis`. It builds, boots and
225
+ installs nothing and writes no Stim state; a remote cache check can
226
+ download the artifact into a temporary directory, so it gets 150 seconds
227
+ instead of 60. A plan predicting that the build would refuse is a result
228
+ whose `refusal` holds the code, message and remedy. A plan that cannot be
229
+ computed is a `stim-failed` error.
230
+ - `machine.get` returns cheap machine usage, read in the server process
231
+ without running `stim`: `volumes`, one per volume that holds a Stim
232
+ workspace, Stim home, or the simulators, with `mount`, `holds`, `freeBytes`
233
+ (free space without purgeable space, which Stim's disk budget measures) and
234
+ `totalBytes`; `memory` with `totalBytes`, `usedBytes` (the Mac's memory in
235
+ use as Activity Monitor's "Memory Used" counts it: app memory, wired and
236
+ compressed; null off macOS) and the macOS `pressure` level (`normal`,
237
+ `warning`, `critical`, or null); `load` with the 1, 5 and 15
238
+ minute load averages and `cpus`; and `sampledAt`.
239
+ - `machine.history` returns `{ "intervalMs", "samples" }`: machine usage
240
+ sampled every 5 seconds while at least one client is connected, the last
241
+ 720 samples (an hour of connected time), kept in memory only. Each sample
242
+ has `at` (epoch milliseconds), `cpu` (busy fraction since the previous
243
+ sample), `memoryUsedBytes`, `memoryPressure` (0 normal, 1 warning, 2
244
+ critical) and `diskFreeBytes` of the startup volume; a field is null when it
245
+ cannot be read. `sinceMs` returns only the samples taken after it.
124
246
  - `unsubscribe` ends a subscription.
247
+ - `action` runs an [action](#actions) and returns
248
+ `{ "action", "workspace", "output" }`.
125
249
  - An `error` event ends a subscription whose source failed, or whose client
126
250
  fell behind (`slow-client`); subscribe again.
127
251
 
128
252
  `workspace` is an environment `path` from a status payload. Any other path is
129
253
  refused with `unknown-workspace` and runs nothing. A connection holds at most
130
- 32 subscriptions and runs at most 4 `logs.query`, `stats.get` and
131
- `settings.get` requests at a time. Those requests fail after 60 seconds or
132
- 32 MiB of output, and closing the connection stops them. A `stim` child that
254
+ 32 subscriptions and runs at most 4 `logs.query`, `stats.get`,
255
+ `settings.get` and `build.plan` requests at a time. Those requests fail after
256
+ 60 seconds, `build.plan` after 150, or at 32 MiB of output, and closing the
257
+ connection stops them. When the command refuses with Stim's error contract on
258
+ stdout, the `stim-failed` message is its code, message and remedy; otherwise
259
+ it is the exit status and the end of stderr. A `stim` child that
133
260
  ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
134
261
  4 MiB unsent gets no more batches until it catches up; past 20,000
135
262
  waiting records the server ends that subscription with `slow-client`.
136
263
 
264
+ ## Actions
265
+
266
+ A device with `control` can send `action` with params `{ "action", "workspace" }`:
267
+
268
+ | Action | Params | Runs in the workspace |
269
+ | -------- | -------------------------------------------------------------------- | ------------------------------- |
270
+ | `reload` | `platform` (`ios` or `android`), optional; needed when both are live | `stim reload [platform] --json` |
271
+ | `stop` | none | `stim stop --json` |
272
+
273
+ Each action is one fixed argument list passed to the bundled `stim`, never
274
+ through a shell. `workspace` must be a project path Stim has registered, the
275
+ `path` a status payload lists; the command runs there. The result's `output`
276
+ is the JSON the command printed. The server refuses the request and runs
277
+ nothing with:
278
+
279
+ - `forbidden` when the device has only `read`;
280
+ - `unknown-action` for any other action;
281
+ - `bad-request` for a missing `workspace`, a `platform` that is not `ios` or
282
+ `android`, or any other param;
283
+ - `unknown-workspace` for a path Stim has not registered or that no longer
284
+ exists;
285
+ - `action-busy` while another action runs in that workspace. The server runs
286
+ one action per workspace at a time, across all connections.
287
+
288
+ A command that exits with an error fails with `action-failed` and the message
289
+ and remedy it printed. An action fails with `action-failed` after 120 seconds,
290
+ and its `stim` child gets SIGTERM. An action keeps running when its client
291
+ disconnects, and stopping `stim-server` stops it.
292
+
293
+ Every `action` request from a paired device, refused or run, appends one line
294
+ to `$STIM_HOME/server/actions.ndjson`: `at`, `device` (`id` and `name`),
295
+ `action`, `workspace`, `platform` when given, `ok`, `error` when it failed,
296
+ and `durationMs` when it ran. Strings from the client and error messages are
297
+ cut to 256 characters. `stim-server log` prints them, with control characters
298
+ replaced by `?`.
299
+
137
300
  The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
138
301
  refuse the client until it pairs again or updates; clients retry the others.
139
302