@stim-cli/server 1.24.0 → 1.26.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
@@ -49,8 +49,8 @@ and [device-host clients](#device-host-approval). The plain listing shows
49
49
  [Actions](#actions).
50
50
 
51
51
  `GET http://127.0.0.1:7787/health` answers requests from this machine with the
52
- server's name, versions, `stimBuild` (the digest of the bundled Stim's build,
53
- which offload compares between machines), protocol, `stimHome`, `startup`, `busy`
52
+ server's name, versions, `stimBuild` (the digest of the bundled Stim's build),
53
+ protocol, `stimHome`, `startup`, `busy`
54
54
  (the offloaded `builds` and `hostedSessions` running now) and the current
55
55
  Tailscale state. It answers HTTP 200 once `startup.state` is `ready`; while the
56
56
  state is `pending` or `degraded` (see the LaunchAgent notes below) it answers
@@ -93,7 +93,7 @@ it appends the default Node of nvm, fnm, Volta, mise or asdf, Homebrew,
93
93
  `JAVA_HOME` to a Homebrew `openjdk` (17, then 21, then the newest), else Android
94
94
  Studio's bundled runtime, when no JDK is installed under
95
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
96
+ and its own Node's directory is always the last `PATH` entry. The build host repeats this discovery for each build offer, toolchain read and build start, so a tool installed after the server started, such as Homebrew's directory, a JDK or the Android SDK, is found without a restart, within the minute the toolchain report is cached. A `PATH` line added to a shell profile is never read. CocoaPods uses the
97
97
  project's `.ruby-version` Ruby, else rvm's or rbenv's default Ruby, and reads the
98
98
  login shell only when neither exists. Anything else a shell profile sets, such as
99
99
  `STIM_*` variables, reaches the server only through its environment or `--env`.
@@ -275,7 +275,9 @@ reports without starting a CLI process. `desktop.reports.get` takes
275
275
  Doctor accepts a selected folder before it is registered with Stim. The result is
276
276
  `{ "pending": false, "value": { ... }, "measuredAt": "..." }`; a failed read
277
277
  returns `stim-failed`. Doctor never repairs the project or records a CLI run, and
278
- GC never deletes anything.
278
+ GC never deletes anything. The GC report, here and in `machine.details`, takes
279
+ worktree build-state sizes only from the disk-usage cache and runs no `du` for
280
+ them; a stale size is `null`.
279
281
 
280
282
  The local-only `desktop-report-platform` feature adds an optional `"platform": "ios"`
281
283
  or `"platform": "android"` to doctor targets. Omitting it checks all selected platforms.
@@ -291,12 +293,17 @@ Active subscriptions refresh after 60 seconds; no refresh timer runs without
291
293
  subscribers. `unsubscribe` and disconnect remove the subscription. Cancelling a
292
294
  get request does not cancel another reader's shared work.
293
295
 
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
296
+ `desktop.reports.invalidate` without parameters invalidates all report targets
297
+ and refreshes active subscriptions. With the local-only
298
+ `desktop-report-invalidate-target` feature it also takes a report target, as
299
+ `desktop.reports.get` does, and invalidates only that target, so a fresh read
300
+ of one report does not restart the others. Results started before
301
+ invalidation cannot replace a newer result or satisfy a waiting get; invalidation
302
+ aborts such a probe, so a waiting get waits only for the fresh one. A waiting get
303
+ does not use one of the connection's request slots: a connection can wait for 16
304
+ reports at a time, and more fail with `limit-exceeded`. The subscription cap,
305
+ report byte quota, API execution and cleanup budgets, and 150-second server limit
306
+ apply. At most four shared report probes run
300
307
  at once across connections and targets. A new target above that limit returns a
301
308
  busy error without starting or queuing work; invalidate and retry after a probe
302
309
  finishes. Unsubscribing does not release a probe's slot before it drains.
@@ -359,7 +366,10 @@ person approves does setup store the ticket's derived PSK and lookup in
359
366
  gets `unknown-secret`, and its `stim doctor` reports the machine as pending and
360
367
  tries again on every run. The requester's next first-contact handshake with the
361
368
  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
369
+ ticket. While it waits, setup says so ("Waiting for the requesting machine to
370
+ connect. Stim Desktop sends this automatically.", or the `stim doctor`
371
+ instruction for a Doctor-made command) and confirms each approval when it
372
+ arrives. From then on the ticket admits only that key, until it expires, so a
363
373
  requester that lost the reply can retry; setup deletes an unspent secret when it
364
374
  ends. Build and
365
375
  device-host approvals are separate records. A build grant carries no `read` or
@@ -1360,7 +1370,10 @@ event loop. Until that read returns, `/health` answers HTTP 503 with
1360
1370
  requests get 503, and no status follower, native helper, file watcher or
1361
1371
  recorder starts. A read that does not return within 10 seconds, plus up to one
1362
1372
  second to stop the child, leaves the server listening with
1363
- `startup: { "state": "degraded", "reason": "..." }` and HTTP 503 on `/health`,
1373
+ `startup: { "state": "degraded", "reason": "...", "cause": "home-unreadable" }`
1374
+ (`start-failed` when starting failed after the read returned) and HTTP 503 on
1375
+ `/health` (a tailnet peer sees only `{ "version", "startup": { "state",
1376
+ "cause" } }`, because the reason names the Stim home path),
1364
1377
  logs the reason once, and reads again every 30 seconds. When a read returns,
1365
1378
  the server starts its watchers and recorder, `/health` answers 200 with
1366
1379
  `startup: { "state": "ready" }`, and no restart is needed. `service status`
@@ -1615,13 +1628,20 @@ connected since stim-server started keeps its directory when revoked.
1615
1628
  same Ruby selection and UTF-8 locale defaults as `pod install`; omitting
1616
1629
  `rubyVersion` uses the worker's default Ruby. It
1617
1630
  returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1631
+ `compat`, the bundled Stim's build compatibility version, which a client
1632
+ compares instead of `stimBuild` when it is present;
1618
1633
  `os`, the OS stim-server runs on as Node names it (`darwin`, `linux`, `win32`);
1619
1634
  `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1620
1635
  `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
1621
1636
  runtimes with an iPhone simulator to build for; `jdk`, the major version of
1622
- the JDK in `JAVA_HOME` or the macOS default; and `androidSdk`, the `ndk`,
1637
+ the JDK in `JAVA_HOME` or the macOS default; `jdks`, the majors of every JDK it finds
1638
+ (`JAVA_HOME`, the system JVM directories, Homebrew, Android Studio's JBR, SDKMAN), the default included; `developerDir` (what `xcode-select -p` prints) and `xcodeApps` (the Xcode apps in
1639
+ `/Applications` with `version`, `build` and, while `xcode` is null, the `license` and `firstLaunch` checks of each), on a Mac; and `androidSdk`, the `ndk`,
1623
1640
  `buildTools` and `platforms` directories of the SDK in `ANDROID_HOME`, else `~/Library/Android/sdk` on macOS or
1624
- `~/Android/Sdk` on Linux, null without one), `capacity` and `warm`
1641
+ `~/Android/Sdk` on Linux, null without one), `androidHome` (that directory) and
1642
+ `androidLicenses` (whether `licenses/android-sdk-license` in it has content, so
1643
+ Gradle can download a missing package; both absent from a server older than
1644
+ them, and from a worker without an SDK), `capacity` and `warm`
1625
1645
  (`checkout`, `dependencies`, and the legacy `build` field, now always false)
1626
1646
  for that repository. For React Native and Expo builds, clients supporting `build-dependency-cache` can send
1627
1647
  `dependencies: { project, packageName }` alongside `lockfile`; the dependency
@@ -1652,8 +1672,10 @@ connected since stim-server started keeps its directory when revoked.
1652
1672
  architecture and target options. These are exact lookups, not a full inventory.
1653
1673
  With `react-native-peer-cache`, a React Native/Expo development offer can send
1654
1674
  `reactNativeArtifact: { project, fingerprint, isExpo, platform, toolchain, ios | android }`.
1655
- The query includes the caller's compatible toolchain and normalized native
1656
- build choices; the worker constructs its own receipt around compilation.
1675
+ The query includes the caller's compatible toolchain, whose `toolchain.compat`
1676
+ takes the place of `stimBuild` with `build-compat`, and normalized native
1677
+ build choices; a caller sends React Native and SwiftPM queries only to a
1678
+ server with `build-compat`; the worker constructs its own receipt around compilation.
1657
1679
  The returned identity is opaque, not an alias for the caller's local cache key.
1658
1680
  The completed receipt binds the worker-verified post-mutation fingerprint
1659
1681
  and native options to the toolchain checked before and after compilation.
@@ -1661,7 +1683,7 @@ connected since stim-server started keeps its directory when revoked.
1661
1683
  does not discard a matching archive. Older workers receive no new query
1662
1684
  parameters and continue through their normal build path.
1663
1685
  With `swiftpm-peer-cache`, `swiftpmArtifact` carries `project`, `sourceDigest`,
1664
- `stimBuild`, complete `inputs` (`ignored`, `outputs`, `localFiles`,
1686
+ `stimBuild` or, with `build-compat`, `compat` in its place, complete `inputs` (`ignored`, `outputs`, `localFiles`,
1665
1687
  `environment`) and `build` (`product`, `infoPlist`, `resources`, `assetCatalog`).
1666
1688
  Resource and catalog paths are repository-relative; the plist is
1667
1689
  project-relative. The worker verifies actual transferred files and full
@@ -1711,7 +1733,9 @@ connected since stim-server started keeps its directory when revoked.
1711
1733
  simulator runtime identifier, required for `ios`, null for `android`),
1712
1734
  `fingerprint`, `packageName`, `isExpo`, `optimizations`, `android` (for
1713
1735
  `android`: `variant`, `abi`, `gradleBuildCache`, `pch` and `compilerCache`,
1714
- `ccache` or `none`) and `stimBuild`, after the whole manifest of `repo` was
1736
+ `ccache` or `none`), `stimBuild` and, from a caller that saw `build-compat`, `compat`, and, for Android from a caller
1737
+ that saw `build-jdk`, `jdk` (the JDK major its Gradle runs on; the build runs with `JAVA_HOME` at an installed JDK of
1738
+ that major, and fails if there is none), after the whole manifest of `repo` was
1715
1739
  synced on this connection. A peer-cache-capable React Native caller can set
1716
1740
  `cacheArtifact: true` to retain a completed development archive when its cache
1717
1741
  write and remote policies allow it. With the `fingerprint-sources` feature, a
@@ -1728,7 +1752,8 @@ connected since stim-server started keeps its directory when revoked.
1728
1752
  `build-busy` while the offer would be declined, naming the reason, or while
1729
1753
  another build of the same client and repository runs, or while every one
1730
1754
  of this machine's `concurrency.maxBuilds` slots is held, and with
1731
- `build-refused` when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1755
+ `build-refused` when `compat` differs from this machine's, or, when either
1756
+ side has no `compat`, when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1732
1757
  `STIM_HOME` set to the repository's area. It makes the area's checkout
1733
1758
  hold exactly the manifest (a file git lists as untracked and not ignored,
1734
1759
  and not in the manifest, is deleted; ignored dependencies and generated
@@ -1940,7 +1965,7 @@ Events are `{ "event", "subscription", ... }`.
1940
1965
  retained logs and agent-device session actions locally through core, with the same JSON filters, ordering,
1941
1966
  markers and error context. Archive reads are synchronous and queued per
1942
1967
  connection, yielding to the event loop between requests. Pending archive
1943
- reads share the four-request limit with commands. Archived session actions
1968
+ reads share the connection's request limit with commands. Archived session actions
1944
1969
  retain available platform and device attribution; device ids and Stim slot
1945
1970
  assignments absent from retained files are unavailable after removal. They never follow files or use a hosted relay.
1946
1971
  An archive subscription sends its records in the same batches and limits,
@@ -2350,9 +2375,10 @@ Events are `{ "event", "subscription", ... }`.
2350
2375
 
2351
2376
  `workspace` is an environment `path` from a status payload. Any other path is
2352
2377
  refused with `unknown-workspace` and runs nothing. A connection holds at most
2353
- 32 subscriptions and runs at most 4 `logs.query`, `stats.get`,
2378
+ 32 subscriptions and runs at most 32 `logs.query`, `stats.get`,
2354
2379
  `settings.get`, `build.plan`, `workspace.files` and `workspace.diff` requests at
2355
- a time. `logs.query`, `stats.get` and `settings.get` fail after 60 seconds,
2380
+ a time, actions included. A tailnet device runs at most 16 across all its
2381
+ connections; more fail with `limit-exceeded`. `logs.query`, `stats.get` and `settings.get` fail after 60 seconds,
2356
2382
  `build.plan` after 150; these four fail at 32 MiB of output. Closing the
2357
2383
  connection stops running requests. When the command refuses with Stim's error
2358
2384
  contract on stdout, the `stim-failed` message is its code, message and remedy;
@@ -290,15 +290,21 @@ const swiftpmArtifactInputs = {
290
290
  }
291
291
  }
292
292
  };
293
+ const buildCompat = {
294
+ type: "integer",
295
+ minimum: 1,
296
+ description: "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
297
+ };
298
+ const stimIdentity = [{ required: ["stimBuild"] }, { required: ["compat"] }];
293
299
  const swiftpmArtifactQuery = {
294
300
  type: "object",
295
301
  required: [
296
302
  "project",
297
303
  "sourceDigest",
298
- "stimBuild",
299
304
  "inputs",
300
305
  "build"
301
306
  ],
307
+ oneOf: stimIdentity,
302
308
  additionalProperties: false,
303
309
  properties: {
304
310
  project: { type: "string" },
@@ -308,6 +314,7 @@ const swiftpmArtifactQuery = {
308
314
  minLength: 1,
309
315
  maxLength: 256
310
316
  },
317
+ compat: buildCompat,
311
318
  inputs: swiftpmArtifactInputs,
312
319
  build: {
313
320
  type: "object",
@@ -379,13 +386,14 @@ const reactNativeArtifactQuery = {
379
386
  platform: { enum: ["ios", "android"] },
380
387
  toolchain: {
381
388
  type: "object",
382
- required: ["stimBuild"],
389
+ oneOf: stimIdentity,
383
390
  additionalProperties: false,
384
391
  properties: {
385
392
  stimBuild: {
386
393
  type: "string",
387
394
  minLength: 1
388
395
  },
396
+ compat: buildCompat,
389
397
  arch: {
390
398
  type: "string",
391
399
  minLength: 1
@@ -2419,10 +2427,10 @@ function protocolJsonSchema() {
2419
2427
  }),
2420
2428
  request("desktop.reports.get", { $ref: "#/$defs/DesktopReportTarget" }),
2421
2429
  request("desktop.reports.subscribe", { $ref: "#/$defs/DesktopReportTarget" }),
2422
- optionalParams("desktop.reports.invalidate", {
2430
+ optionalParams("desktop.reports.invalidate", { oneOf: [{
2423
2431
  type: "object",
2424
2432
  maxProperties: 0
2425
- }),
2433
+ }, { $ref: "#/$defs/DesktopReportTarget" }] }),
2426
2434
  optionalParams("desktop.settings.schema", {
2427
2435
  type: "object",
2428
2436
  maxProperties: 0
@@ -3304,6 +3312,15 @@ function protocolJsonSchema() {
3304
3312
  stimBuild: {
3305
3313
  type: "string",
3306
3314
  minLength: 1
3315
+ },
3316
+ compat: {
3317
+ ...buildCompat,
3318
+ description: "Compared instead of stimBuild by a machine whose build.offer toolchain reports compat."
3319
+ },
3320
+ jdk: {
3321
+ type: "string",
3322
+ pattern: "^[0-9]{1,3}$",
3323
+ description: "The JDK major an Android build runs on, sent to a stim-server with the build-jdk feature."
3307
3324
  }
3308
3325
  }
3309
3326
  }),
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-B8Ztv1K4.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-nY-u8rtw.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -1280,9 +1280,12 @@
1280
1280
  "hosted-android-process",
1281
1281
  "hosted-metro-ports",
1282
1282
  "server-update",
1283
+ "build-compat",
1284
+ "build-jdk",
1283
1285
  "status-views",
1284
1286
  "desktop-reports",
1285
1287
  "desktop-report-platform",
1288
+ "desktop-report-invalidate-target",
1286
1289
  "desktop-settings",
1287
1290
  "desktop-settings-schema",
1288
1291
  "desktop-settings-write",
@@ -4029,8 +4032,15 @@
4029
4032
  "const": "desktop.reports.invalidate"
4030
4033
  },
4031
4034
  "params": {
4032
- "type": "object",
4033
- "maxProperties": 0
4035
+ "oneOf": [
4036
+ {
4037
+ "type": "object",
4038
+ "maxProperties": 0
4039
+ },
4040
+ {
4041
+ "$ref": "#/$defs/DesktopReportTarget"
4042
+ }
4043
+ ]
4034
4044
  }
4035
4045
  }
4036
4046
  },
@@ -6885,8 +6895,17 @@
6885
6895
  },
6886
6896
  "toolchain": {
6887
6897
  "type": "object",
6888
- "required": [
6889
- "stimBuild"
6898
+ "oneOf": [
6899
+ {
6900
+ "required": [
6901
+ "stimBuild"
6902
+ ]
6903
+ },
6904
+ {
6905
+ "required": [
6906
+ "compat"
6907
+ ]
6908
+ }
6890
6909
  ],
6891
6910
  "additionalProperties": false,
6892
6911
  "properties": {
@@ -6894,6 +6913,11 @@
6894
6913
  "type": "string",
6895
6914
  "minLength": 1
6896
6915
  },
6916
+ "compat": {
6917
+ "type": "integer",
6918
+ "minimum": 1,
6919
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
6920
+ },
6897
6921
  "arch": {
6898
6922
  "type": "string",
6899
6923
  "minLength": 1
@@ -7030,10 +7054,21 @@
7030
7054
  "required": [
7031
7055
  "project",
7032
7056
  "sourceDigest",
7033
- "stimBuild",
7034
7057
  "inputs",
7035
7058
  "build"
7036
7059
  ],
7060
+ "oneOf": [
7061
+ {
7062
+ "required": [
7063
+ "stimBuild"
7064
+ ]
7065
+ },
7066
+ {
7067
+ "required": [
7068
+ "compat"
7069
+ ]
7070
+ }
7071
+ ],
7037
7072
  "additionalProperties": false,
7038
7073
  "properties": {
7039
7074
  "project": {
@@ -7048,6 +7083,11 @@
7048
7083
  "minLength": 1,
7049
7084
  "maxLength": 256
7050
7085
  },
7086
+ "compat": {
7087
+ "type": "integer",
7088
+ "minimum": 1,
7089
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
7090
+ },
7051
7091
  "inputs": {
7052
7092
  "type": "object",
7053
7093
  "required": [
@@ -7652,6 +7692,16 @@
7652
7692
  "stimBuild": {
7653
7693
  "type": "string",
7654
7694
  "minLength": 1
7695
+ },
7696
+ "compat": {
7697
+ "type": "integer",
7698
+ "minimum": 1,
7699
+ "description": "Compared instead of stimBuild by a machine whose build.offer toolchain reports compat."
7700
+ },
7701
+ "jdk": {
7702
+ "type": "string",
7703
+ "pattern": "^[0-9]{1,3}$",
7704
+ "description": "The JDK major an Android build runs on, sent to a stim-server with the build-jdk feature."
7655
7705
  }
7656
7706
  }
7657
7707
  }
@@ -7934,8 +7984,17 @@
7934
7984
  },
7935
7985
  "toolchain": {
7936
7986
  "type": "object",
7937
- "required": [
7938
- "stimBuild"
7987
+ "oneOf": [
7988
+ {
7989
+ "required": [
7990
+ "stimBuild"
7991
+ ]
7992
+ },
7993
+ {
7994
+ "required": [
7995
+ "compat"
7996
+ ]
7997
+ }
7939
7998
  ],
7940
7999
  "additionalProperties": false,
7941
8000
  "properties": {
@@ -7943,6 +8002,11 @@
7943
8002
  "type": "string",
7944
8003
  "minLength": 1
7945
8004
  },
8005
+ "compat": {
8006
+ "type": "integer",
8007
+ "minimum": 1,
8008
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
8009
+ },
7946
8010
  "arch": {
7947
8011
  "type": "string",
7948
8012
  "minLength": 1
@@ -8079,10 +8143,21 @@
8079
8143
  "required": [
8080
8144
  "project",
8081
8145
  "sourceDigest",
8082
- "stimBuild",
8083
8146
  "inputs",
8084
8147
  "build"
8085
8148
  ],
8149
+ "oneOf": [
8150
+ {
8151
+ "required": [
8152
+ "stimBuild"
8153
+ ]
8154
+ },
8155
+ {
8156
+ "required": [
8157
+ "compat"
8158
+ ]
8159
+ }
8160
+ ],
8086
8161
  "additionalProperties": false,
8087
8162
  "properties": {
8088
8163
  "project": {
@@ -8097,6 +8172,11 @@
8097
8172
  "minLength": 1,
8098
8173
  "maxLength": 256
8099
8174
  },
8175
+ "compat": {
8176
+ "type": "integer",
8177
+ "minimum": 1,
8178
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
8179
+ },
8100
8180
  "inputs": {
8101
8181
  "type": "object",
8102
8182
  "required": [
@@ -1,4 +1,4 @@
1
- import { n as protocol_exports } from "./protocol-B8Ztv1K4.mjs";
1
+ import { n as protocol_exports } from "./protocol-nY-u8rtw.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn } from "node:child_process";
4
4
  import { appendFileSync, chmodSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, futimesSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, statfsSync, watch, writeFileSync, writeSync } from "node:fs";
@@ -661,16 +661,16 @@ function validateServeEnvironment(env, pathPrepend, serving = false) {
661
661
  * environment launchd gave the process; one that `source` lacks is skipped with a line on stderr. `STIM_HOME` and
662
662
  * `SHELL` entries are skipped the same way: the job takes them from the installing process.
663
663
  */
664
- function applyServeEnvironment(base, env, pathPrepend, source = process.env) {
664
+ function applyServeEnvironment(base, env, pathPrepend, source = process.env, warn = console.error) {
665
665
  const result = { ...base };
666
666
  for (const entry of env) {
667
667
  const parsed = parseEnvAssignment(entry);
668
668
  const name = typeof parsed === "string" ? entry : parsed.key;
669
- if (JOB_KEYS.includes(name)) console.error(`stim-server: ignoring --env ${name}; the service takes it from the process that installed it.`);
669
+ if (JOB_KEYS.includes(name)) warn(`stim-server: ignoring --env ${name}; the service takes it from the process that installed it.`);
670
670
  else if (typeof parsed !== "string") result[name] = parsed.value;
671
671
  else if (ENV_KEY_PATTERN.test(entry)) {
672
672
  const value = source[entry];
673
- if (value === void 0) console.error(`stim-server: --env ${entry} has no value in the service environment; skipping it.`);
673
+ if (value === void 0) warn(`stim-server: --env ${entry} has no value in the service environment; skipping it.`);
674
674
  else result[entry] = value;
675
675
  }
676
676
  }
@@ -3283,7 +3283,7 @@ function macosBuildOptions(params) {
3283
3283
  const artifactQuery = options.artifactInputs === void 0 ? null : swiftpmArtifactQuery({
3284
3284
  project: params.project,
3285
3285
  sourceDigest: params.fingerprint,
3286
- stimBuild: params.stimBuild,
3286
+ ...typeof params.compat === "number" ? { compat: params.compat } : { stimBuild: params.stimBuild },
3287
3287
  inputs: options.artifactInputs,
3288
3288
  build: {
3289
3289
  product: options.product,
@@ -3457,6 +3457,9 @@ var BuildHost = class {
3457
3457
  } catch {}
3458
3458
  }
3459
3459
  }
3460
+ environment() {
3461
+ return this.options.currentEnv?.() ?? this.options.env;
3462
+ }
3460
3463
  root() {
3461
3464
  return buildWorkerRoot();
3462
3465
  }
@@ -3472,7 +3475,7 @@ var BuildHost = class {
3472
3475
  const value = new Promise((resolve) => {
3473
3476
  const args = native ? [`offer-native-${native}`] : ["offer", ...rubyVersion ? [rubyVersion] : []];
3474
3477
  const child = spawn(process.execPath, [this.options.worker, ...args], {
3475
- env: this.options.env,
3478
+ env: this.environment(),
3476
3479
  stdio: [
3477
3480
  "ignore",
3478
3481
  "pipe",
@@ -3548,7 +3551,7 @@ var BuildHost = class {
3548
3551
  },
3549
3552
  warm: {
3550
3553
  checkout: existsSync(join(area, "src")),
3551
- dependencies: typeof params.lockfile === "string" && dependencies !== void 0 && reusableDependencies(area, dependencies.project, params.lockfile, this.options.env),
3554
+ dependencies: typeof params.lockfile === "string" && dependencies !== void 0 && reusableDependencies(area, dependencies.project, params.lockfile, this.environment()),
3552
3555
  build: false
3553
3556
  },
3554
3557
  ...artifact !== void 0 ? { artifact } : {}
@@ -3559,7 +3562,7 @@ var BuildHost = class {
3559
3562
  }
3560
3563
  workerEnvironment(clientDir, area) {
3561
3564
  return {
3562
- ...this.options.env,
3565
+ ...this.environment(),
3563
3566
  STIM_HOME: join(area, "home"),
3564
3567
  CP_HOME_DIR: join(clientDir, "cache", "cocoapods-home"),
3565
3568
  CP_CACHE_DIR: join(clientDir, "cache", "cocoapods"),
@@ -3636,7 +3639,7 @@ var BuildHost = class {
3636
3639
  const query = swiftpmArtifactQuery({
3637
3640
  project: job.project,
3638
3641
  sourceDigest: job.expectedFingerprint,
3639
- stimBuild: job.stimBuild,
3642
+ ...typeof job.compat === "number" ? { compat: job.compat } : { stimBuild: job.stimBuild },
3640
3643
  inputs: options.artifactInputs,
3641
3644
  build: {
3642
3645
  product: options.product,
@@ -4352,7 +4355,11 @@ var BuildSession = class {
4352
4355
  for (const file of this.files.values()) if (!existsSync(this.blobPath(file.sha256))) return refusal("bad-request", `The blob of ${file.path} is missing; sync again.`);
4353
4356
  const toolchain = await this.host.toolchain(null, native?.provider);
4354
4357
  if (this.closed) return refusal("build-refused", "The connection closed.");
4355
- if (!toolchain || toolchain.stimBuild !== params.stimBuild) return refusal("build-refused", `This machine runs Stim build ${toolchain?.stimBuild ?? "unknown"}.`);
4358
+ const compat = typeof params.compat === "number" ? params.compat : void 0;
4359
+ if (!toolchain) return refusal("build-refused", "This machine runs Stim build unknown.");
4360
+ if (compat !== void 0 && toolchain.compat !== void 0) {
4361
+ if (toolchain.compat !== compat) return refusal("build-refused", `This machine runs Stim build ${toolchain.stimBuild ?? "unknown"} with build compatibility ${toolchain.compat}, not ${compat}.`);
4362
+ } else if (toolchain.stimBuild !== params.stimBuild) return refusal("build-refused", `This machine runs Stim build ${toolchain.stimBuild ?? "unknown"}.`);
4356
4363
  const launched = this.host.launch({
4357
4364
  client: this.client,
4358
4365
  repo: params.repo,
@@ -4368,6 +4375,8 @@ var BuildSession = class {
4368
4375
  isExpo: params.isExpo === true,
4369
4376
  cacheArtifact: params.cacheArtifact === true,
4370
4377
  stimBuild: params.stimBuild,
4378
+ ...compat !== void 0 && toolchain.compat !== void 0 ? { compat } : {},
4379
+ ...typeof params.jdk === "string" ? { jdk: params.jdk } : {},
4371
4380
  configuration: optional(params.configuration),
4372
4381
  scheme: optional(params.scheme),
4373
4382
  iosProjectPath: optional(params.iosProjectPath),
@@ -4967,7 +4976,7 @@ function toolChecks(tools, stimBuild, platform) {
4967
4976
  ],
4968
4977
  [
4969
4978
  "Android SDK",
4970
- !!tools.androidSdk?.platforms.length && !!tools.androidSdk.buildTools.length && !!tools.androidSdk.ndk.length,
4979
+ !!tools.androidSdk,
4971
4980
  tools.androidSdk ? JSON.stringify(tools.androidSdk) : "Missing Android SDK",
4972
4981
  platform === "linux" ? "Install the Android SDK in ~/Android/Sdk, or rerun setup with --env ANDROID_HOME=<sdk>" : platform === "win32" ? "Install the Android SDK in %LOCALAPPDATA%\\Android\\Sdk, or rerun setup with --env ANDROID_HOME=<sdk>" : "Install Android Studio or set ANDROID_HOME"
4973
4982
  ],
@@ -5309,7 +5318,8 @@ async function runSetup(args, version, deps) {
5309
5318
  try {
5310
5319
  deps.storeSecret(opts.ticket, approved, opts.expiresAt, deps.now());
5311
5320
  secretStored = true;
5312
- if (deps.fromDoctor) printer.line("Waiting for the requesting machine to reconnect. Run `stim doctor` on that machine to finish.");
5321
+ const waiting = deps.fromDoctor ? "Waiting for the requesting machine to reconnect. Run `stim doctor` on that machine to finish." : "Waiting for the requesting machine to connect. Stim Desktop sends this automatically.";
5322
+ step("approve", "running", "Access approval", waiting, void 0, { running: waiting });
5313
5323
  while (deps.now() < Date.parse(opts.expiresAt)) {
5314
5324
  changed = false;
5315
5325
  for (const grant of recorded(approved)) note(grant, false);
@@ -12131,6 +12141,7 @@ var UsageRecorder = class {
12131
12141
  //#endregion
12132
12142
  //#region src/desktop-reports.ts
12133
12143
  const MAX_REPORT_READS = 4;
12144
+ const reportKey = (target) => target.report === "gc" ? "gc" : `doctor:${target.platform ?? "all"}:${target.cwd}`;
12134
12145
  var DesktopReports = class {
12135
12146
  entries = /* @__PURE__ */ new Map();
12136
12147
  closed = false;
@@ -12201,9 +12212,13 @@ var DesktopReports = class {
12201
12212
  }
12202
12213
  };
12203
12214
  }
12204
- invalidate() {
12205
- for (const entry of this.entries.values()) {
12215
+ /** Invalidates `target`, or every target without one. */
12216
+ invalidate(target) {
12217
+ const only = target && this.entries.get(reportKey(target));
12218
+ if (target && !only) return;
12219
+ for (const entry of only ? [only] : this.entries.values()) {
12206
12220
  entry.generation++;
12221
+ entry.pending?.abort.abort();
12207
12222
  clearTimeout(entry.timer);
12208
12223
  entry.timer = void 0;
12209
12224
  if (entry.listeners.size && !entry.pending) this.start(entry);
@@ -12219,7 +12234,7 @@ var DesktopReports = class {
12219
12234
  }
12220
12235
  entry(target) {
12221
12236
  for (const [key, entry] of this.entries) if (!entry.readers && !entry.listeners.size && !entry.pending && (!entry.settled || this.now() - entry.settled.at >= this.ttlMs)) this.entries.delete(key);
12222
- const key = target.report === "gc" ? "gc" : `doctor:${target.platform ?? "all"}:${target.cwd}`;
12237
+ const key = reportKey(target);
12223
12238
  let entry = this.entries.get(key);
12224
12239
  if (!entry) {
12225
12240
  entry = {
@@ -12242,6 +12257,7 @@ var DesktopReports = class {
12242
12257
  const admitted = this.activeLoads < MAX_REPORT_READS;
12243
12258
  if (admitted) this.activeLoads++;
12244
12259
  const generation = entry.generation;
12260
+ const abort = new AbortController();
12245
12261
  const pending = {
12246
12262
  promise: Promise.resolve().then(() => {
12247
12263
  if (this.closed) return {
@@ -12252,7 +12268,7 @@ var DesktopReports = class {
12252
12268
  ok: false,
12253
12269
  message: `The server is already running ${MAX_REPORT_READS} shared reports. Try again after a report finishes.`
12254
12270
  };
12255
- return this.load(entry.target);
12271
+ return this.load(entry.target, abort.signal);
12256
12272
  }).catch((cause) => ({
12257
12273
  ok: false,
12258
12274
  message: cause instanceof Error ? cause.message : String(cause)
@@ -12273,7 +12289,8 @@ var DesktopReports = class {
12273
12289
  this.publish(entry);
12274
12290
  this.schedule(entry);
12275
12291
  }),
12276
- generation
12292
+ generation,
12293
+ abort
12277
12294
  };
12278
12295
  entry.pending = pending;
12279
12296
  this.publish(entry);
@@ -13782,7 +13799,12 @@ function lastCursor(page) {
13782
13799
  return last ? archiveCursor(last) : null;
13783
13800
  }
13784
13801
  const STATUS_READ_WAIT_MS = 3e4;
13785
- const MAX_COMMANDS = 4;
13802
+ const REQUEST_LIMITS = {
13803
+ connection: 32,
13804
+ device: 16
13805
+ };
13806
+ const MAX_REPORT_WAITS = 16;
13807
+ const MAX_RETAINED_PLANS = 4;
13786
13808
  function isWorkspaceChangeMethod(method) {
13787
13809
  return method === "workspace.files" || method === "workspace.diff";
13788
13810
  }
@@ -14433,6 +14455,11 @@ async function startServer(options) {
14433
14455
  return foldBuild;
14434
14456
  };
14435
14457
  const running = /* @__PURE__ */ new Set();
14458
+ const peerCommands = /* @__PURE__ */ new Map();
14459
+ const requestLimits = {
14460
+ ...REQUEST_LIMITS,
14461
+ ...options.requestLimits
14462
+ };
14436
14463
  const cancelling = new Pending();
14437
14464
  const logLimits = {
14438
14465
  ...LOG_LIMITS,
@@ -14449,8 +14476,10 @@ async function startServer(options) {
14449
14476
  const busyWorkspaces = /* @__PURE__ */ new Set();
14450
14477
  const retainedLifecycles = /* @__PURE__ */ new Set();
14451
14478
  const planQueues = /* @__PURE__ */ new Map();
14452
- const readDetailsReport = (method, cwd = homedir(), platform) => {
14479
+ const readDetailsReport = (method, cwd = homedir(), platform, signal) => {
14453
14480
  const abort = new AbortController();
14481
+ if (signal?.aborted) abort.abort();
14482
+ else signal?.addEventListener("abort", () => abort.abort(), { once: true });
14454
14483
  const timeoutMs = options.commandLimits?.timeoutMs ?? DETAILS_TIMEOUT_MS;
14455
14484
  let task;
14456
14485
  const cancel = async () => {
@@ -14463,10 +14492,14 @@ async function startServer(options) {
14463
14492
  projectRoot: cwd,
14464
14493
  env: options.env
14465
14494
  });
14466
- return method === "doctor" ? await stim.doctor({
14495
+ if (method === "doctor") return await stim.doctor({
14467
14496
  signal: abort.signal,
14468
14497
  platform
14469
- }) : await stim[method]({ signal: abort.signal });
14498
+ });
14499
+ return method === "gc" ? await stim.gc({
14500
+ signal: abort.signal,
14501
+ measureBuildState: false
14502
+ }) : await stim.stats({ signal: abort.signal });
14470
14503
  }).then((report) => {
14471
14504
  if (method !== "stats" && Buffer.byteLength(JSON.stringify(report)) > commandLimits.maxOutputBytes) return {
14472
14505
  ok: false,
@@ -14486,7 +14519,7 @@ async function startServer(options) {
14486
14519
  running.add(cancel);
14487
14520
  return task;
14488
14521
  };
14489
- const desktopReports = new DesktopReports((target) => readDetailsReport(target.report, target.report === "doctor" ? target.cwd : void 0, target.report === "doctor" ? target.platform : void 0));
14522
+ const desktopReports = new DesktopReports((target, signal) => readDetailsReport(target.report, target.report === "doctor" ? target.cwd : void 0, target.report === "doctor" ? target.platform : void 0, signal));
14490
14523
  const machineDetails = new MachineDetailsCache(() => loadMachineDetails(() => readDetailsReport("gc"), loadAudit, () => readDetailsReport("stats")));
14491
14524
  const buildMachines = new BuildMachinesCache();
14492
14525
  let recordingTurn = Promise.resolve();
@@ -14578,6 +14611,7 @@ async function startServer(options) {
14578
14611
  const builds = new BuildHost({
14579
14612
  worker: join(dirname(options.stimCli), "offload-worker.mjs"),
14580
14613
  env: options.env,
14614
+ currentEnv: options.currentEnv,
14581
14615
  limits: options.buildLimits,
14582
14616
  ready: () => startup.state === "ready",
14583
14617
  finished: ({ client, repo, ok, error, durationMs }) => auditSafely({
@@ -14758,6 +14792,19 @@ async function startServer(options) {
14758
14792
  const replays = /* @__PURE__ */ new Map();
14759
14793
  let keyframeReads = 0;
14760
14794
  const commands = /* @__PURE__ */ new Set();
14795
+ const reportWaits = /* @__PURE__ */ new Set();
14796
+ const holdSlot = (cancel) => {
14797
+ commands.add(cancel);
14798
+ if (peer !== null) peerCommands.set(peer, (peerCommands.get(peer) ?? 0) + 1);
14799
+ };
14800
+ const releaseSlot = (cancel) => {
14801
+ if (!commands.delete(cancel) || peer === null) return;
14802
+ const left = (peerCommands.get(peer) ?? 1) - 1;
14803
+ if (left > 0) peerCommands.set(peer, left);
14804
+ else peerCommands.delete(peer);
14805
+ };
14806
+ const slotsFull = (needed = 1) => commands.size + needed > requestLimits.connection || peer !== null && (peerCommands.get(peer) ?? 0) + needed > requestLimits.device;
14807
+ const slotsMessage = (needed = 1) => peer !== null && commands.size + needed <= requestLimits.connection ? `A remote device can run ${requestLimits.device} requests at a time.` : `A connection can run ${requestLimits.connection} requests at a time.`;
14761
14808
  const planRequests = /* @__PURE__ */ new Map();
14762
14809
  const persistentActions = /* @__PURE__ */ new Set();
14763
14810
  let nextSubscription = 1;
@@ -14857,6 +14904,7 @@ async function startServer(options) {
14857
14904
  "desktop-plan",
14858
14905
  "desktop-reports",
14859
14906
  "desktop-report-platform",
14907
+ "desktop-report-invalidate-target",
14860
14908
  "desktop-settings",
14861
14909
  "desktop-settings-schema",
14862
14910
  "desktop-settings-write",
@@ -15550,18 +15598,18 @@ async function startServer(options) {
15550
15598
  subscriptions.set(subscription, cleanup);
15551
15599
  }
15552
15600
  function archiveRead(id, read) {
15553
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15601
+ if (slotsFull()) return error(id, "limit-exceeded", slotsMessage());
15554
15602
  let dropped = false;
15555
15603
  const cancel = async () => {
15556
15604
  dropped = true;
15557
15605
  await task;
15558
15606
  };
15559
- commands.add(cancel);
15607
+ holdSlot(cancel);
15560
15608
  running.add(cancel);
15561
15609
  const task = archiveReads.then(() => new Promise((resolve) => setImmediate(resolve))).then(() => {
15562
15610
  if (!dropped && socket.readyState === socket.OPEN) return read();
15563
15611
  }).finally(() => {
15564
- commands.delete(cancel);
15612
+ releaseSlot(cancel);
15565
15613
  running.delete(cancel);
15566
15614
  });
15567
15615
  archiveReads = task.catch(() => socket.close(1011, "internal error"));
@@ -15593,14 +15641,14 @@ async function startServer(options) {
15593
15641
  if (!isJsonObject(params) || typeof params.workspace !== "string" || !params.workspace.length || params.workspace.length > 4096 || params.workspace.includes("\0") || !needsPath && params.group !== "changed" && params.group !== "untracked" || needsPath && (typeof params.path !== "string" || !params.path.length || params.path.length > 4096 || params.path.includes("\0")) || Object.keys(params).some((key) => key !== "workspace" && !(needsPath ? key === "path" : key === "group"))) return error(id, "bad-request", "Workspace diff requires workspace and, for a patch, a file path.");
15594
15642
  const cwd = workspaceDir(id, params.workspace, true);
15595
15643
  if (!cwd) return;
15596
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15644
+ if (slotsFull()) return error(id, "limit-exceeded", slotsMessage());
15597
15645
  const abort = new AbortController();
15598
15646
  let task;
15599
15647
  const cancel = async () => {
15600
15648
  abort.abort();
15601
15649
  await task;
15602
15650
  };
15603
- commands.add(cancel);
15651
+ holdSlot(cancel);
15604
15652
  running.add(cancel);
15605
15653
  task = readWorkspaceDiff(cwd, needsPath ? params.path : void 0, options.env, abort.signal, !needsPath ? params.group : void 0).then((result) => {
15606
15654
  if (!abort.signal.aborted) send(socket, {
@@ -15610,13 +15658,13 @@ async function startServer(options) {
15610
15658
  }, (cause) => {
15611
15659
  if (!abort.signal.aborted) error(id, "stim-failed", cause.message);
15612
15660
  }).finally(() => {
15613
- commands.delete(cancel);
15661
+ releaseSlot(cancel);
15614
15662
  running.delete(cancel);
15615
15663
  });
15616
15664
  }
15617
- function inProcessRead(id, label, read, timeoutMs = commandLimits.timeoutMs, turn = null) {
15618
- if (commands.size >= MAX_COMMANDS) {
15619
- error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15665
+ function inProcessRead(id, label, read, timeoutMs = commandLimits.timeoutMs, turn = null, report = false) {
15666
+ if (report ? reportWaits.size >= MAX_REPORT_WAITS : slotsFull()) {
15667
+ error(id, "limit-exceeded", report ? `A connection can wait for ${MAX_REPORT_WAITS} reports at a time.` : slotsMessage());
15620
15668
  return null;
15621
15669
  }
15622
15670
  const abort = new AbortController();
@@ -15640,14 +15688,16 @@ async function startServer(options) {
15640
15688
  if (!abort.signal.aborted) error(id, "stim-failed", readFailure(cause));
15641
15689
  }).finally(() => {
15642
15690
  clearTimeout(deadline);
15643
- commands.delete(cancel);
15691
+ releaseSlot(cancel);
15692
+ reportWaits.delete(cancel);
15644
15693
  running.delete(cancel);
15645
15694
  });
15646
15695
  const cancel = async () => {
15647
15696
  abort.abort();
15648
15697
  await task;
15649
15698
  };
15650
- commands.add(cancel);
15699
+ if (report) reportWaits.add(cancel);
15700
+ else holdSlot(cancel);
15651
15701
  running.add(cancel);
15652
15702
  return {
15653
15703
  done: task,
@@ -15716,7 +15766,7 @@ async function startServer(options) {
15716
15766
  const { action } = parsed;
15717
15767
  const resolved = registeredWorkspace(action.workspace);
15718
15768
  if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
15719
- if (commands.size >= MAX_COMMANDS) return refuseAction("limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15769
+ if (slotsFull()) return refuseAction("limit-exceeded", slotsMessage());
15720
15770
  if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
15721
15771
  let admittedCwd = resolved.dir;
15722
15772
  let run;
@@ -15750,7 +15800,7 @@ async function startServer(options) {
15750
15800
  let finished = false;
15751
15801
  const release = (outcome) => {
15752
15802
  if (outcome.unresolved) return;
15753
- commands.delete(cancel);
15803
+ releaseSlot(cancel);
15754
15804
  persistentActions.delete(cancel);
15755
15805
  running.delete(cancel);
15756
15806
  busyWorkspaces.delete(resolved.dir);
@@ -15788,7 +15838,7 @@ async function startServer(options) {
15788
15838
  await run.cancel();
15789
15839
  release(await run.drained);
15790
15840
  };
15791
- commands.add(cancel);
15841
+ holdSlot(cancel);
15792
15842
  persistentActions.add(cancel);
15793
15843
  running.add(cancel);
15794
15844
  run.outcome.then((outcome) => finish(!outcome.ok && "code" in outcome && outcome.code === "STIM_ACTION_BUSY" ? {
@@ -15915,7 +15965,7 @@ async function startServer(options) {
15915
15965
  } catch (cause) {
15916
15966
  return error(id, "bad-request", `Cannot reload this workspace: ${readFailure(cause)}`);
15917
15967
  }
15918
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15968
+ if (slotsFull()) return error(id, "limit-exceeded", slotsMessage());
15919
15969
  if (busyWorkspaces.has(cwd)) return error(id, "action-busy", `An action is already running in ${cwd}.`);
15920
15970
  const subscription = openSubscription(id);
15921
15971
  if (!subscription) return;
@@ -15956,7 +16006,7 @@ async function startServer(options) {
15956
16006
  }
15957
16007
  });
15958
16008
  const cancel = () => run.cancel();
15959
- commands.add(cancel);
16009
+ holdSlot(cancel);
15960
16010
  running.add(cancel);
15961
16011
  busyWorkspaces.add(cwd);
15962
16012
  subscriptions.set(subscription, () => {
@@ -15966,7 +16016,7 @@ async function startServer(options) {
15966
16016
  run.outcome.then(finish);
15967
16017
  run.drained.then((outcome) => {
15968
16018
  if (!outcome.unresolved) {
15969
- commands.delete(cancel);
16019
+ releaseSlot(cancel);
15970
16020
  running.delete(cancel);
15971
16021
  busyWorkspaces.delete(cwd);
15972
16022
  busyWorkspaces.delete(admittedCwd);
@@ -16084,7 +16134,7 @@ async function startServer(options) {
16084
16134
  } catch (cause) {
16085
16135
  return error(id, "bad-request", `Cannot ${kind} this workspace: ${readFailure(cause)}`);
16086
16136
  }
16087
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
16137
+ if (slotsFull()) return error(id, "limit-exceeded", slotsMessage());
16088
16138
  if (busyWorkspaces.has(cwd)) return error(id, "action-busy", `An action is already running in ${cwd}.`);
16089
16139
  const subscription = openSubscription(id);
16090
16140
  if (!subscription) return;
@@ -16125,7 +16175,7 @@ async function startServer(options) {
16125
16175
  const cancel = async () => {
16126
16176
  await run.cancel();
16127
16177
  };
16128
- commands.add(cancel);
16178
+ holdSlot(cancel);
16129
16179
  running.add(cancel);
16130
16180
  busyWorkspaces.add(cwd);
16131
16181
  subscriptions.set(subscription, async () => {
@@ -16140,7 +16190,7 @@ async function startServer(options) {
16140
16190
  run.drained.then((outcome) => {
16141
16191
  if (kind === "gc" || kind === "worktree-remove" || kind === "worktree-warm" || kind === "machine-access") desktopReports.invalidate();
16142
16192
  const release = () => {
16143
- commands.delete(cancel);
16193
+ releaseSlot(cancel);
16144
16194
  running.delete(cancel);
16145
16195
  busyWorkspaces.delete(cwd);
16146
16196
  for (const workspace of admittedWorkspaces) busyWorkspaces.delete(workspace);
@@ -16194,7 +16244,7 @@ async function startServer(options) {
16194
16244
  } catch (cause) {
16195
16245
  return error(id, "bad-request", `Cannot change settings here: ${readFailure(cause)}`);
16196
16246
  }
16197
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
16247
+ if (slotsFull()) return error(id, "limit-exceeded", slotsMessage());
16198
16248
  if (busyWorkspaces.has(cwd)) return error(id, "action-busy", `An action is already running in ${cwd}.`);
16199
16249
  const { key, action } = params;
16200
16250
  const value = typeof params.value === "string" ? params.value : void 0;
@@ -16282,7 +16332,7 @@ async function startServer(options) {
16282
16332
  } });
16283
16333
  }).finally(() => {
16284
16334
  clearTimeout(deadline);
16285
- commands.delete(cancel);
16335
+ releaseSlot(cancel);
16286
16336
  running.delete(cancel);
16287
16337
  busyWorkspaces.delete(cwd);
16288
16338
  busyWorkspaces.delete(admittedCwd);
@@ -16308,7 +16358,7 @@ async function startServer(options) {
16308
16358
  abort.abort();
16309
16359
  await task;
16310
16360
  };
16311
- commands.add(cancel);
16361
+ holdSlot(cancel);
16312
16362
  running.add(cancel);
16313
16363
  busyWorkspaces.add(cwd);
16314
16364
  }
@@ -16320,7 +16370,7 @@ async function startServer(options) {
16320
16370
  abort.abort();
16321
16371
  await task;
16322
16372
  };
16323
- commands.add(cancel);
16373
+ holdSlot(cancel);
16324
16374
  running.add(cancel);
16325
16375
  task = Promise.resolve().then(() => createStim({
16326
16376
  projectRoot: cwd,
@@ -16330,7 +16380,7 @@ async function startServer(options) {
16330
16380
  signal: abort.signal
16331
16381
  })).then((records) => Buffer.byteLength(records.map((record) => JSON.stringify(record)).join("\n")) > commandLimits.maxOutputBytes ? `stim logs printed more than ${commandLimits.maxOutputBytes} bytes.` : records, (cause) => readFailure(cause)).finally(() => {
16332
16382
  clearTimeout(deadline);
16333
- commands.delete(cancel);
16383
+ releaseSlot(cancel);
16334
16384
  running.delete(cancel);
16335
16385
  });
16336
16386
  return task;
@@ -16372,7 +16422,7 @@ async function startServer(options) {
16372
16422
  archive
16373
16423
  });
16374
16424
  if (!cwd) return;
16375
- if (archive === void 0 && commands.size + 2 > MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
16425
+ if (archive === void 0 && slotsFull(2)) return error(id, "limit-exceeded", slotsMessage(2));
16376
16426
  const slotName = slot ?? "default";
16377
16427
  const spans = recordedSpans(listSegments(recordingDir(workspace, platform, slotName, archive), archive !== void 0));
16378
16428
  const replayTarget = {
@@ -16563,7 +16613,7 @@ async function startServer(options) {
16563
16613
  }, "build.plan");
16564
16614
  }
16565
16615
  function queuePlan(id, cwd, plan, method, request) {
16566
- if (request !== void 0 && planRequests.size >= MAX_COMMANDS) {
16616
+ if (request !== void 0 && planRequests.size >= MAX_RETAINED_PLANS) {
16567
16617
  error(id, "limit-exceeded", "Acknowledge retained plan refusals before requesting more previews.");
16568
16618
  return;
16569
16619
  }
@@ -16826,8 +16876,7 @@ async function startServer(options) {
16826
16876
  }).settings({ signal }));
16827
16877
  }
16828
16878
  function desktopReport(id, method, params) {
16829
- if (method === "desktop.reports.invalidate") {
16830
- if (params !== void 0 && (!isJsonObject(params) || Object.keys(params).length)) return error(id, "bad-request", "desktop.reports.invalidate takes no parameters.");
16879
+ if (method === "desktop.reports.invalidate" && (params === void 0 || isJsonObject(params) && !Object.keys(params).length)) {
16831
16880
  desktopReports.invalidate();
16832
16881
  return send(socket, {
16833
16882
  id,
@@ -16840,12 +16889,19 @@ async function startServer(options) {
16840
16889
  cwd: resolve(String(params.cwd)),
16841
16890
  ...params.platform === "ios" || params.platform === "android" ? { platform: params.platform } : {}
16842
16891
  };
16892
+ if (method === "desktop.reports.invalidate") {
16893
+ desktopReports.invalidate(target);
16894
+ return send(socket, {
16895
+ id,
16896
+ result: {}
16897
+ });
16898
+ }
16843
16899
  if (method === "desktop.reports.get") {
16844
16900
  inProcessRead(id, `stim ${target.report}`, async (signal) => {
16845
16901
  const snapshot = await desktopReports.get(target, signal);
16846
16902
  if (snapshot.error) throw new Error(snapshot.error);
16847
16903
  return snapshot;
16848
- }, options.commandLimits?.timeoutMs ?? DETAILS_TIMEOUT_MS);
16904
+ }, options.commandLimits?.timeoutMs ?? DETAILS_TIMEOUT_MS, null, true);
16849
16905
  return;
16850
16906
  }
16851
16907
  const subscription = openSubscription(id);
@@ -17217,12 +17273,14 @@ async function startServer(options) {
17217
17273
  controllers.delete(socket);
17218
17274
  for (const unsubscribe of subscriptions.values()) unsubscribe();
17219
17275
  subscriptions.clear();
17220
- for (const cancel of commands) {
17221
- if (persistentActions.has(cancel)) continue;
17276
+ for (const cancel of [...commands, ...reportWaits]) {
17277
+ if (persistentActions.has(cancel)) {
17278
+ releaseSlot(cancel);
17279
+ continue;
17280
+ }
17222
17281
  running.delete(cancel);
17223
17282
  cancelling.track(cancel());
17224
17283
  }
17225
- commands.clear();
17226
17284
  });
17227
17285
  }
17228
17286
  function upgrade(request, socket, head) {
@@ -17410,7 +17468,13 @@ async function startServer(options) {
17410
17468
  return;
17411
17469
  }
17412
17470
  if (request.method === "GET" && request.url === "/health" && peerAddress(request) !== null && request.headers.origin === void 0 && request.headers["sec-fetch-site"] === void 0) {
17413
- const peerHealth = { version: health.version };
17471
+ const peerHealth = {
17472
+ version: health.version,
17473
+ ...startup.state === "ready" ? {} : { startup: startup.state === "degraded" ? {
17474
+ state: "degraded",
17475
+ cause: startup.cause
17476
+ } : { state: "pending" } }
17477
+ };
17414
17478
  response.writeHead(startup.state === "ready" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(peerHealth));
17415
17479
  return;
17416
17480
  }
@@ -17595,6 +17659,7 @@ async function startServer(options) {
17595
17659
  }
17596
17660
  startupProbe = probeDirectories(directories, options.env, options.startupProbeMs ?? 1e4, configDir());
17597
17661
  let reason = await startupProbe.result;
17662
+ let cause = "home-unreadable";
17598
17663
  startupProbe = null;
17599
17664
  if (closing) return settle(startup);
17600
17665
  if (reason === null) try {
@@ -17606,6 +17671,7 @@ async function startServer(options) {
17606
17671
  becomeReady();
17607
17672
  } catch (error) {
17608
17673
  reason = error.message;
17674
+ cause = "start-failed";
17609
17675
  }
17610
17676
  if (reason === null) {
17611
17677
  if (startup.state === "degraded") console.error("stim-server: the Stim home answers again; serving clients.");
@@ -17630,7 +17696,8 @@ async function startServer(options) {
17630
17696
  if (startup.state !== "degraded" || startup.reason !== reason) console.error(`stim-server: listening, but not serving clients until the Stim home answers. ${reason}`);
17631
17697
  startup = {
17632
17698
  state: "degraded",
17633
- reason
17699
+ reason,
17700
+ cause
17634
17701
  };
17635
17702
  settle(startup);
17636
17703
  startupRetry = setTimeout(() => void checkHome(), options.startupRetryMs ?? 3e4);
@@ -17781,12 +17848,21 @@ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
17781
17848
  const desktopInput = bootstrapFd === "0" ? await readDesktopBootstrap(process.stdin).catch(() => fail("Desktop bootstrap input is invalid or unavailable.")) : void 0;
17782
17849
  delete process.env.NODE_COMPILE_CACHE;
17783
17850
  const captureHost = hostFromExecutable(process.env.STIM_HOST_EXECUTABLE);
17784
- const env = applyServeEnvironment(discoverToolchain(process.env).env, extraEnv, pathPrepend);
17785
- delete env.CLAUDE_CODE_SESSION_ID;
17786
- delete env.CODEX_THREAD_ID;
17851
+ const inherited = { ...process.env };
17852
+ const serveEnvironment = (warn) => {
17853
+ const found = applyServeEnvironment(discoverToolchain(inherited).env, extraEnv, pathPrepend, process.env, warn);
17854
+ delete found.CLAUDE_CODE_SESSION_ID;
17855
+ delete found.CODEX_THREAD_ID;
17856
+ return found;
17857
+ };
17858
+ const env = serveEnvironment();
17787
17859
  if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
17788
17860
  if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
17789
17861
  Object.assign(process.env, env);
17862
+ const currentEnv = () => ({
17863
+ ...serveEnvironment(() => {}),
17864
+ ...env.STIM_HOME ? { STIM_HOME: env.STIM_HOME } : {}
17865
+ });
17790
17866
  const loaded = await loadTransport({
17791
17867
  reference: "@stim-cli/transport-tailscale",
17792
17868
  load: () => import("@stim-cli/transport-tailscale"),
@@ -17813,6 +17889,7 @@ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
17813
17889
  stimVersion: stim.version,
17814
17890
  serverVersion: pkg.version,
17815
17891
  env,
17892
+ currentEnv,
17816
17893
  transports: [transport],
17817
17894
  service: {
17818
17895
  label: serviceManagerOrNull()?.runningLabel(process.env) ?? null,
@@ -4,6 +4,6 @@ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e
4
4
  if (encode(process.versions.node) < encode("22.12.0")) {
5
5
  process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
6
6
  process.exitCode = 1;
7
- } else import("./stim-server-Z40-_Git.mjs");
7
+ } else import("./stim-server-DU1htjam.mjs");
8
8
  //#endregion
9
9
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,13 +28,13 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/agent-driver": "^1.24.0",
32
- "@stim-cli/agent-driver-agent-device": "^1.24.0",
33
- "@stim-cli/cache": "^1.24.0",
34
- "@stim-cli/core": "^1.24.0",
35
- "@stim-cli/transport": "^1.24.0",
36
- "@stim-cli/transport-tailscale": "^1.24.0",
37
- "stim": "1.24.0",
31
+ "@stim-cli/agent-driver": "1.26.0",
32
+ "@stim-cli/agent-driver-agent-device": "1.26.0",
33
+ "@stim-cli/cache": "1.26.0",
34
+ "@stim-cli/core": "1.26.0",
35
+ "@stim-cli/transport": "1.26.0",
36
+ "@stim-cli/transport-tailscale": "1.26.0",
37
+ "stim": "1.26.0",
38
38
  "ws": "^8.21.3"
39
39
  },
40
40
  "devDependencies": {