@stim-cli/server 1.20.2 → 1.22.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,9 +1,9 @@
1
1
  # @stim-cli/server
2
2
 
3
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. It writes only its own pairing state and action
4
+ and lets the clients the machine grants control run `stim reload` and `stim stop`
5
+ in a workspace. It runs on the same machine as Stim. Private Desktop project
6
+ checks also record completed doctor checks in project state. It writes its own pairing state and action
7
7
  log under `$STIM_HOME/server/`, and the device recordings described under
8
8
  [Recording](#recording).
9
9
 
@@ -16,23 +16,22 @@ The design is in
16
16
  stim-server [--port <n>] [--loopback-only]
17
17
  # serve paired clients, port 7787 by default
18
18
  stim-server pair [--port <n>] [--control]
19
- # print a single-use pairing payload
20
- stim-server devices [list] # list pairings, approvals, requests and hosted sessions
21
- stim-server devices grant <id> --control|--read|--build|--device-host
22
- # let a paired device run actions, or only read;
23
- # --build approves builds; --device-host approves device hosting
24
- stim-server devices revoke <id> # revoke a paired device or client, or deny a request
19
+ # print a single-use pairing payload with this server's key fingerprint
20
+ stim-server devices [list] # list pairings, approved clients and hosted sessions
21
+ stim-server devices grant <id> --control|--read
22
+ # let a paired device run actions, or only read
23
+ stim-server devices revoke <id> # revoke a paired device or approved client and close its connections
25
24
  stim-server log # list the actions paired devices ran
26
- stim-server setup --client <node-id> --ticket <t> --expires <iso> --build|--device-host
27
- # worker-side approval and setup, see below
25
+ STIM_SETUP_TICKET=<t> stim-server setup --expires <iso> --build|--device-host
26
+ # worker-side approval of the machine holding this ticket, see below
28
27
  stim-server service install|status|update|rollback|uninstall
29
28
  # run stim-server as a macOS LaunchAgent, see below
30
29
  ```
31
30
 
32
31
  `--loopback-only` makes the server listen on `127.0.0.1` only and refuse any request
33
32
  that names a remote peer (`X-Forwarded-For`, as `tailscale serve --https` adds), so
34
- phones and other Macs cannot connect; `/health` then reports `loopbackOnly: true`.
35
- Without it, the server also listens on this Mac's Tailscale addresses while Tailscale
33
+ phones and other machines cannot connect; `/health` then reports `loopbackOnly: true`.
34
+ Without it, the server also listens on this machine's Tailscale addresses while Tailscale
36
35
  runs (see [Tailscale](#tailscale)). Stim Desktop starts its own server with the flag
37
36
  unless it serves phones.
38
37
 
@@ -42,17 +41,16 @@ serving command: see [Run as a service](#run-as-a-service). When serving, a bare
42
41
 
43
42
  `pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
44
43
  `devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
45
- `identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash,
46
- followed by the [build clients](#build-access) and [device-host clients](#device-host-approval).
47
- New client records carry `requestedCapability` (`build` or `device-host`),
48
- including after approval. Pending records also carry `pendingUntil`; a legacy
49
- pending record without `requestedCapability` is a build request.
44
+ `from` (where it paired from, as a label only), `keyFingerprint`, `pairedAt`,
45
+ `lastSeenAt` and `capabilities`, followed by the [build clients](#build-access)
46
+ and [device-host clients](#device-host-approval). The plain listing shows
47
+ `from` and the first 12 characters of the key fingerprint.
50
48
  `log --json` prints `{ "actions": [...] }`, the records described under
51
49
  [Actions](#actions).
52
50
 
53
- `GET http://127.0.0.1:7787/health` answers requests from this Mac with the
51
+ `GET http://127.0.0.1:7787/health` answers requests from this machine with the
54
52
  server's name, versions, `stimBuild` (the digest of the bundled Stim's build,
55
- which offload compares between Macs), protocol, `stimHome`, `startup`, `busy`
53
+ which offload compares between machines), protocol, `stimHome`, `startup`, `busy`
56
54
  (the offloaded `builds` and `hostedSessions` running now) and the current
57
55
  Tailscale state. It answers HTTP 200 once `startup.state` is `ready`; while the
58
56
  state is `pending` or `degraded` (see the LaunchAgent notes below) it answers
@@ -65,75 +63,320 @@ setup command would use. When the server runs under Stim Host, it also carries
65
63
  app (checked at most every 5 seconds), or `null` when the check fails. Stim
66
64
  Desktop uses it to find a running server and show its route. A request through `tailscale serve` or on a Tailscale address
67
65
  without an `Origin` or `Sec-Fetch-Site` header, which a web page's request
68
- carries, gets only `{ "server": "stim-server", "version", "protocol" }`, which Stim
69
- Desktop's Remote Macs list uses to find stim-server on the other Macs of the
70
- tailnet. A request from this Mac with a `Host` other than `127.0.0.1` or
66
+ carries, gets only `{ "version" }`, which Stim
67
+ Desktop's Remote Machines list uses to find stim-server on the other machines of the
68
+ tailnet. A request from this machine with a `Host` other than `127.0.0.1` or
71
69
  `localhost` gets HTTP 426, like any other plain HTTP request.
72
70
 
73
- `GET /setup/<hash>` returns a setup journal over the tailnet HTTP route. The
74
- hash is exactly 64 lowercase hex characters: SHA-256 of the optional
75
- `auth.setupTicket` on a build or device-host request (43 base64url characters).
76
- The registry stores only `setupTicketHash` and retains it after approval;
77
- missing or invalid tickets leave ordinary access requests unchanged.
78
-
79
- The journal lives at `$STIM_HOME/server/setup/<hash>.json`, written atomically
80
- only by stim-server and read through core's state reader. It contains the
81
- client node, ISO `expiresAt`, requested capabilities, steps, granted request
82
- ids, completion state and optional exit code; it contains no tokens. Only the
83
- matching tailnet node can read an unexpired journal. Browser requests, loopback,
84
- 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.
85
- The route returns 503 until startup is ready, caches peer identity for 30 seconds
86
- and returns 429 after repeated identity failures. All responses use
71
+ `GET /setup/<journal>` returns a setup journal. The name is exactly 64
72
+ lowercase hex characters, `SHA-256("stim setup journal v1" || ticket)` of the
73
+ 32-byte setup ticket, so only the ticket's holder can derive it. The journal
74
+ holds no secret, so the route serves it to whoever names it; it never
75
+ authenticates the caller.
76
+
77
+ The journal lives at `$STIM_HOME/server/setup/<journal>.json`, written atomically
78
+ only by stim-server and read through core's state reader. It contains ISO
79
+ `expiresAt`, requested capabilities, steps, granted record ids, completion
80
+ state and optional exit code; it contains no ticket, PSK or token. Browser
81
+ requests, invalid paths, missing, malformed or expired journals get the same 404. The route returns 503 until startup is ready and 429 after repeated
82
+ failures from one peer address. All responses use
87
83
  `cache-control: no-store`. Steps whose ids start with `tools` appear only after
88
84
  a build grant. The server prunes journals more than one hour past expiry at
89
85
  startup and hourly.
90
86
 
91
87
  `stim-server` runs the `stim` version this package was released with, not the
92
- one on your PATH. It reads the login shell's environment once at start, so
93
- `PATH`, `ANDROID_HOME`, and `STIM_*` variables match your terminal even when
94
- another app starts it.
95
-
96
- ## Set up a worker Mac
97
-
98
- A person on the worker runs the command generated for their client Mac:
88
+ one on your PATH. It never runs a login shell. At start it fills the gaps in
89
+ the environment it was given, the way `stim doctor` reports under **Toolchain**:
90
+ it appends the default Node of nvm, fnm, Volta, mise or asdf, Homebrew,
91
+ `~/.local/bin`, pnpm's and bun's global directories and the Android SDK tools to
92
+ `PATH`, sets `ANDROID_HOME` to `~/Library/Android/sdk` when that exists, and sets
93
+ `JAVA_HOME` to a Homebrew `openjdk` (17, then 21, then the newest), else Android
94
+ Studio's bundled runtime, when no JDK is installed under
95
+ `/Library/Java/JavaVirtualMachines`. It never replaces a variable it was given,
96
+ and its own Node's directory is always the last `PATH` entry. CocoaPods uses the
97
+ project's `.ruby-version` Ruby, else rvm's or rbenv's default Ruby, and reads the
98
+ login shell only when neither exists. Anything else a shell profile sets, such as
99
+ `STIM_*` variables, reaches the server only through its environment or `--env`.
100
+
101
+ ## Private Desktop bootstrap
102
+
103
+ When Desktop launches its bundled server, it passes a fresh 32-byte key through
104
+ the child's stdin pipe. The key stays in memory; it is absent from arguments,
105
+ environment values, logs and pairing records. `STIM_DESKTOP_BOOTSTRAP_FD=0`
106
+ marks that private startup input and is removed before the server starts other
107
+ processes. Invalid or missing marked input refuses startup.
108
+ Desktop keeps the pipe open until its child exits. The server shuts down when
109
+ that pipe closes, including when Desktop crashes or is forcibly killed.
110
+
111
+ A connection on the local Unix socket can send `desktopBootstrap: true` in
112
+ `hello`. The server returns a fresh per-connection nonce and an HMAC-SHA256
113
+ proof over `server:<nonce>`. Desktop verifies the child before sending the
114
+ separate `client:<nonce>` proof through `desktop.authenticate`. Each challenge
115
+ answers one proof, so a wrong, replayed or late proof cannot win; disconnect
116
+ ends the authority, and it adds no persisted capability. Network connections,
117
+ which run Noise, never get a challenge.
118
+
119
+ On a server Desktop started, only a socket that passed this proof can call the
120
+ Desktop-only methods (`route.setup`, `machines.update.*`, `device-host.sessions`
121
+ and `device-host.sessions.stop`, plus `desktop.reports.*`, `desktop.settings.get`,
122
+ `desktop.logs.subscribe`, `desktop.doctor.run` and `desktop.stop.subscribe`); another client of this user on the socket, such
123
+ as the CLI, keeps read and control without them. A server Desktop adopted or that
124
+ runs on its own, such as a `stim-server service`, has no private key: it offers no
125
+ challenge and keeps serving those methods to this user's socket. Starting a fresh
126
+ bundled child establishes a new private key. Desktop also refuses a socket whose
127
+ server reports another TCP port than the server it adopted, before it sends any
128
+ proof.
129
+
130
+ `desktop.settings.write` requires the private proof even on an adopted or
131
+ standalone server. Only a server started by Desktop advertises the local-only
132
+ `desktop-settings-write` feature. The method accepts an absolute existing `cwd`,
133
+ setting `key`, optional `scope`, and either `"action": "set"` with a text `value`,
134
+ or `"action": "unset"` without `value`. It runs the shared settings API in process
135
+ and returns its masked setting payload or a typed refusal (`code`, `message`,
136
+ `remedy`). Timeout and output failures use the RPC error channel. Writes resolve
137
+ the selected folder through the shared settings API and share its workspace
138
+ admission with control actions. They stay admitted until their cleanup
139
+ drains, including after timeout or disconnect. Pending writes cancel; recording
140
+ deletion after commit finishes before shutdown. The audit records the key, scope
141
+ and result without setting values. This method grants no phone or network access.
142
+
143
+ The local-only `desktop-doctor-run` feature provides `desktop.doctor.run` with
144
+ an absolute `cwd` and optional `ios` or `android` `platform`. It performs a fresh
145
+ doctor check without a CLI process or repairs, returns the doctor JSON report,
146
+ and records the completed native check in project state. A chosen supported
147
+ project need not already be registered. Recording happens after report execution
148
+ and owned cleanup succeed. Recording waits asynchronously for the config lock,
149
+ honors cancellation and reports unavailable state. The existing private Desktop bootstrap gate applies;
150
+ phone and network clients cannot call it or see the feature. Request caps, output
151
+ limits and the report execution/cleanup budgets apply. Disconnect or shutdown
152
+ cancels the request and drains its owned work. Cached doctor report reads do not
153
+ record checks.
154
+
155
+ The local-only `desktop-run` feature provides `desktop.run.subscribe` with an
156
+ absolute `cwd` and `options` matching the typed `stim.run` selectors. For example,
157
+ `{ "cwd": "/absolute/app", "options": { "platform": "ios", "slot": "fold", "remote": "mini" } }`
158
+ builds, installs and launches through the in-process API. Only a proved private
159
+ Desktop session can call it; adopted servers and local control alone cannot.
160
+ `desktop-run-progress` carries ordered `stream`/`message` chunks, followed by
161
+ `desktop-run-ended` with `exitCode`, a human `message`, and `stdout` containing
162
+ the serialized `{ platform, facts }` API result or typed refusal. The default
163
+ execution budget is one hour; progress plus the terminal JSON are limited to
164
+ 1 MiB. Cancellation, unsubscribe, disconnect and server shutdown cancel and drain
165
+ owned work before releasing workspace admission. Unresolved cleanup retains the
166
+ reservation until every recorded claim is recovered. If cleanup cannot name its
167
+ claims, admission stays blocked until the server restarts; recover the owned work
168
+ before restarting. Reconnect never replays a run. Desktop's platform buttons
169
+ still use the CLI until their consumer migration.
170
+
171
+ The private `desktop-build` feature provides `desktop.build.subscribe` with an
172
+ absolute `cwd` and artifact-only `options`: `ios` accepts `configuration`, `scheme`
173
+ and `arch`; `android` accepts `variant` and `abi`; both accept `buildCache` and
174
+ `remoteBuild`. `macos` accepts `remoteBuild`. It invokes `createStim.build`
175
+ without acquiring a device or starting a runtime. Device, slot, hosted-launch,
176
+ Metro and EAS selectors are refused. It uses the same proved Desktop authority,
177
+ one-hour default budget, 1 MiB output bound and cleanup admission as platform
178
+ run. Ordered `desktop-build-progress` events end with `desktop-build-ended`,
179
+ whose `stdout` contains `{ platform, facts }` or the typed refusal. Reconnecting
180
+ never replays a build. This transport is a migration prerequisite; existing
181
+ Desktop buttons do not use it yet.
182
+
183
+ The private `desktop-gc` feature provides `desktop.gc.subscribe` with an absolute
184
+ existing `cwd` and optional `cache`, `olderThan` (whole days), `worktrees`, `delete`
185
+ and `idleMs` (positive whole milliseconds). It invokes `createStim.collectGarbage`
186
+ with the same scoped cleanup and ownership rules as the CLI. Only the proved
187
+ Desktop connection can call it; phone, network and adopted-server connections
188
+ cannot. Ordered `desktop-gc-progress` events end with `desktop-gc-ended`, whose
189
+ `stdout` contains the GC JSON report or typed refusal and whose `exitCode` preserves
190
+ partial cleanup failures. It preserves the API's 60-second execution and 35-second
191
+ cleanup budgets. The server's request deadline can cancel execution earlier; its
192
+ progress plus terminal JSON bound is 1 MiB. Unsubscribe, disconnect, shutdown and timeout
193
+ cancel and drain owned work before releasing admission; unresolved cleanup retains
194
+ the reservation and its recovery claims. Settled requests invalidate cached Desktop
195
+ reports. Reconnect never repeats cleanup. Desktop GC buttons still use the CLI
196
+ until their consumer migration.
197
+
198
+ The private `desktop-worktree-remove` feature provides
199
+ `desktop.worktree.remove.subscribe` with an absolute existing `cwd`, an optional
200
+ absolute `target`, and optional boolean `force`. It calls `createStim.removeWorktree`
201
+ with the CLI's safety checks and ownership rules. A separate target can be absent
202
+ on disk when retrying pending branch cleanup. Only the proved connection of the
203
+ Desktop that started this server can call it; paired phones and adopted local
204
+ connections cannot.
205
+
206
+ Ordered `desktop-worktree-remove-progress` events end with
207
+ `desktop-worktree-remove-ended`. Its `stdout` contains `{ removed, exitCode }` or
208
+ a typed refusal. Admission covers the resolved checkout or project and every
209
+ registered descendant reclaimed by cleanup, including when the request names a
210
+ subfolder. Deletion and cleanup success remain separate: source cleanup
211
+ can succeed without deleting a checkout, and branch cleanup can fail after a
212
+ linked checkout was deleted. `force` has the CLI's destructive meaning. The API's
213
+ 60-second execution and 35-second cleanup budgets and the server's deadline and
214
+ output limit apply. Cancellation drains owned work before releasing admission;
215
+ unresolved cleanup retains its recovery claims. Settled requests invalidate
216
+ cached Desktop reports. Reconnect never repeats removal. Desktop callers migrate
217
+ separately.
218
+
219
+ The private `desktop-worktree-warm` feature provides
220
+ `desktop.worktree.warm.subscribe` with only an absolute existing `cwd`. It calls
221
+ `createStim.warmWorktree` for plain copying; refresh and dependency installation
222
+ remain CLI-only. Only the proved connection of the Desktop that started this
223
+ server can call it. Phones, network clients and adopted local connections cannot.
224
+
225
+ Ordered `desktop-worktree-warm-progress` events end with
226
+ `desktop-worktree-warm-ended`, whose `stdout` contains `{ exitCode }` or a typed
227
+ refusal. The default request budget is 90 minutes, with an explicitly configured
228
+ server action deadline taking precedence. The API's 35-second cleanup budget and
229
+ the server's 1 MiB progress-plus-result bound apply. Admission covers the resolved
230
+ linked checkout, its registered descendants and the inferred app before effects.
231
+ Cancellation drains owned work before releasing admission; unverifiable cleanup
232
+ retains admission until its exact recovery claims are cleared. Settled requests
233
+ invalidate cached Desktop reports. Reconnect never repeats warming. Desktop
234
+ callers migrate separately.
235
+
236
+ The private `desktop-plan` feature provides `desktop.plan.get` with an absolute
237
+ existing `cwd`, `platform` (`ios`, `android` or `macos`) and an optional iOS/Android
238
+ `slot`. An optional `request` identifies that connection's check. It returns the shared `createStim.plan` report or a typed `{ code, message, remedy }` refusal without a CLI process,
239
+ building or launching. Only the proved Desktop connection can select folders;
240
+ phones and ordinary local connections cannot use it. The existing `build.plan`
241
+ method remains limited to registered iOS/Android workspaces. Both paths share
242
+ per-directory queues, connection request limits, output bounds and cancellation.
243
+ The planner has a 60-second execution and 35-second cleanup budget inside the
244
+ server's 150-second request deadline, which starts after queued work drains.
245
+ These bounded plans support EAS and Stim cache providers; legacy custom Expo
246
+ cache plugins are checked only by CLI plans.
247
+ Desktop preview consumers use this private read. `desktop.plan.cancel { request }` cancels only a check on the same proved connection and replies after its queued work and cleanup drain. A retained cleanup failure is included as `refusal` in the cancellation result; other connections cannot cancel it. Shared subscriptions stay connected.
248
+
249
+ The local-only `desktop-stop` feature provides `desktop.stop.subscribe` with an
250
+ absolute `cwd` and optional `slot` (`web` closes only the owned Chrome). It stops
251
+ that exact canonical workspace through the shared API, without searching for a
252
+ monorepo child or starting the CLI. The response gives a subscription; ordered
253
+ `desktop-stop-progress` events carry `stream` and `message` chunks, followed by
254
+ `desktop-stop-ended` with `exitCode`, CLI-compatible JSON `stdout` and a human
255
+ summary or failure `message`. Cumulative progress and final JSON have separate
256
+ output bounds, and the action duration limit applies. A terminal
257
+ timeout or output failure does not release workspace admission until actual
258
+ cleanup finishes. Public control actions share this admission. Unsubscribe or
259
+ disconnect cancels waiting work; teardown already underway finishes. Server
260
+ shutdown cancels and drains it, and the action audit records the result. This
261
+ method does not change phone or network permissions. Desktop stop consumers
262
+ still use the CLI until they adopt this feature.
263
+
264
+ The local-only `desktop-settings` feature provides `desktop.settings.get` with
265
+ `{ "cwd": "/absolute/folder" }`. It reads the same masked settings, layers,
266
+ environment overrides and source files as `stim settings --json`, including for
267
+ a selected folder that is not registered with Stim. Each call reads current
268
+ settings, without a CLI process or a shared report cache. The existing Desktop
269
+ bootstrap boundary, request cap, byte quota, cancellation and 60-second read limit
270
+ apply. Network clients retain `settings.get`'s registered-workspace restriction.
271
+
272
+ The local-only `desktop-reports` feature provides full doctor and GC dry-run
273
+ reports without starting a CLI process. `desktop.reports.get` takes
274
+ `{ "report": "gc" }` or `{ "report": "doctor", "cwd": "/absolute/project" }`.
275
+ Doctor accepts a selected folder before it is registered with Stim. The result is
276
+ `{ "pending": false, "value": { ... }, "measuredAt": "..." }`; a failed read
277
+ returns `stim-failed`. Doctor never repairs the project or records a CLI run, and
278
+ GC never deletes anything.
279
+
280
+ The local-only `desktop-report-platform` feature adds an optional `"platform": "ios"`
281
+ or `"platform": "android"` to doctor targets. Omitting it checks all selected platforms.
282
+ Reports for each cwd and selector have separate cache entries and subscriptions.
283
+ Clients requiring a filtered report check this feature before requesting one.
284
+
285
+ `desktop.reports.subscribe` takes the same target and returns a subscription id.
286
+ It pushes `desktop-report` events with that `subscription`, `target`, and a
287
+ `snapshot` containing `pending`, `value`, optional `error`, and `measuredAt` after
288
+ the first completed read. During refresh it retains the last settled value or
289
+ error. All connections share one in-flight read and a 60-second result per target.
290
+ Active subscriptions refresh after 60 seconds; no refresh timer runs without
291
+ subscribers. `unsubscribe` and disconnect remove the subscription. Cancelling a
292
+ get request does not cancel another reader's shared work.
293
+
294
+ After changing settings, running an action, or explicitly refreshing a view,
295
+ Desktop calls `desktop.reports.invalidate` without parameters. This invalidates
296
+ all report targets and refreshes active subscriptions. Results started before
297
+ invalidation cannot replace a newer result or satisfy a waiting get. The existing
298
+ request and subscription caps, report byte quota, API execution and cleanup
299
+ budgets, and 150-second server limit apply. At most four shared report probes run
300
+ at once across connections and targets. A new target above that limit returns a
301
+ busy error without starting or queuing work; invalidate and retry after a probe
302
+ finishes. Unsubscribing does not release a probe's slot before it drains.
303
+ Server shutdown stops refreshes and
304
+ cancels and drains the shared probes. These methods and events are excluded from
305
+ the phone protocol and unavailable over network connections.
306
+
307
+ `desktop.logs.subscribe` reads a registered workspace's complete retained logs
308
+ for the private local Desktop connection. It accepts `workspace` and optional
309
+ `sources`, `level`, `grep` and `errors`; Desktop applies scoped build time, platform
310
+ and legacy slot bounds. `desktop-full-logs` advertises it only on the local socket,
311
+ with the same private bootstrap admission as Desktop reports. Network and phone
312
+ clients keep the existing 5,000-record tail limit.
313
+
314
+ Full backfill selects timeline records in batches of at most 500 and waits for
315
+ the send queue before selecting more. Agent actions still use the existing
316
+ attribution reader and retain matching records during backfill. A disconnect,
317
+ unsubscribe or backfill deadline cancels the read and releases its open files.
318
+
319
+ ## Set up a worker machine
320
+
321
+ A person on the worker runs the command generated for their client machine.
322
+ The client machine makes the setup ticket, 32 random bytes: Stim Desktop's Add
323
+ Machine flow shows the command, and on a machine without Desktop,
324
+ `stim doctor --fix` prints it.
99
325
 
100
326
  ```bash
101
- stim-server setup --client <node-id> --ticket <43-base64url-characters> --expires <ISO-time> \
327
+ STIM_SETUP_TICKET=<43-base64url-characters> stim-server setup --expires <ISO-time> \
102
328
  --build --device-host [--port <n>] [--label <name>] \
103
329
  [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose]
104
330
  ```
105
331
 
332
+ The ticket travels in the `STIM_SETUP_TICKET` environment variable, because
333
+ other users on the worker can read a process's command-line arguments but not
334
+ its environment. Setup refuses `--ticket` and `--client`: the ticket alone
335
+ names the requesting machine. On Windows the generated command runs in a PowerShell script block that
336
+ sets `$env:STIM_SETUP_TICKET` and removes it when setup ends.
337
+
106
338
  If the server is not installed globally, use `npx --yes --package @stim-cli/server@<version> stim-server`
107
- in place of `stim-server`, with the exact version from the client Mac.
339
+ in place of `stim-server`, with the exact version from the client machine.
108
340
  At least one capability is required. The expiry must be in the future and at
109
341
  most two hours ahead. The defaults are port 7787 and label `dev.stim.server`.
110
342
  Argument and preflight refusals change nothing. Preflight requires macOS,
111
- Node 22.12+, running Tailscale, the client node on this tailnet, and this user's
112
- GUI login session.
113
-
114
- Running setup on the worker is the worker-side approval. It pre-approves at
115
- most one request per chosen capability, from this node, carrying this ticket,
116
- until this expiry. A terminal asks Y/n for each grant (default Yes: Enter, `y` and `yes` approve;
343
+ Linux or Windows, Node 22.12+, running Tailscale, and
344
+ this user's GUI login session on macOS or systemd user manager on Linux. On
345
+ Linux and Windows, setup takes `--build` only and refuses `--device-host`, because hosting
346
+ needs a Mac; see [On Linux](#on-linux) and [On Windows](#on-windows). Off macOS, stim-server also refuses a
347
+ device-host connection at hello.
348
+
349
+ Running setup on the worker is the worker-side approval. It approves the
350
+ client key that holds this ticket, once per chosen capability, until this
351
+ expiry. A terminal asks Y/n for each grant (default Yes: Enter, `y` and `yes` approve;
117
352
  `n`, `no` and any other answer decline); use `--yes` to approve without those
118
353
  questions, including without a terminal. Without
119
354
  a terminal or `--yes`, setup refuses before installing anything unless every
120
355
  chosen capability already has a matching approval. A person on
121
- the worker still approves; an SSH-driven run is not offered. Build and
122
- device-host requests are separate grants. A build grant carries no `read` or
356
+ the worker still approves; an SSH-driven run is not offered. Only after the
357
+ person approves does setup store the ticket's derived PSK and lookup in
358
+ `$STIM_HOME/server/secrets.json`. Until then the requesting machine's handshake
359
+ gets `unknown-secret`, and its `stim doctor` reports the machine as pending and
360
+ tries again on every run. The requester's next first-contact handshake with the
361
+ ticket records its public key once per approved capability, which spends the
362
+ ticket. From then on the ticket admits only that key, until it expires, so a
363
+ requester that lost the reply can retry; setup deletes an unspent secret when it
364
+ ends. Build and
365
+ device-host approvals are separate records. A build grant carries no `read` or
123
366
  `control` capability, but it is full access to this account in practice (see
124
367
  [Build access](#build-access)).
125
- A rerun before expiry reports matching approved requests as already approved
126
- and never grants a second request. After expiry it refuses with exit 2 before
127
- any change. Requests themselves lapse after 15 minutes.
368
+ A rerun before expiry reports capabilities the ticket already recorded as
369
+ already approved. After expiry it refuses with exit 2 before any change.
128
370
 
129
- Setup installs its exact server release into
130
- `~/Library/Application Support/Stim/services/<label>/versions/`, installs
131
- Stim Host, installs or reuses the managed LaunchAgent, and creates or reuses a
371
+ Setup installs its exact server release into the service's `versions/`
372
+ directory (`~/Library/Application Support/Stim/services/<label>/versions/` on
373
+ macOS), installs Stim Host on macOS, installs or reuses the managed
374
+ LaunchAgent, systemd user service or scheduled task, and creates or reuses a
132
375
  tailnet-only HTTPS route on 7443 or the next free port. Older managed services
133
376
  use the service update busy-wait and rollback behavior. Newer versions stay
134
- installed; tools report a Stim build difference. A reused server must be at
135
- least **1.16.0** (ignoring its prerelease suffix for this floor), the first version with setup ticket storage and the journal
136
- route. An older app server refuses immediately with an update remedy.
377
+ installed; tools report a Stim build difference. A reused server must
378
+ speak protocol 2 (stim-server **1.21.0** or later), which setup tickets
379
+ authenticate with. An older server refuses immediately with an update remedy.
137
380
 
138
381
  When a Desktop-run server already answers on the port, setup reuses it and
139
382
  installs no LaunchAgent. It requires an existing tailnet route and refuses
@@ -168,7 +411,7 @@ earlier releases did. Plain progress goes to stdout; errors use
168
411
  `stim-server: <message>` on stderr (when stdout is a terminal, the refusal appears on stdout only).
169
412
  `--json` sends the long (verbose) progress to stderr, without the banner, and prints one final payload with `ok`,
170
413
  `label`, `port`, `route` (state, DNS name, HTTPS port), `server` (version and
171
- Stim build), `managed`, separate `granted` request ids and client identities,
414
+ Stim build), `managed`, separate `granted` record ids and client names,
172
415
  `permissions`, `tools` and `warnings`. Final permission values are `granted`,
173
416
  `denied` (not authorized), `skipped` or `not-needed`; a timed-out wait remains
174
417
  `pending` in the journal and names its unavailable feature. Exit codes come
@@ -181,13 +424,13 @@ from the final journal:
181
424
  | 2 | No grant before expiry, or an already expired command. |
182
425
  | 3 | At least one grant, but another chosen capability, permission or tool is missing; the output names affected features. |
183
426
 
184
- Setup writes `$STIM_HOME/server/setup/<sha256(ticket)>.json` from its first
427
+ Setup writes `$STIM_HOME/server/setup/<journal>.json` from its first
185
428
  step and after every change, including completion and the exit code. The
186
- node-bound `GET /setup/<hash>` route described above mirrors it until expiry,
429
+ `GET /setup/<journal>` route described above mirrors it until expiry,
187
430
  hiding tools until a build grant. Each valid setup run prunes journals more
188
431
  than one hour past expiry. A dedicated `server/setup.claims` ownership claim
189
432
  serializes runs; the service update claim is released before waiting for
190
- requests and permissions. A competing setup refuses with the exact claim
433
+ the requesting machine and permissions. A competing setup refuses with the exact claim
191
434
  and removal command. Ctrl-C or SIGTERM finishes the running step as failed
192
435
  with detail `interrupted`, completes the journal, releases the setup claim,
193
436
  and exits 1. A typed N also exits 1; a question timeout follows ticket expiry
@@ -196,8 +439,8 @@ and exits 2.
196
439
  The `To undo` line (or the `--verbose` summary) lists the undo commands:
197
440
 
198
441
  ```bash
199
- stim-server devices revoke <build-request-id>
200
- stim-server devices revoke <device-host-request-id>
442
+ stim-server devices revoke <build-client-id>
443
+ stim-server devices revoke <device-host-client-id>
201
444
  stim-server service uninstall --label <label>
202
445
  ```
203
446
 
@@ -211,12 +454,13 @@ permissions remain; remove permission grants in System Settings if desired.
211
454
 
212
455
  ## Tailscale
213
456
 
214
- Tailscale carries and encrypts the traffic between the phone and the Mac, on
215
- the same Wi-Fi or across networks, and identifies each device. Stim adds no
216
- cryptography of its own. You need Tailscale on the Mac and on the phone, in the
457
+ Tailscale carries the traffic between the phone and the Mac, on the same
458
+ Wi-Fi or across networks. Stim authenticates and encrypts every network
459
+ connection itself with a Noise handshake (see [Pairing](#pairing)), so
460
+ Tailscale's identity of a peer only labels it. You need Tailscale on the Mac and on the phone, in the
217
461
  same tailnet or with the Mac shared to the phone's user.
218
462
 
219
- `stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
463
+ `stim-server` listens only on `127.0.0.1` and on the machine's Tailscale
220
464
  addresses, never on every interface. It re-reads the Tailscale state in the
221
465
  background, so a server that started before Tailscale was up, or while it did
222
466
  not answer, starts listening on the Tailscale addresses once it runs, and stops
@@ -247,10 +491,10 @@ Funnel port makes it public, and `pair` refuses.
247
491
  When Tailscale is not running, `stim-server` listens on loopback only and
248
492
  says so on stderr. Start Tailscale, then restart `stim-server`.
249
493
 
250
- Desktop uses `route.setup` with no parameters on its authenticated loopback
251
- control connection. The result is a verified `ServeRoute`; an existing route is a
252
- no-op. The server refuses forwarded, browser-origin or tailnet connections,
253
- read-only clients, unknown configuration and any route exposing this server
494
+ Desktop uses `route.setup` with no parameters on its connection to the local
495
+ Unix socket (see [Pairing](#pairing)). The result is a verified
496
+ `ServeRoute`; an existing route is a no-op. The server refuses it on any network
497
+ connection, unknown configuration and any route exposing this server
254
498
  through Funnel. Only the fixed `tailscale serve --bg --https=<free port>
255
499
  http://127.0.0.1:<server port>` invocation runs, followed by route verification.
256
500
  No LaunchAgent or Funnel configuration is created. Failure output remains
@@ -261,35 +505,72 @@ available for an explicit retry, including Tailscale's HTTPS approval link.
261
505
  `stim-server pair` prints the JSON that the pairing QR code encodes:
262
506
 
263
507
  ```json
264
- { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }
508
+ {
509
+ "v": 2,
510
+ "name": "Janic's MacBook Pro",
511
+ "endpoint": "wss://janics-mbp.tail1234.ts.net:7443",
512
+ "pairingToken": "...",
513
+ "fingerprint": "..."
514
+ }
265
515
  ```
266
516
 
267
- The endpoint names the route's port, and omits it for 443. When a route to
268
- the server is on a port with Funnel on, `pair` refuses and exits 1 without
269
- creating a token.
270
-
271
- The pairing token works once and expires after 5 minutes. A client spends it in
272
- `hello` and receives a random device token, which it presents on every later
273
- connection. The server stores only the SHA-256 hash of each token.
274
-
275
- At pairing, the server records the peer's tailnet node and user from
276
- `tailscale whois`. A device token presented from any other node is refused, so
277
- a leaked token alone is not enough. A device paired over loopback, from this
278
- Mac, is accepted only over loopback. `tailscale serve` connects from loopback
279
- and names the peer in `X-Forwarded-For`, which the server trusts only on
280
- loopback connections. Forward only `tailscale serve` in HTTP mode to the
281
- loopback port: a forwarder that omits that header, such as `tailscale serve
282
- --tcp`, `ssh -L`, or a tunnel, makes every remote peer look like this Mac.
283
-
284
- A connection must send `hello` within 5 seconds. Five failed attempts from the
285
- same peer within a minute block new connections from it for up to a minute.
517
+ The endpoint names the route's port, and omits it for 443. `fingerprint` is
518
+ base64url, without padding, of the SHA-256 of the server's public key. When a
519
+ route to the server is on a port with Funnel on, `pair` refuses and exits 1
520
+ without creating a token.
521
+
522
+ Every network connection, loopback TCP included, runs a Noise handshake before
523
+ any request, as the
524
+ [design](../../docs/specs/2026-10-13-pluggable-transports-design.md#authentication)
525
+ specifies. The server has one long-term X25519 key in
526
+ `$STIM_HOME/server/identity.key`, and each client has its own. There are no
527
+ device tokens.
528
+
529
+ - **First contact (`XXpsk2`).** The pairing token is 32 random bytes, pairs
530
+ one client key and expires after 5 minutes. The client proves it holds the token in the
531
+ handshake, then pins the server's key; the phone also checks the key against
532
+ the QR code's `fingerprint`. The server records the client's public key in
533
+ `devices.json` with the capabilities stored with the token, and spends the
534
+ token: until it expires it admits only that key again, so a client that lost
535
+ the reply can retry. The server keeps only the token's derived PSK and lookup in
536
+ `$STIM_HOME/server/secrets.json`.
537
+ - **Returning client (`XX`).** The server accepts the client's key only if a
538
+ record has it, and the client accepts only the server key it pinned.
539
+
540
+ A refusal during the handshake closes the WebSocket with code 4401 and the
541
+ reason `unknown-secret` (no valid token or ticket matches), `unauthorized` (no
542
+ record has the key, or it was revoked) or `handshake-failed`. A client that
543
+ gets another server key than it pinned closes with `server-key-mismatch`. A
544
+ protocol 1 client gets `protocol-unsupported` in cleartext with an "update,
545
+ then pair again" message. Pairings, approvals and credentials from protocol 1
546
+ are not carried over: phones pair again, and remote machines are approved
547
+ again with a new setup ticket.
548
+
549
+ Stim Desktop and other clients of the server's own user on the same machine do
550
+ not pair. They connect to the Unix socket `$STIM_HOME/server/local.sock`, mode
551
+ 0600 in a 0700 directory, which serves only this user and needs no handshake.
552
+ That connection gets read and control. The Desktop-only methods (`route.setup`,
553
+ `machines.update.*`, `device-host.sessions` and `device-host.sessions.stop`)
554
+ work only there; on a server Desktop started, only after the private Desktop
555
+ bootstrap proof (see [Private Desktop bootstrap](#private-desktop-bootstrap)).
556
+ Windows has no socket.
557
+
558
+ The network address, `tailscale whois` and the `X-Forwarded-For` header that
559
+ `tailscale serve` adds never authenticate. They only label a record (`from` in
560
+ `stim-server devices`) and key the rate limits below.
561
+
562
+ A connection must finish the handshake and send `hello` within 5 seconds of
563
+ each step. Five failed attempts from the same peer address within a minute
564
+ block new connections from it for up to a minute. An `unknown-secret` refusal
565
+ is not a failed attempt: a requesting machine retries with its setup ticket
566
+ until setup on this machine stores it.
286
567
  The server answers 403 to a WebSocket upgrade that carries `Sec-Fetch-Site`, or
287
568
  an `Origin` other than the requested host (`http:` on loopback, `https:`
288
569
  through the route), and counts no failed attempt, so a web page can neither
289
- connect nor block this Mac's clients.
570
+ connect nor block this machine's clients.
290
571
 
291
572
  Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
292
- closes its open connections. The server checks registrations on file changes
573
+ deletes its record and closes its open connections. The server checks registrations on file changes
293
574
  and once a second, so a missed filesystem notification cannot leave a revoked
294
575
  connection authorized.
295
576
 
@@ -300,7 +581,7 @@ A paired device has the `read` capability, which serves state, or also
300
581
  With the `workspace-diff` feature, `read` also serves changed and untracked
301
582
  text file contents in registered workspaces, including any non-ignored
302
583
  untracked text file such as an unignored `.env`. Pairing grants `read` only,
303
- unless the pairing code came from `stim-server pair --control`. On the Mac,
584
+ unless the pairing code came from `stim-server pair --control`. On the machine,
304
585
  `stim-server devices grant <id> --control` adds control to a paired device and
305
586
  `--read` takes it away. Nothing a client sends changes its own capabilities.
306
587
  The server checks the device's capabilities in `devices.json` on every action
@@ -310,17 +591,45 @@ registration check. `hello` reports
310
591
  the capabilities and actions of the connection's device when it connects; a
311
592
  connection sees a new grant after it reconnects. Under Stim Host, an approved
312
593
  `hello` also carries the same `host` grants as `/health`, which `stim doctor`
313
- reports for remote Macs.
594
+ reports for remote machines.
314
595
 
315
596
  Every method other than `hello` needs `read`, except the methods with their own
316
597
  approval: `build.*` needs `build`, the `device-host.*` methods that run a hosted
317
598
  session need `device-host`, and `server.update.*` needs the build or device-host
318
- approval of the requesting Mac (see [Remote update](#remote-update)). A device
599
+ approval of the requesting machine (see [Remote update](#remote-update)). A device
319
600
  with only `build` gets `forbidden` for every other method.
320
601
 
602
+ ## Pause incoming work
603
+
604
+ On the worker machine, pause new builds and hosted sessions independently:
605
+
606
+ ```bash
607
+ stim settings set server.buildsPaused true
608
+ stim settings set server.devicesPaused true
609
+ ```
610
+
611
+ The running server reads these machine settings without a restart. A pause
612
+ rejects new work even when a client received an offer before the setting changed.
613
+ Running builds and existing hosted sessions continue; clients can still attach,
614
+ fetch completed artifacts, and stop their sessions. Hosted sessions include iOS,
615
+ Android and macOS. Client approvals are preserved, and local CLI builds and devices
616
+ remain available. These worker settings apply to every requesting machine, including
617
+ explicitly selected workers; Desktop's pool switches only affect that requester's
618
+ automatic placement.
619
+
620
+ Resume either capability when the machine is available again:
621
+
622
+ ```bash
623
+ stim settings set server.buildsPaused false
624
+ stim settings set server.devicesPaused false
625
+ ```
626
+
627
+ Both settings default to `false`. Pausing does not make the machine idle: let
628
+ existing work finish or stop the relevant sessions before timing benchmarks.
629
+
321
630
  ## Build access
322
631
 
323
- `build` lets another Mac on the tailnet run its project's code on this Mac to
632
+ `build` lets another machine on the tailnet run its project's code on this machine to
324
633
  build for it: config plugins, CocoaPods hooks, Xcode script phases and Gradle
325
634
  plugins run as the user `stim-server` runs as. The offload worker runs with that
326
635
  user's real home directory and no sandbox, so a build grant is equivalent to
@@ -331,40 +640,27 @@ real home directory still reaches) to grant itself either, read other build
331
640
  clients' checkouts and blobs, and persist through LaunchAgents or shell startup
332
641
  files. `devices grant` never turns a paired device into a build client, and the
333
642
  protocol does not list `read` or `control` among a build client's capabilities,
334
- but do not rely on that as a boundary. Grant it only to Macs you trust with
643
+ but do not rely on that as a boundary. Grant it only to machines you trust with
335
644
  that. Revoking the grant stops future builds and kills the running job's
336
- process group. It does not undo anything project code already did. A loopback
337
- connection from this Mac cannot get it: it has no tailnet node to bind the
338
- token to. A connection from this Mac to its own tailnet address or `*.ts.net`
339
- name does carry the Mac's own tailnet identity and can request `build`.
340
- Because the server trusts `X-Forwarded-For` on loopback (see
341
- [Pairing](#pairing)), a process on this Mac can still claim a tailnet peer's
342
- address. Run `stim-server` as a user no one else can run processes as when
343
- other people use this Mac, and approve a request only when you expect it.
344
-
345
- A Mac gets `build` only by asking and being approved on this Mac. The client
346
- sends `hello` with `auth` set to `{ "request": "build", "deviceName" }`, a
347
- name of at most 64 UTF-16 code units with no control or format characters.
348
- The server records a pending build client bound to the peer's node, answers
349
- with a `deviceToken`, no capabilities and
350
- `approval: { "state": "pending", "expiresAt" }`, and closes the connection.
351
- `stim-server devices` lists it as `pending build`. On this Mac,
352
- `stim-server devices grant <id> --build` approves it, or a person runs
353
- [`stim-server setup`](#set-up-a-worker-mac) on this Mac with its node and ticket, and
354
- `stim-server devices revoke <id>` denies it; Stim Desktop's **Allow** and
355
- **Deny** run those commands. Until then, `hello` with its token
356
- fails with `approval-pending`, which does not count as a failed attempt, while
357
- the request itself does, so a peer cannot send more requests than failed
358
- attempts. A request lapses after 15 minutes. Each node has at most one pending
359
- request, the newest, and the server holds at most 8; more fail with
360
- `limit-exceeded`. There is no pairing code for `build`: `stim-server pair`
361
- never grants it.
362
-
363
- `devices grant` never gives `build` to a paired device, or `read` or
364
- `control` to a build client. Build clients live in
365
- `$STIM_HOME/server/build-clients.json`, apart from `devices.json`, so a
366
- `stim-server` release without `build` never reads them and refuses their
367
- tokens.
645
+ process group. It does not undo anything project code already did. Approve a
646
+ machine only when you expect it.
647
+
648
+ A machine gets `build` only through
649
+ [`stim-server setup`](#set-up-a-worker-machine) on this machine. The requesting
650
+ machine makes a setup ticket and connects with it in a first-contact handshake,
651
+ naming itself in the handshake (at most 64 characters). Until a person runs
652
+ setup with that ticket and approves, the handshake fails with `unknown-secret`
653
+ and the requester retries. Once approved, the next handshake records the
654
+ requester's public key as a build client, and the requester pins this server's
655
+ key in its `$STIM_HOME/build-machines.json`. Later connections send `hello`
656
+ with `access: "build"` and authenticate by that key alone.
657
+ `stim-server devices revoke <id>` deletes the record and closes its
658
+ connections. There is no pairing code for `build`: `stim-server pair` never
659
+ grants it.
660
+
661
+ `devices grant` takes only `--control` or `--read`: it never gives `build` to a
662
+ paired device, or `read` or `control` to a build client. Build clients live in
663
+ `$STIM_HOME/server/build-clients.json`, apart from `devices.json`.
368
664
 
369
665
  ## Device-host approval
370
666
 
@@ -372,37 +668,30 @@ Device hosting has a separate `device-host` capability. An approved client can
372
668
  reserve, boot, reconnect to and stop its own iOS simulator or Android emulator
373
669
  through the protocol. It can deliver, install and launch a compatible iOS app
374
670
  bundle, Android APK or prebuilt macOS app, stream the iOS simulator, Android emulator or macOS app and control it. Hosted
375
- iOS and Android apps connect back to Metro on the client Mac. Automatic placement remains in [#2266](https://github.com/appandflow/stim/issues/2266).
671
+ iOS and Android apps connect back to Metro on the client machine. Automatic placement remains in [#2266](https://github.com/appandflow/stim/issues/2266).
376
672
 
377
673
  The Stim client can name expected hosts with
378
674
  `stim settings set remote.machines '["<mac>"]'` and request access with
379
- `stim doctor --fix`. It stores a separate private credential in
380
- `$STIM_HOME/device-host-machines.json`, pins the worker's tailnet node, and
381
- reports approval under `deviceHosts` in doctor JSON. Plain doctor makes no
675
+ `stim doctor --fix`, which makes a setup ticket and prints the setup command.
676
+ Once a person runs it on the worker, doctor stores a separate private credential
677
+ in `$STIM_HOME/device-host-machines.json` that pins the worker's stim-server key,
678
+ and reports approval under `deviceHosts` in doctor JSON. Plain doctor makes no
382
679
  approval request. Use `stim ios --remote <machine>` or `stim android --remote <machine>` for explicit placement.
383
680
 
384
- A client on the tailnet sends `hello` with
385
- `auth: { "request": "device-host", "deviceName": "Laptop" }`. As with a build
386
- request, the server returns a token and pending approval, then closes the
387
- connection. It binds the token to the peer's tailnet node. Requests expire
388
- after 15 minutes; each node keeps only its newest pending request of this kind,
389
- and at most eight device-host requests can be pending. Build requests have a
390
- separate limit. The same name validation and failed-attempt limit apply.
391
-
392
- On the hosting Mac, inspect `stim-server devices`, then approve the matching
393
- request with `stim-server devices grant <id> --device-host`, run
394
- [`stim-server setup`](#set-up-a-worker-mac) there with its node and ticket, or use **Allow**
395
- in Stim Desktop. Approve only an expected request: it authorizes that Mac to
396
- reserve session-owned simulators or emulators and run native app code. **Deny**
397
- or `stim-server devices revoke <id>` removes it; revocation also closes its
398
- open authenticated connections. The local-process trust boundary described in
399
- [Build access](#build-access) applies here too.
681
+ As with build, the client spends its setup ticket in a first-contact
682
+ handshake and gets `unknown-secret` until a person on the hosting Mac runs
683
+ [`stim-server setup`](#set-up-a-worker-machine) with `--device-host` and that
684
+ ticket. One ticket can approve build and device hosting together, as one record
685
+ for each. Later connections send `hello` with `access: "device-host"`. Approve
686
+ only an expected machine: it can reserve session-owned simulators or emulators
687
+ and run native app code. `stim-server devices revoke <id>` removes it and also
688
+ closes its open authenticated connections.
400
689
 
401
690
  Hosting clients live in `$STIM_HOME/server/device-host-clients.json`, separate
402
- from phone pairings and build clients. A hosting token grants no `read`,
403
- `control` or `build` access, including access to unrelated workspaces. Existing
404
- read, control and build tokens cannot gain hosting through `devices grant`.
405
- Loopback requests and `pair --device-host` are refused.
691
+ from phone pairings and build clients. A hosting record grants no `read`,
692
+ `control` or `build` access, including access to unrelated workspaces. Read,
693
+ control and build records cannot gain hosting through `devices grant`, and
694
+ `pair` never grants it.
406
695
 
407
696
  ### Hosted here
408
697
 
@@ -422,9 +711,9 @@ Each row has `id`, `client: { id, name }`, `platform`, nullable `device` and `ap
422
711
  session through the existing stop path, closing Metro, view, agent and logs
423
712
  before owned-device teardown. Repeating a stopped session returns `stopped`;
424
713
  an absent id gets `unknown-session`.
425
- Both requests require an authenticated loopback Desktop connection with local
426
- identity and control access. Local read-only connections, paired phones and
427
- remote hosting clients get `forbidden`. A session whose active owner is not
714
+ Both requests work only on the local Unix socket that Stim Desktop uses (see
715
+ [Pairing](#pairing)). Every network connection, paired phones and remote
716
+ hosting clients included, gets `forbidden`. A session whose active owner is not
428
717
  attached to this server reads as `unknown`; stop reconciles it through the same
429
718
  ownership checks. The approved-client `device-host.stop` remains limited to
430
719
  that client's own sessions.
@@ -465,7 +754,12 @@ This protocol does not select a host for `stim ios` or `stim android`.
465
754
 
466
755
  On the hosting Mac, `hosting.agentDriver` names the tool that lets a client's
467
756
  coding agent drive the macOS app hosted for that client. `none` (the default)
468
- starts nothing. `stim-server` reference-counts the driver's daemon on running
757
+ starts nothing. Each tool is a driver package implementing
758
+ [`@stim-cli/agent-driver`](../agent-driver/README.md) that stim-server depends
759
+ on; today that is `agent-device`, from `@stim-cli/agent-driver-agent-device`. A
760
+ named driver that fails to load leaves the app with `{ "driver": "none" }` and
761
+ a generic notice; the reason, which can name paths on the hosting Mac, goes to
762
+ stim-server's stderr. `stim-server` reference-counts the driver's daemon on running
469
763
  hosted macOS apps: it starts with the first, stops after the last, on
470
764
  revocation and on server shutdown, and restarts if it exits while apps run. The
471
765
  daemon is held under an ownership claim at `server/agent-device.claims` whose
@@ -481,10 +775,13 @@ ANY /device-host/agent/<session>/<driver path>
481
775
 
482
776
  on the tailnet `serve` route is forwarded to the loopback daemon, with the
483
777
  daemon's own token, only when all of these hold: the request carries that
484
- session's token as a bearer token, it comes from the tailnet node pinned to the
485
- session's approved device-host client, the client still holds `device-host`, and
486
- the session's app is running. Loopback requests, browser requests and other
487
- sessions' tokens are refused with 403, an unknown or stopped session with 404.
778
+ session's token as a bearer token, the session's device-host client still
779
+ holds `device-host`, the session's app is running, and the request comes from the
780
+ tailnet node that client's latest authenticated connection came from. The token is
781
+ the credential; the node check only stops a machine that took over the host's
782
+ MagicDNS name from replaying it, and a request whose node no transport identifies
783
+ is refused. Browser requests and
784
+ other sessions' tokens are refused with 403, an unknown or stopped session with 404.
488
785
 
489
786
  The `agent-device` adapter runs the binary that `STIM_AGENT_DEVICE_BIN` names
490
787
  in `stim-server`'s environment, or else the first of
@@ -636,13 +933,13 @@ session before starting another transfer. Manifest, file and partial blobs are s
636
933
  by attempts of one session in its content-addressed store; offers recheck complete
637
934
  blob digests and request only missing bytes.
638
935
 
639
- An iOS simulator, Android emulator or macOS session can take its content from a build this Mac ran instead: after
936
+ An iOS simulator, Android emulator or macOS session can take its content from a build this machine ran instead: after
640
937
  the manifest arrives, send `device-host.app.handoff` with
641
938
  `{session, attempt, build: {handoff, sha256}}`, where `handoff` is the token
642
939
  and `sha256` the archive digest from that job's `build.artifact` answer. The
643
940
  server hands the build over only when the build client that ran it still has
644
941
  `build`, the caller has `device-host`, and both approvals belong to the same
645
- tailnet node. It copies only regular files, and links where the manifest
942
+ client key. It copies only regular files, and links where the manifest
646
943
  declares links, whose directory resolves inside the staged bundle and whose
647
944
  bytes match their manifest digest; it answers `{files, bytes}` for what it
648
945
  took. The token is spent once the handoff starts. Re-offer and upload whatever
@@ -693,10 +990,10 @@ retain the same reconciliation rules as iOS.
693
990
 
694
991
  With `hosting.agentDriver: agent-device`, each installed Android session gets
695
992
  its own daemon and child-aware claim under `device-host/sessions/<session>/agent-device.claims`.
696
- Both Macs need agent-device 0.21.22 or later. The host verifies the serial policy
993
+ Both machines need agent-device 0.21.22 or later. The host verifies the serial policy
697
994
  digest and `android-instance` backend and advertises `hosted-android-agent`.
698
995
  `device-host.app.attach` and session attach carry a session grant with
699
- `android:mobile:<serial>` and the same token and client-node checks as iOS.
996
+ `android:mobile:<serial>` and the same token checks as iOS.
700
997
  The client writes a 0600 remote config under `hosted-android/<slot>/` and reports
701
998
  it as `android.host.agent`. Use `agent-device <command> --remote-config <file>`.
702
999
  Missing or unsupported agent-device reports `driver: none` with its reason.
@@ -873,8 +1170,9 @@ Automatic native macOS placement remains separate work.
873
1170
  ### Hosted viewer relay
874
1171
 
875
1172
  The client's stim-server relays a hosted workspace's iOS simulator, Android emulator or macOS view and input to
876
- its host using the client's approved device-host credential over the pinned
877
- tailnet connection. Stim Desktop and phones keep talking only to their own
1173
+ its host using the client's approved device-host credential, over a connection
1174
+ that goes ahead only with the host that proves the server key pinned at
1175
+ approval. Stim Desktop and phones keep talking only to their own
878
1176
  server. The `macos-hosted`, `ios-hosted` and `android-hosted` features advertise these relays.
879
1177
  iOS resolves `ios.host` and Android resolves `android.host` by the requested slot; macOS retains its default slot.
880
1178
  The local platform and slot replace the host's private target fields in events
@@ -884,7 +1182,7 @@ The relay forwards iOS device artwork and orientation metadata. Hosted targets
884
1182
  refuse replay (`at`/`rate`), `duoFrame` and `physical` with `bad-request`.
885
1183
  All of the client's relayed subscriptions and control sessions for one host share one connection,
886
1184
  whichever local client opened them. Each request still checks the credential
887
- and the pinned node; a changed credential or endpoint opens a new connection.
1185
+ and the pinned key; a changed credential or endpoint opens a new connection.
888
1186
  Ending a subscription or session sends `device-host.unsubscribe` or
889
1187
  `device-host.control.end`, and the connection closes when the last one ends.
890
1188
  The host's per-connection limits, such as its 32 subscriptions and input
@@ -978,18 +1276,26 @@ before replacement. Automatic placement remains in
978
1276
  ## Run as a service
979
1277
 
980
1278
  `stim-server service install` runs stim-server as a per-user LaunchAgent on
981
- macOS, so a remote Mac or a phone-serving Mac keeps it running without a
982
- terminal:
1279
+ macOS, a systemd user service on Linux ([On Linux](#on-linux)), or a scheduled
1280
+ task on Windows ([On Windows](#on-windows)), so a remote
1281
+ machine or a phone-serving Mac keeps it running without a terminal. Other
1282
+ systems refuse; start stim-server from your own service manager there:
983
1283
 
984
1284
  ```bash
985
1285
  stim-server service install [--port <n>] [--label <name>] [--serve]
986
1286
  [--env KEY=VALUE]... [--path-prepend <dir>]...
987
1287
  stim-server service status [--label <name>] [--json]
988
- stim-server service update [--label <name>] --release <version>|--from <dir>
1288
+ stim-server service update [--label <name>] [--no-wait] --release <version>|--from <dir>
989
1289
  stim-server service rollback [--label <name>]
990
1290
  stim-server service uninstall [--label <name>]
991
1291
  ```
992
1292
 
1293
+ Updates wait for offloaded builds and hosted sessions to finish by default.
1294
+ Use `--no-wait` to restart with work still active: shutdown interrupts those
1295
+ builds and sessions. Service shutdown, health checks and rollback still apply.
1296
+ `stim-server setup --no-wait` uses the same policy when it updates a managed
1297
+ service; if setup requires a separate service update, its remedy includes the flag.
1298
+
993
1299
  `install` writes `~/Library/LaunchAgents/<label>.plist` (label `dev.stim.server`
994
1300
  by default) and starts it with `launchctl bootstrap gui/<uid>`. The job starts
995
1301
  Stim Host, which stays alive as the parent of the absolute `node` and
@@ -1066,10 +1372,9 @@ foreground `tailscale serve` session counts as present. To move a service that
1066
1372
  created a route to another `--port`, run `uninstall` first.
1067
1373
 
1068
1374
  `--env KEY=VALUE` and `--path-prepend <dir>` pin variables and PATH entries for
1069
- the server. stim-server replaces its environment with the login shell's at
1070
- start, so a `PATH` or `GEM_HOME` in the plist alone is lost and an entry in
1071
- `~/.zshrc` changes the terminal too. These flags apply after that capture, to
1072
- the server and to the builds it starts. The plist stores each `--env` value in
1375
+ the server. A LaunchAgent gets no shell profile, so these are how a service sees
1376
+ a variable or tool that toolchain discovery does not find. They apply after
1377
+ discovery, to the server and to the builds it starts. The plist stores each `--env` value in
1073
1378
  `EnvironmentVariables`, so it stays out of the server's process arguments, and
1074
1379
  passes only the name as `--env KEY`; the server reads that variable from the
1075
1380
  environment launchd gives it. The values sit in plain text in the plist. A
@@ -1099,14 +1404,16 @@ the job to it. `--release <version>` takes an exact version from the public npm
1099
1404
  registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and
1100
1405
  npm checks each package against the registry's integrity. `--from <dir>`
1101
1406
  installs the `.tgz` packages in that directory, such as `pnpm pack` output of
1102
- the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and
1103
- `@stim-cli/server` packages of a checkout, and records each file's sha256.
1407
+ the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim`,
1408
+ `@stim-cli/transport`, `@stim-cli/transport-tailscale` and `@stim-cli/server`
1409
+ packages of a checkout, and records each file's sha256.
1104
1410
  Either way, `npm audit signatures` must verify the registry signature of every
1105
1411
  package that came from the registry, and the provenance attestation of each one
1106
1412
  that has it, or the update stops before it touches the job. Install scripts
1107
1413
  never run. Each install goes to
1108
1414
  `~/Library/Application Support/Stim/services/<label>/versions/`, in a directory
1109
- named by its version, Stim build and server code, and must start with the job's
1415
+ named by its version, Stim build and server code, plus the package files for
1416
+ `--from`, and must start with the job's
1110
1417
  `node` before the switch. `update` then waits up to 30 minutes for `/health` to
1111
1418
  report no offloaded build and no hosted session (a server older than `busy` in
1112
1419
  `/health` restarts without waiting), rewrites only the server script in the
@@ -1125,24 +1432,116 @@ and uninstall of one label run one at a time.
1125
1432
  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
1126
1433
  second service beside the first, for example with another `STIM_HOME`.
1127
1434
 
1128
- To set up a remote Mac: install Stim and stim-server on the Mac, run
1435
+ To set up a remote machine: install Stim and stim-server on it, run
1129
1436
  `stim-server service install --serve` (with the pins its toolchain needs), set
1130
1437
  `server.workerRoot` there, and on each client run
1131
- `stim settings set remote.machines '["<mac>"]'` and `stim doctor --fix`.
1132
- Approve the request on the remote Mac with
1133
- `stim-server devices grant <id> --build`.
1438
+ `stim settings set remote.machines '["<machine>"]'` and `stim doctor --fix`.
1439
+ A person on the remote machine approves it by running the `stim-server setup`
1440
+ command doctor printed (see [Set up a worker machine](#set-up-a-worker-machine)).
1441
+
1442
+ ### On Linux
1443
+
1444
+ On Linux, `service` manages a systemd user unit with the same commands, flags
1445
+ and checks. A Linux machine takes offloaded Android builds only, so it never
1446
+ installs Stim Host and has no permission grants.
1447
+
1448
+ - `install` writes `~/.config/systemd/user/<label>.service`, runs
1449
+ `systemctl --user daemon-reload`, `enable` and `start`, and logs to
1450
+ `~/.local/state/stim/<label>.log`. The unit runs the absolute `node` and `stim-server.mjs` of the
1451
+ install that ran the command, with `Restart=always` after 30 seconds,
1452
+ `STIM_HOME` and `SHELL` from the installing shell, each `--env` value in an
1453
+ `Environment=` line, and `STIM_SERVICE_LABEL=<label>`, which tells the
1454
+ server its label. The `[X-Stim]` section, which systemd ignores, marks the
1455
+ unit as one `install` wrote and records the serve route and the previous
1456
+ server script. The unit sits in plain text, values included.
1457
+ - `update` and `rollback` keep versions under
1458
+ `~/.local/share/stim/services/<label>/versions/`, whatever the XDG
1459
+ variables say, so every caller finds the same versions; they rewrite only
1460
+ `ExecStart=` and the previous script, run `daemon-reload` and restart the
1461
+ unit, and switch back when the new server does not answer.
1462
+ - `uninstall` stops and disables the unit, removes it and runs
1463
+ `daemon-reload`.
1464
+ - The unit stops when its user manager does, which by default is when you log
1465
+ out. Turn on linger once so it starts at boot and keeps running:
1466
+ `sudo loginctl enable-linger $USER`. `install` prints that command when
1467
+ linger is off, and `status` reports it; Stim never runs sudo.
1468
+ - `systemctl --user` needs your user manager. An SSH or desktop login has one;
1469
+ without a session, Stim falls back to `XDG_RUNTIME_DIR=/run/user/<uid>`,
1470
+ which works once linger is on.
1471
+ - `--serve` runs `tailscale serve` as you, which Tailscale on Linux allows only
1472
+ for its operator: run `sudo tailscale set --operator=$USER` first.
1473
+ - A systemd unit has no login shell, and the service finds the Android SDK only
1474
+ in `~/Android/Sdk`. Pin the JDK and an SDK elsewhere:
1475
+
1476
+ ```bash
1477
+ stim-server service install --serve \
1478
+ --env JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
1479
+ --env ANDROID_HOME=$HOME/Android/Sdk
1480
+ ```
1481
+
1482
+ - A remote update the server starts for an approved client runs under
1483
+ `systemd-run --user --scope`, so restarting the unit does not stop it.
1484
+ - `stim-server setup` works on Linux for `--build`. It refuses
1485
+ `--device-host`, needs no GUI session, and skips Stim Host, the permission
1486
+ steps and the Xcode, iOS runtime and CocoaPods checks.
1487
+
1488
+ ### On Windows
1489
+
1490
+ On Windows, `service` manages a per-user scheduled task with the same commands,
1491
+ flags and checks, and never needs elevation. A Windows machine takes offloaded
1492
+ Android builds only, so it never installs Stim Host and has no permission
1493
+ grants.
1494
+
1495
+ - `install` writes the service file
1496
+ `%LOCALAPPDATA%\Stim\services\<label>.json` (the server's `node`, script,
1497
+ arguments and environment, `STIM_SERVICE_LABEL=<label>`, the serve route and
1498
+ the previous server script), copies the supervisor
1499
+ `stim-service-runner.mjs` into `%LOCALAPPDATA%\Stim\services\<label>\`, and
1500
+ registers the task `\Stim\<label>` with `schtasks /Create`, then starts it.
1501
+ It logs to `%LOCALAPPDATA%\Stim\Logs\<label>.log`.
1502
+ - The task starts at your logon and runs whether or not you are logged on
1503
+ (Task Scheduler's S4U logon type, which an ordinary user can register), so it
1504
+ keeps running after you sign out; after a reboot it starts at your next
1505
+ logon. Task Scheduler restarts a task only when it fails to start, not when
1506
+ its process exits, so the task runs the supervisor, which restarts the
1507
+ server 30 seconds after it exits and reads the service file again each time.
1508
+ The supervisor records its pid, the server's, the number of runs and the last
1509
+ exit code in `supervisor.json`, which `status` reports, and its own errors,
1510
+ such as an unreadable service file, in `supervisor.log` beside it. The task
1511
+ runs at normal priority, so builds are not slowed by Task Scheduler's
1512
+ below-normal default.
1513
+ - Stopping the service ends the supervisor's process tree with
1514
+ `taskkill /T /F`; a remote update the server starts runs detached from that
1515
+ tree, so restarting the service does not stop it.
1516
+ - `update` and `rollback` keep versions under
1517
+ `%LOCALAPPDATA%\Stim\services\<label>\versions\` and rewrite only the
1518
+ service file's arguments and previous script. `uninstall` stops the task,
1519
+ deletes it and removes the service file.
1520
+ - The task's environment is your user environment, without a login shell.
1521
+ Builds start tools such as Expo's config loader with the first `node.exe` on
1522
+ `PATH`, and a version manager's `node.exe` shim, such as pnpm's, breaks their
1523
+ Node IPC, so builds fail with `fingerprint-mismatch`. Stim keeps `PATH` as
1524
+ you set it; `install` names a first `node.exe` that is not the Node running
1525
+ the server, and `--path-prepend` puts the real Node first:
1526
+
1527
+ ```powershell
1528
+ stim-server service install --serve --path-prepend "C:\Program Files\nodejs" `
1529
+ --env JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-17" `
1530
+ --env ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
1531
+ ```
1532
+
1533
+ - `stim-server setup` works on Windows for `--build`, as on Linux.
1134
1534
 
1135
1535
  ## Remote update
1136
1536
 
1137
- A Mac this one approved for builds (`--build`) or device hosting
1138
- (`--device-host`) can update this Mac's `stim-server service` over its tailnet
1139
- connection, so the remote Mac keeps up with the client without ssh. A
1537
+ A machine this one approved for builds (`--build`) or device hosting
1538
+ (`--device-host`) can update this machine's `stim-server service` over its tailnet
1539
+ connection, so the remote machine keeps up with the client without ssh. A
1140
1540
  server that offers it lists `server-update` in its `hello` features. A paired
1141
- phone, a `read` or `control` device and a loopback connection from this Mac
1142
- cannot. The methods need that approval, not `read`:
1541
+ phone, a `read` or `control` device and the local socket connection cannot. The methods need that approval, not `read`:
1143
1542
 
1144
1543
  - `server.update.status` returns `server` (`version` and `stimBuild`),
1145
- `service` (the label of the LaunchAgent this server runs under, or `null` when
1544
+ `service` (the label of the service this server runs under, or `null` when
1146
1545
  it does not run as a `stim-server service` and so cannot update itself),
1147
1546
  `acceptsClientBuilds`, `running` (the update in progress, with its `state`,
1148
1547
  `missing` package bytes and the last lines of its `log`) and `last` (how the
@@ -1154,8 +1553,8 @@ cannot. The methods need that approval, not `read`:
1154
1553
  `{ "packages": [{ "name", "size", "sha256" }] }`, at most 8 `.tgz` packages
1155
1554
  of the client's own build. A `release` older than the version this server
1156
1555
  runs is refused with `bad-request`; run `stim-server service update --release`
1157
- on this Mac to downgrade it. Packages need `server.acceptClientBuilds` set to
1158
- `true` on this Mac (`stim settings set server.acceptClientBuilds true`); a
1556
+ on this machine to downgrade it. Packages need `server.acceptClientBuilds` set to
1557
+ `true` on this machine (`stim settings set server.acceptClientBuilds true`); a
1159
1558
  release needs no setting. One update runs at a time.
1160
1559
  - `server.update.chunk` takes `{ id, name, offset, data }`, at most 32 KiB of
1161
1560
  base64 at the package's next offset. A package whose bytes do not match its
@@ -1175,10 +1574,10 @@ arrives once every package is in gets `action-busy`, and a retried reservation
1175
1574
  of an existing hosted session still answers during the drain.
1176
1575
 
1177
1576
  Stim Desktop starts these updates through its own local stim-server with two
1178
- methods that only the local Desktop control connection may call:
1577
+ methods that only the local Unix socket connection may call:
1179
1578
  `machines.update.start` takes `{ "machine" }`, an approved `remote.machines`
1180
- entry. It connects to that machine's pinned node with
1181
- the stored credential and asks it for this server's npm release. When this
1579
+ entry. It connects to that machine with the stored credential, goes ahead only
1580
+ with the stim-server that proves the key pinned at approval, and asks it for this server's npm release. When this
1182
1581
  server runs from a Stim checkout, it instead packs the checkout's workspace
1183
1582
  packages as `npm pack` would and sends them. `machines.update.status` takes
1184
1583
  the same `{ "machine" }` and returns `remote` (the machine's
@@ -1202,18 +1601,23 @@ connected since stim-server started keeps its directory when revoked.
1202
1601
  same Ruby selection and UTF-8 locale defaults as `pod install`; omitting
1203
1602
  `rubyVersion` uses the worker's default Ruby. It
1204
1603
  returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1604
+ `os`, the OS stim-server runs on as Node names it (`darwin`, `linux`, `win32`);
1205
1605
  `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1206
1606
  `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
1207
1607
  runtimes with an iPhone simulator to build for; `jdk`, the major version of
1208
1608
  the JDK in `JAVA_HOME` or the macOS default; and `androidSdk`, the `ndk`,
1209
- `buildTools` and `platforms` directories of the SDK in `ANDROID_HOME` or
1210
- `~/Library/Android/sdk`, null without one), `capacity` and `warm`
1211
- (`checkout`, `dependencies` when the last install used that lockfile, and
1212
- `build` once a build ran there, including a non-empty repository `macos/`
1213
- scratch directory) for that repository. `capacity` holds
1609
+ `buildTools` and `platforms` directories of the SDK in `ANDROID_HOME`, else `~/Library/Android/sdk` on macOS or
1610
+ `~/Android/Sdk` on Linux, null without one), `capacity` and `warm`
1611
+ (`checkout`, `dependencies`, and the legacy `build` field, now always false)
1612
+ for that repository. For React Native and Expo builds, clients supporting `build-dependency-cache` can send
1613
+ `dependencies: { project, packageName }` alongside `lockfile`; the dependency
1614
+ hint requires the selected project's installed modules and a matching install
1615
+ marker, including the package-manager arguments. Without that query it is
1616
+ false. Clients ignore older workers' unverified warmth and do not rank
1617
+ checkout or build-directory presence as cache state. `capacity` holds
1214
1618
  `running` and `max` offloaded builds, `diskFreeBytes` of the worker root's
1215
1619
  volume and `minDiskFreeBytes`, `cpus`, `loadPerCore` (the 5-minute load
1216
- average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
1620
+ average per CPU), `builds` (this machine's own Stim runs in prebuild, pods or
1217
1621
  compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
1218
1622
  `concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
1219
1623
  `server.maxLoadPerCore`, default 2), `memoryUsedBytes` (Activity Monitor's
@@ -1227,12 +1631,57 @@ connected since stim-server started keeps its directory when revoked.
1227
1631
  simulator-runtime facts. Unused report fields are null. This report has its own
1228
1632
  cache, ignores Ruby selection, and is reused for native Xcode build admission.
1229
1633
  Clients use the complete report on older native-build-capable hosts.
1634
+ With `build-artifact-cache`, a native Xcode offer can also send
1635
+ `artifact: { fingerprint, cacheKey }`. Its `artifact` result is null on a miss,
1636
+ or `{ fingerprint, cacheKey, name, size, sha256 }` for a completed archive in
1637
+ this client's repository area. The key includes source, toolchain, SDK,
1638
+ architecture and target options. These are exact lookups, not a full inventory.
1639
+ With `react-native-peer-cache`, a React Native/Expo development offer can send
1640
+ `reactNativeArtifact: { project, fingerprint, isExpo, platform, toolchain, ios | android }`.
1641
+ The query includes the caller's compatible toolchain and normalized native
1642
+ build choices; the worker constructs its own receipt around compilation.
1643
+ The returned identity is opaque, not an alias for the caller's local cache key.
1644
+ The completed receipt binds the worker-verified post-mutation fingerprint
1645
+ and native options to the toolchain checked before and after compilation.
1646
+ Lookup uses that immutable receipt, so a later checkout or toolchain change
1647
+ does not discard a matching archive. Older workers receive no new query
1648
+ parameters and continue through their normal build path.
1649
+ With `swiftpm-peer-cache`, `swiftpmArtifact` carries `project`, `sourceDigest`,
1650
+ `stimBuild`, complete `inputs` (`ignored`, `outputs`, `localFiles`,
1651
+ `environment`) and `build` (`product`, `infoPlist`, `resources`, `assetCatalog`).
1652
+ Resource and catalog paths are repository-relative; the plist is
1653
+ project-relative. The worker verifies actual transferred files and full
1654
+ artifact identity around compilation, then retains an opaque receipt.
1655
+ Private input values stay on that worker and are rechecked on every lookup.
1656
+ SDKROOT, then SWIFTPM_SDKROOT_macosx, selects the SDK ahead of the default;
1657
+ its real path and build version are revalidated.
1658
+ A matching archive survives checkout and job-output pruning when those
1659
+ private inputs remain available and unchanged and its direct Apple toolchain
1660
+ selection can be reproduced. Command paths and versions are checked from the
1661
+ package directory, including SWIFT_EXEC and SWIFT_EXEC_MANIFEST compiler overrides.
1662
+ Overrides and custom Swift, xcrun or xcodebuild launchers require the
1663
+ checkout and become a miss after it is removed.
1664
+ - `build.cached-artifact` takes `repo` and `artifact: { fingerprint, cacheKey }`.
1665
+ It requires the existing build approval. It verifies and streams the cached
1666
+ archive with the same digest-prefixed binary frames as `build.artifact`, and
1667
+ returns its descriptor or null if missing or corrupt. It starts no build and
1668
+ takes no build slot, even when new builds are declined. Completed native Xcode
1669
+ worker archives survive job-output cleanup in their client's repository cache;
1670
+ removing that client's worker area removes the cache too. Other clients and
1671
+ repositories cannot look up those entries.
1672
+ React Native archives also require the same `reactNativeArtifact` query on
1673
+ download, checked against the completed receipt again. An identity alone
1674
+ cannot fetch them. Archives use the existing complete-file cache and digest verification;
1675
+ a hit uploads no source and takes no compiler slot. A missing or corrupt
1676
+ archive falls back through the caller's existing placement policy.
1677
+ SwiftPM archives require the same `swiftpmArtifact` query on fetch and another
1678
+ private-input check; their opaque keys alone cannot fetch an archive.
1230
1679
  - `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
1231
1680
  manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
1232
1681
  a link's blob being its target. Pages accumulate until `done`; the next
1233
1682
  `build.sync` starts a new manifest. A path is relative, with no `.`, `..`,
1234
1683
  empty or `.git` component (in any case). The result's `missing` lists the digests of the
1235
- page this Mac lacks. The client then sends each as binary frames: 32 bytes of
1684
+ page this machine lacks. The client then sends each as binary frames: 32 bytes of
1236
1685
  the sha256, then the next bytes of that blob, one blob after another. A frame
1237
1686
  for a blob that was not asked for, one that overruns its size, or bytes that
1238
1687
  do not match the digest close the connection with 4400. Blobs are kept per
@@ -1249,10 +1698,12 @@ connected since stim-server started keeps its directory when revoked.
1249
1698
  `fingerprint`, `packageName`, `isExpo`, `optimizations`, `android` (for
1250
1699
  `android`: `variant`, `abi`, `gradleBuildCache`, `pch` and `compilerCache`,
1251
1700
  `ccache` or `none`) and `stimBuild`, after the whole manifest of `repo` was
1252
- synced on this connection. It returns `{ "job" }`. It fails with
1701
+ synced on this connection. A peer-cache-capable React Native caller can set
1702
+ `cacheArtifact: true` to retain a completed development archive when its cache
1703
+ write and remote policies allow it. It returns `{ "job" }`. It fails with
1253
1704
  `build-busy` while the offer would be declined, naming the reason, or while
1254
1705
  another build of the same client and repository runs, or while every one
1255
- of this Mac's `concurrency.maxBuilds` slots is held, and with
1706
+ of this machine's `concurrency.maxBuilds` slots is held, and with
1256
1707
  `build-refused` when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1257
1708
  `STIM_HOME` set to the repository's area. It makes the area's checkout
1258
1709
  hold exactly the manifest (a file git lists as untracked and not ignored,
@@ -1265,12 +1716,15 @@ connected since stim-server started keeps its directory when revoked.
1265
1716
  Gradle daemon leaves the build's process group, so no claim or slot tracks
1266
1717
  it. An Android build keeps the client's daemon warm, with Gradle's idle
1267
1718
  timeout set to `server.gradleDaemonIdleMinutes` (default 30; 0 stops it
1268
- when the build ends), and stops it when the build gets SIGTERM. Gradle
1719
+ when the build ends), and stops it when the build gets SIGTERM (on Windows,
1720
+ where a cancel ends the build's process tree with `taskkill /T /F`, the
1721
+ daemon sweep below stops it). Gradle
1269
1722
  fixes a daemon's idle timeout when the daemon starts, so a new value
1270
1723
  applies to the next daemon. Once no build of that client runs, stim-server
1271
1724
  sends SIGTERM to each daemon whose command line runs from the client's
1272
- Gradle home when a build of the client was cancelled, when the client
1273
- loses `build`, and, for every client, while this Mac has less than 2 GB of
1725
+ Gradle home (on Windows, every process running from it, the Kotlin daemon
1726
+ included, since Windows cannot delete a jar a running process opened) when a build of the client was cancelled, when the client
1727
+ loses `build`, and, for every client, while this machine has less than 2 GB of
1274
1728
  available memory (total memory minus Activity Monitor's Memory Used). It
1275
1729
  checks every minute and on each change under its server directory. A daemon
1276
1730
  whose Gradle home is deleted stops itself within seconds, because Gradle
@@ -1303,7 +1757,11 @@ connected since stim-server started keeps its directory when revoked.
1303
1757
  It returns that app as a tar archive, its manifest fingerprint, timings and
1304
1758
  `compilationCache: {}`. The client verifies the digest, identity, executable
1305
1759
  and signature before promotion and launches locally. Every offload failure
1306
- falls back locally; macOS artifacts are not cached. Matching Xcode and macOS
1760
+ falls back locally. A caller with remote artifact caching enabled can set
1761
+ `cacheArtifact: true` and `macos.artifactInputs` for declared repeatable,
1762
+ relocatable apps. Completed archive retention requires matching worker
1763
+ input identities before and after the build; ignored bundle files need
1764
+ explicit private declarations. Matching Xcode and macOS
1307
1765
  SDK are required, and the worker needs network access for the first SwiftPM
1308
1766
  dependency fetch; the dependency cache is shared only within that client.
1309
1767
  - `build.progress` events `{ "event": "build.progress", "job", ... }` carry a
@@ -1330,23 +1788,30 @@ connected since stim-server started keeps its directory when revoked.
1330
1788
  as binary frames, each 32 bytes of its sha256 and then the next bytes, then
1331
1789
  answers `{ "name", "size", "sha256" }`, and deletes it here. For an iOS simulator, Android emulator or macOS
1332
1790
  job the answer also carries `handoff`, a single-use token, and the staged
1333
- `.app` or APK stays for 10 minutes so a hosted session on this Mac can take it with
1791
+ `.app` or APK stays for 10 minutes so a hosted session on this machine can take it with
1334
1792
  `device-host.app.handoff`. A client keeps at most one such bundle; its next
1335
1793
  fetched native build or a revoked approval deletes it. An archive nobody fetched is deleted when its job
1336
1794
  is cancelled; one left by a server that crashed stays under
1337
1795
  `repos/<repo>/out/` until you delete it.
1338
1796
 
1339
- The worker root is `server.workerRoot` in this Mac's Stim settings, or
1797
+ The worker root is `server.workerRoot` in this machine's Stim settings, or
1340
1798
  `$STIM_HOME/build-worker`. Each client gets `<root>/<device id>/`, with its
1341
1799
  blobs, caches (`CP_HOME_DIR`, `CP_CACHE_DIR`, the pnpm store and
1342
1800
  `GRADLE_USER_HOME` and SwiftPM) and one area
1343
1801
  per repository under `repos/<repo>/`: the checkout, its own Stim home with
1344
1802
  DerivedData, the compilation cache and ccache, and the output. A build never reads or
1345
- writes this Mac's own Stim home. An ownership claim at
1803
+ writes this machine's own Stim home. An ownership claim at
1346
1804
  `repos/<repo>.claims`, whose child is the build's process group, guards each
1347
1805
  area; it is released only once that group is gone, and a claim whose server
1348
- and build are both gone is recovered by the next build. With a non-zero
1349
- `concurrency.maxBuilds`, the build also holds one of this Mac's build slots
1806
+ and build are both gone is recovered by the next build. Windows has no
1807
+ process groups, so there the claim follows the build's worker process, and a
1808
+ cancel ends its whole tree with `taskkill /T /F` before the claim is
1809
+ released. The NDK's ninja on Windows cannot open paths longer than 260
1810
+ characters, so a Windows worker keeps each area's checkout and each client's
1811
+ Gradle home in private short directories, `<SystemDrive>\sw\<6 hex>`, with
1812
+ an owner key beside each; the area's `src` is a junction to its checkout.
1813
+ Removing a client deletes them too. With a non-zero
1814
+ `concurrency.maxBuilds`, the build also holds one of this machine's build slots
1350
1815
  (`$STIM_HOME/build-slots/`), the same claims local Stim builds take, with the
1351
1816
  same child, so a local build waits for it and `builds` counts it. Each finished build
1352
1817
  appends a `build` record to the [action log](#actions) with the repository as
@@ -1355,40 +1820,44 @@ client's directory to reclaim it.
1355
1820
 
1356
1821
  ## Protocol
1357
1822
 
1358
- JSON messages over a WebSocket. Requests are `{ "id", "method", "params" }`,
1823
+ JSON messages over a WebSocket. On a network connection they follow the Noise
1824
+ handshake (see [Pairing](#pairing)) and each WebSocket message is one encrypted
1825
+ transport message; on the local Unix socket they are plain JSON. Requests are `{ "id", "method", "params" }`,
1359
1826
  answered by `{ "id", "result" }` or `{ "id", "error": { "code", "message" } }`.
1360
1827
  Events are `{ "event", "subscription", ... }`.
1361
1828
 
1362
- - `hello` must come first. Params: `protocol` (1), `client` (`name`,
1363
- `version`), and `auth`, one of `{ "pairingToken", "deviceName" }`,
1364
- `{ "deviceToken" }`, or `{ "request": "build", "deviceName" }` (see
1365
- [Build access](#build-access)). The result carries the server name and versions, the
1829
+ - `hello` must come first. Params: `client` (`name`, `version`), and
1830
+ optionally `access`, `build` or `device-host`, which picks which of the key's
1831
+ own records the connection acts as (see [Build access](#build-access));
1832
+ without it, the pairing in `devices.json`. It never widens what a record
1833
+ allows. The result carries `protocol` (2), the server name and versions, the
1366
1834
  device's `capabilities` (see [Scopes](#scopes)), the server's `features`
1367
1835
  (`physical-ios` and `physical-android` when it serves `physical: true` for
1368
1836
  that platform: see `frames.subscribe` below for an iPhone and
1369
1837
  [Physical Android devices](#physical-android-devices), and `notifications`
1370
1838
  for `notifications.list` and the `notification` event),
1371
1839
  the `actions` it may run
1372
- (none without `control`), the paired device, and the new `deviceToken` when
1373
- the hello paired. `server.home` is the home folder
1840
+ (none without `control`) and the record the connection authenticated as.
1841
+ `server.home` is the home folder
1374
1842
  of the user the server runs as, so clients can show paths under it as
1375
- `~/...`. A pending approval result omits it.
1843
+ `~/...`.
1376
1844
  - `status.subscribe` returns a subscription id. Each `status` event carries a
1377
1845
  full payload as `stim status --watch --json` prints it, including each
1378
1846
  environment's `physicalDevices`, the phones it leases, and its `agents`, the
1379
1847
  Claude Code and Codex sessions working in it. Those carry the session id,
1380
1848
  working directory, the short session name the tool keeps, and times, never
1381
1849
  prompts or conversation. `openUrl` there is a `claude://` or `codex://` link
1382
- that only the Mac can open; the server has no action that opens it. `webUrl`
1850
+ that only the machine can open; the server has no action that opens it. `webUrl`
1383
1851
  is a Claude Code session's `https://claude.ai/code/` link while Remote Control
1384
1852
  is connected, which a phone opens in the Claude app or a browser.
1385
1853
  `endedAgents` lists the sessions that stopped running there in the last 3
1386
1854
  days, with the same fields as last found, no `pid`, and `endedAt`. The server
1387
1855
  drops `CLAUDE_CODE_SESSION_ID` and `CODEX_THREAD_ID` from the environment of
1388
1856
  the `stim` commands it runs, so a phone's reload is not recorded as the work
1389
- of the agent session that started the server. All subscribers share
1390
- one `stim status --watch --json` child, which stops with the last
1391
- subscriber. While any status subscription is open, the server keeps a CPU
1857
+ of the agent session that started the server. The server runs the status
1858
+ watch itself, in a worker thread, from the moment it is ready, so a
1859
+ subscriber gets the latest payload at once; all subscribers share it.
1860
+ While any status subscription is open, the server keeps a CPU
1392
1861
  and memory history from each payload's `machine.owners`, and every `status`
1393
1862
  event carries it beside the payload as `usage`, once it holds a reading:
1394
1863
  `{ "intervalMs", "endAt", "environments", "devices" }`. It covers the last 10
@@ -1405,6 +1874,35 @@ Events are `{ "event", "subscription", ... }`.
1405
1874
  `grantedAt` times as `ownLeases`, so a client can tell a person controlling a
1406
1875
  device from an agent driving it: an `activity.driver` of `stim device lock`
1407
1876
  whose `since` is in `ownLeases` is a phone.
1877
+ - A server that offers the following status views lists `status-views` in its
1878
+ `hello` features.
1879
+ - `workspaces.subscribe` returns a subscription id and sends `workspaces`
1880
+ events, each with a `seq` that starts at 0 and grows by one. The first
1881
+ carries `snapshot`: `{ "workspaces", "worktrees", "archived" }`. Each
1882
+ workspace is its environment as `status` reports it, without what only a
1883
+ shown workspace needs: a build keeps its outcome, timing and first located
1884
+ diagnostic, each platform's history only its newest build and the failures
1885
+ right before it, `logs` only `errorsSinceMarker`, a pull request no
1886
+ `reviewDecision` or `checkedAt`, and a macOS app no launch `arguments`.
1887
+ `worktrees` are the unprovisioned worktrees' `path`, `branch`, `repository`,
1888
+ `git`, `gitChip` and pull request without `reviewDecision` or `checkedAt`; `archived` is
1889
+ `{ "count", "bytes", "newest" }` with the 10 newest archives. Later events
1890
+ carry only what changed: `workspaces` and `worktrees` as
1891
+ `{ "upsert", "remove" }` (whole entries by `path`, and the paths that are
1892
+ gone), and `archived` when it changed. A client that misses a `seq`
1893
+ subscribes again for a new snapshot.
1894
+ - `workspace.subscribe` with `{ "workspace" }` sends `workspace` events with
1895
+ that workspace's full `environment`, on subscribing and on every change to
1896
+ it, or `null` while no workspace has that path.
1897
+ - `machine.subscribe` sends `machine` events with `capacity`, `machine`,
1898
+ `deviceLeases`, `simctlAvailable` and `maintenance` without `recent` and
1899
+ `sizes`, plus `usage` and `ownLeases` as on `status` events.
1900
+ - `builds.history` with `{ "workspace" }` returns that workspace's full
1901
+ `{ "builds" }`. `archived.list` with optional `before` and `limit` (at most 50) returns `{ "archived", "usage", "next" }`: archives newest removal first,
1902
+ and the `before` of the next page, or null. `worktrees.list` returns the full
1903
+ `{ "worktrees" }` and `maintenance.get` the full `{ "maintenance" }`. These
1904
+ answer from the server's latest status, waiting up to 30 seconds for the
1905
+ first one.
1408
1906
  - `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
1409
1907
  events: first the last `tail` matching records, then new ones in batches.
1410
1908
  Both take the Stim Desktop log viewer's filters: exactly one of `workspace`
@@ -1431,8 +1929,8 @@ Events are `{ "event", "subscription", ... }`.
1431
1929
  with a message naming the archive id.
1432
1930
  - `stats.get` returns the same payload as `stim stats --json`, using the shared
1433
1931
  core reader in a bounded, cancellable server child. The stats part of
1434
- `machine.details` uses the same reader. `settings.get` runs
1435
- `stim settings --json`, which masks sensitive values. Without `workspace`,
1932
+ `machine.details` uses the same reader. `settings.get` uses the in-process
1933
+ settings API, which masks sensitive values. Without `workspace`,
1436
1934
  these reads use the home directory as their project context.
1437
1935
  - `frames.subscribe` takes `workspace`, `platform` (`ios`, `android`, `web` or `macos`),
1438
1936
  `slot` (`default` when absent), `fps` (1 to 30, 5 by default) and `maxEdge`
@@ -1532,7 +2030,7 @@ Events are `{ "event", "subscription", ... }`.
1532
2030
  and on an iPhone Duo bit 1 while folded or bit 2 while unfolded,
1533
2031
  u16 header length, u32 sequence number of the messages sent on this
1534
2032
  subscription, f64 capture time in milliseconds since the epoch on the
1535
- Mac's clock, u16 width, u16 height, u8 subscription id length and the
2033
+ machine's clock, u16 width, u16 height, u8 subscription id length and the
1536
2034
  ASCII id), then one Annex-B access unit. Every keyframe carries its SPS
1537
2035
  and PPS, and the stream has no B-frames, so each access unit is shown as
1538
2036
  it arrives. A change of size, such as a rotation or a Duo fold, restarts
@@ -1640,7 +2138,7 @@ Events are `{ "event", "subscription", ... }`.
1640
2138
  it.
1641
2139
  - `recording`: true while the server records the device now.
1642
2140
  - `spans`: the recorded time ranges `{ start, end }`, in epoch milliseconds on
1643
- the Mac's clock, oldest first. Segments less than 1.5 seconds apart form
2141
+ the machine's clock, oldest first. Segments less than 1.5 seconds apart form
1644
2142
  one span; the gaps between spans are time nothing was recorded, such as
1645
2143
  after `stim stop`.
1646
2144
  - `markers`: `{ at, kind, command?, label }` from the start of the first span
@@ -1714,13 +2212,13 @@ Events are `{ "event", "subscription", ... }`.
1714
2212
  deleted.
1715
2213
  - `build.plan` takes `workspace`, `platform` (`ios` or `android`) and `slot`
1716
2214
  (`default` when absent), and returns the payload of
1717
- `stim <platform> --plan --json` run in the workspace: the fingerprint, the
2215
+ `stim <platform> --plan --json` through the shared in-process API: the fingerprint, the
1718
2216
  cache result the next build would get (`local`, `remote` or `false`), the
1719
2217
  prebuild decision, `expectedMs` with its `basis`, and on a predicted cold
1720
2218
  build its `missReason`. It builds, boots and
1721
2219
  installs nothing and writes no Stim state; a remote cache check can
1722
- download the artifact into a temporary directory, so it gets 150 seconds
1723
- instead of 60. A plan predicting that the build would refuse is a result
2220
+ download the artifact into a temporary directory. The planner has a 60-second
2221
+ execution and 35-second cleanup budget inside the 150-second request limit. A plan predicting that the build would refuse is a result
1724
2222
  whose `refusal` holds the code, message and remedy. A plan that cannot be
1725
2223
  computed is a `stim-failed` error. One plan runs per workspace at a time,
1726
2224
  across all connections; later requests wait their turn, and the 150
@@ -1756,7 +2254,7 @@ Events are `{ "event", "subscription", ... }`.
1756
2254
  without running `stim`: `volumes`, one per volume that holds a Stim
1757
2255
  workspace, Stim home, or the simulators, with `mount`, `holds`, `freeBytes`
1758
2256
  (free space without purgeable space, which Stim's disk budget measures) and
1759
- `totalBytes`; `memory` with `totalBytes`, `usedBytes` (the Mac's memory in
2257
+ `totalBytes`; `memory` with `totalBytes`, `usedBytes` (the machine's memory in
1760
2258
  use as Activity Monitor's "Memory Used" counts it: app memory, wired and
1761
2259
  compressed; null off macOS) and the macOS `pressure` level (`normal`,
1762
2260
  `warning`, `critical`, or null); `load` with the 1, 5 and 15
@@ -1769,23 +2267,27 @@ Events are `{ "event", "subscription", ... }`.
1769
2267
  critical) and `diskFreeBytes` of the startup volume; a field is null when it
1770
2268
  cannot be read. `sinceMs` returns only the samples taken after it.
1771
2269
  - `machine.details` returns `{ "gc", "stats", "measuredAt" }`: the payloads of
1772
- `stim gc --json` and `stim stats --json`, both run in the home directory, so
2270
+ GC dry-run and stats report APIs, both read in process from the home directory, so
1773
2271
  a phone can show what Stim Desktop's Machine page shows about disk: every
1774
2272
  simulator, AVD, iOS runtime and system image, shared caches, recordings,
1775
2273
  workspace build outputs and logs, and what `stim gc --delete` would free. The
1776
- `gc` run is always the dry run with no other flag; the server never deletes
1777
- anything for this request. A part is null when its command failed, and
2274
+ GC read is always the dry run with no other scope; the server never deletes
2275
+ anything for this request. A part is null when its read failed, and
1778
2276
  `gcError` or `statsError` says why. It needs only `read`. The server keeps
1779
2277
  one result for 60 seconds, shared by every connection: a request while the
1780
- commands run waits for them, and `measuredAt` says when they started. The
1781
- commands fail after 150 seconds. Servers that predate it answer
2278
+ reads run waits for them, and `measuredAt` says when they started. GC and
2279
+ doctor have the API's 60 second execution budget; the shared reads also retain
2280
+ the server's 150 second limit. API cleanup has a separate 35 second budget;
2281
+ unfinished cleanup returns `STIM_CLAIM_UNAVAILABLE`, and an unverifiable
2282
+ child is left untouched. Servers that predate it answer
1782
2283
  `unknown-method`. It also carries `buildMachines`, the `remoteMachines` list
1783
- of `stim doctor --json --platform ios` (never with `--fix`), so a phone can
1784
- show whether each remote Mac takes builds. The server runs doctor only
2284
+ of `stim doctor --json` (never with `--fix`), so a phone can
2285
+ show whether each remote machine takes builds. The server runs doctor only
1785
2286
  when `remote.machines` names a machine, else the list is empty; it runs it
1786
2287
  in the registered workspace that ran doctor for iOS most recently, because
1787
- doctor judges a machine against an app. Like a doctor run from a terminal,
1788
- that records the run for the workspace. `buildMachines` is null with
2288
+ doctor judges a machine against an app. This read does not repair or record a
2289
+ CLI doctor run. Changing the selected workspace clears its cached result and
2290
+ prevents an older read from overwriting the new workspace's result. `buildMachines` is null with
1789
2291
  `buildMachinesError` when doctor failed, the config could not be read, or no
1790
2292
  registered workspace exists. The reply never waits for doctor, whose offer
1791
2293
  to an unreachable machine can take about 13 seconds: it carries the last
@@ -1794,10 +2296,10 @@ Events are `{ "event", "subscription", ... }`.
1794
2296
  at a time, and sets `buildMachinesPending` while that background run has not
1795
2297
  settled a result yet, or the settled one is 60 seconds old or older. A client
1796
2298
  that wants the refreshed result asks `machine.details` again. It also
1797
- carries `buildClients`, the builds this Mac ran for other Macs, one entry
1798
- per client Mac read from the `build` records of the audit log, with `id`,
2299
+ carries `buildClients`, the builds this machine ran for other machines, one entry
2300
+ per client machine read from the `build` records of the audit log, with `id`,
1799
2301
  `name`, `builds`, `failed`, `buildMs`, `lastAt` and the same three counts
1800
- for `today`, most recent client first, with `today` on this Mac's local
2302
+ for `today`, most recent client first, with `today` on this machine's local
1801
2303
  calendar day. Servers that predate it leave it out.
1802
2304
  - `unsubscribe` ends a subscription.
1803
2305
  - `push.register` takes `token`, an Expo push token, `events`, one or more
@@ -1830,8 +2332,8 @@ a time. `logs.query`, `stats.get` and `settings.get` fail after 60 seconds,
1830
2332
  `build.plan` after 150; these four fail at 32 MiB of output. Closing the
1831
2333
  connection stops running requests. When the command refuses with Stim's error
1832
2334
  contract on stdout, the `stim-failed` message is its code, message and remedy;
1833
- otherwise it is the exit status and the end of stderr. A `stim` child that
1834
- ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
2335
+ otherwise the message describes the failed read. In-process reads cancel their
2336
+ owned children and drain cleanup before releasing their request slot. A log subscriber whose socket has more than
1835
2337
  4 MiB unsent gets no more batches until it catches up; past 20,000
1836
2338
  waiting records the server ends that subscription with `slow-client`.
1837
2339
 
@@ -1842,8 +2344,8 @@ so clients can replay what happened while nobody watched. It records a device
1842
2344
  while `stim status` shows an automation tool driving it (`activity.state` is
1843
2345
  `driven`), or while a client has a `frames.subscribe` subscription to it. It
1844
2346
  records nothing else, holds no device awake and changes no device setting. To
1845
- see drivers, the server keeps one `stim status --watch --json` child running
1846
- for as long as it runs, shared with status subscribers.
2347
+ see drivers, it reads the status watch the server runs for as long as it
2348
+ runs, shared with status subscribers.
1847
2349
 
1848
2350
  Recording goes through the device's `stim-frames` helper, shared with live
1849
2351
  subscribers. The helper runs a second H.264 encoder for it, at 720 pixels on
@@ -1878,7 +2380,7 @@ the `.part` segments of a server that stopped. When a status payload shows
1878
2380
  workspace within seconds and deletes its recordings. `stim stop` ends recording
1879
2381
  and keeps the footage; `stim worktree remove` and `stim gc` delete it. A gc that
1880
2382
  deletes the segment being written loses the rest of that segment; the next one
1881
- is written as usual. Recordings stay on the Mac and are served only to paired
2383
+ is written as usual. Recordings stay on the machine and are served only to paired
1882
2384
  clients.
1883
2385
 
1884
2386
  ## Push notifications
@@ -1896,7 +2398,7 @@ shows them in its attention strip. The server pushes, per device and only for
1896
2398
  the events the device chose:
1897
2399
 
1898
2400
  - `started`: a workspace began warming (`phase` `warming`), or an agent first
1899
- drove one of its devices. It is grouped per Mac, and opens the workspace, or the device viewer
2401
+ drove one of its devices. It is grouped per machine, and opens the workspace, or the device viewer
1900
2402
  once an agent drives it. An agent driving the workspace updates the warming
1901
2403
  notification in place.
1902
2404
  - `stuck`: an agent drove the workspace, its devices are still up, and nothing
@@ -1950,9 +2452,9 @@ registers does not push. During the phone's quiet hours nothing pushes: a
1950
2452
  problem that still holds when they end pushes then, and what started or
1951
2453
  finished during them does not.
1952
2454
 
1953
- While at least one device is paired, the server keeps its own
1954
- `stim status --watch --json` child running, even with no client connected, and
1955
- reads the free space of Stim's volumes and the memory pressure every minute.
2455
+ While at least one device is paired, the server reads its status watch, even
2456
+ with no client connected, and reads the free space of Stim's volumes and the
2457
+ memory pressure every minute.
1956
2458
  While a device wants `finished`, it looks up the pull requests of worktree
1957
2459
  branches that have an upstream every 5 minutes, with one `gh api graphql` call
1958
2460
  per repository, the lookup `stim gc` uses. Without `gh`, or when it is signed
@@ -1964,9 +2466,9 @@ count. More than three at once become one summary push that opens the
1964
2466
  phone's home screen.
1965
2467
 
1966
2468
  Pushes go to the Expo push service, `https://exp.host/--/api/v2/push/send`,
1967
- which forwards them to Apple. No APNs key or other secret lives on the Mac. A
1968
- workspace push carries the workspace title, a one-line cause and the Mac's name
1969
- as the subtitle; a machine or summary push has the Mac's name as its title. In
2469
+ which forwards them to Apple. No APNs key or other secret lives on the machine. A
2470
+ workspace push carries the workspace title, a one-line cause and the machine's name
2471
+ as the subtitle; a machine or summary push has the machine's name as its title. In
1970
2472
  `data` it carries the `ref`, the screen to open (`home`, `machine`,
1971
2473
  `workspace`, `device`, `build` or `url`), and the workspace's absolute path,
1972
2474
  with the platform and slot for a device, the platform for build details and
@@ -2001,9 +2503,9 @@ and needs-attention vectors. Notification delivery remains local to each.
2001
2503
 
2002
2504
  ## Notification history
2003
2505
 
2004
- The server logs the notifications the rules produce for the Mac, whether or not
2506
+ The server logs the notifications the rules produce for the machine, whether or not
2005
2507
  a phone was pushed, so a phone can list what it missed. While any device is
2006
- paired, it runs the rules once more for the Mac itself, with every category, no
2508
+ paired, it runs the rules once more for the machine itself, with every category, no
2007
2509
  quiet hours and the lowest `stuckMinutes` a registered device asked for (15
2008
2510
  without one). Each notification it produces is an entry; a `control` conflict is
2009
2511
  an entry too, listed only to the phone that lost the device. Pull request
@@ -2040,13 +2542,14 @@ A device with `control` can send `action` with params `{ "action", "workspace" }
2040
2542
  | Action | Params | Runs in the workspace |
2041
2543
  | -------- | -------------------------------------------------------------------- | ------------------------------- |
2042
2544
  | `reload` | `platform` (`ios` or `android`), optional; needed when both are live | `stim reload [platform] --json` |
2043
- | `stop` | none | `stim stop --json` |
2545
+ | `stop` | none | in-process workspace stop API |
2044
2546
 
2045
- Each action is one fixed argument list passed to the bundled `stim`, never
2046
- through a shell. `workspace` must be a project path Stim has registered, the
2047
- `path` a status payload lists; the command runs there. The result's `output`
2048
- is the JSON the command printed. The server refuses the request and runs
2049
- nothing with:
2547
+ `reload` passes its fixed argument list to the bundled `stim`, without a shell.
2548
+ `stop` calls the typed API in the server process and acts on the exact registered
2549
+ workspace, including a partial workspace that no longer has an app manifest.
2550
+ `workspace` must be a project path Stim has registered, the `path` a status
2551
+ payload lists. The result's `output` retains the CLI JSON shape; stop returns
2552
+ `{ root, ok, ...outcomes }`. The server refuses the request and runs nothing with:
2050
2553
 
2051
2554
  - `forbidden` when the device has only `read`;
2052
2555
  - `unknown-action` for any other action;
@@ -2057,10 +2560,13 @@ nothing with:
2057
2560
  - `action-busy` while another action runs in that workspace. The server runs
2058
2561
  one action per workspace at a time, across all connections.
2059
2562
 
2060
- A command that exits with an error fails with `action-failed` and the message
2061
- and remedy it printed. An action fails with `action-failed` after 120 seconds,
2062
- and its `stim` child gets SIGTERM. An action keeps running when its client
2063
- disconnects, and stopping `stim-server` stops it.
2563
+ Failures use `action-failed` with the command's error or the stop cleanup summary
2564
+ and bounded diagnostic detail. Registration storage failures retain the original
2565
+ record and report the storage error. An action fails with `action-failed` after
2566
+ 120 seconds: reload's CLI child gets SIGTERM, while stop is cancelled. A stop
2567
+ waiting for native work ends its wait; one already tearing down finishes cleanup.
2568
+ The workspace remains busy until that work drains, and server shutdown waits
2569
+ for it. An action keeps running when its client disconnects.
2064
2570
 
2065
2571
  Every `action` request from a paired device, refused or run, appends one line
2066
2572
  to `$STIM_HOME/server/actions.ndjson`: `at`, `device` (`id` and `name`),
@@ -2226,8 +2732,11 @@ appends a line to the action log, with `action` set to `control.begin`,
2226
2732
  ended or whom it took the device from. Inputs are not logged.
2227
2733
 
2228
2734
  The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
2229
- refuse the client until it pairs again or updates; `approval-pending` refuses
2230
- a build client until the Mac approves it; clients retry the others.
2735
+ refuse the client until it pairs again or updates; `unknown-secret` means the
2736
+ server does not hold the pairing token or setup ticket, or not yet, so a client
2737
+ with a setup ticket retries until setup runs; clients retry the others.
2738
+ Refusals during the handshake arrive as the WebSocket close reason with code
2739
+ 4401, not as a JSON error.
2231
2740
 
2232
2741
  The package exports the message types, and the build writes their JSON Schema
2233
2742
  to `dist/protocol.schema.json`, exported as `@stim-cli/server/protocol.schema.json`.