@solidrt/cli 0.0.53 → 0.0.54

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/AGENTS.md CHANGED
@@ -11,7 +11,9 @@ bundled `flux` runtime, not on Bun. Invoke via `bunx srt <command>`.
11
11
  The dev loop against a running app is pause_watch -> edit -> reload ->
12
12
  resume_watch -> get_logs -> get_snapshot, with mute_user_input while you
13
13
  measure or test and unmute_user_input after; agents/debugging.md has the
14
- why of each hold. `reload` surfaces build errors but not type errors:
14
+ why of each hold. Several clients may be attached at once: `reload` reaches
15
+ all of them, while call_debug / send_input / get_snapshot are per client
16
+ (debugging.md). `reload` surfaces build errors but not type errors:
15
17
  `bunx srt check` is for those.
16
18
 
17
19
  agents/ carries the depth this one leaves out; read the one that matches
@@ -156,20 +156,26 @@ when exactly one client is connected.
156
156
  window root.
157
157
  - `/texture?id=<textureId>` - same shape and options as `/snapshot`, at the
158
158
  texture's native size (a scene or shader target behind a `<texture>` leaf).
159
- - `/gpu?label=<text>` - the GPU resource inventory; `label` keeps only the
160
- resources created with exactly that label (ids change on reload, labels
161
- do not).
159
+ - `/gpu?label=<text>&draw=<id>` - the GPU resource inventory; `label` keeps
160
+ only the resources created with exactly that label (ids change on reload,
161
+ labels do not). A draw target's entries report uniforms wider than a vec4
162
+ (matrices) as their length, `"[16]"`; `draw` names the one entry (ids are
163
+ per target, so pair it with `label`) reported in full.
162
164
  - `/buffer?id=<bufferId>&offset=<n>&length=<n>&as=<f32|u16|u8>` - vertex
163
165
  buffer contents (default f32; reads cap at 64 KiB).
164
166
  - `/stats?window=<ms>` - the performance statistics. POST
165
167
  `/stats?active=true|false` switches the on-screen stats overlay instead
166
168
  (the `set_stats_overlay` tool): one client with `&client=<id>`, every
167
169
  client (and the setting new clients join with) without; `/clients`
168
- reports each client's `stats`. `frames` counts the frames actually
169
- rebuilt in the window, `fps` the refresh rate presented at: when motion
170
- looks wrong and `fps` looks fine, `frames` is the number to read - a
171
- picture that only changes 26 times a second shows 26 there, and the
172
- stutter is the app's update cadence, not the engine's.
170
+ reports each client's `stats`. `frames` counts the frames that changed
171
+ the picture: tree rebuilds, plus GPU content changes presented without
172
+ one (a layer write, a shader param, an upload - a sprite or shader app
173
+ rebuilds nothing, every frame of it is one of these).
174
+ `fps` is the refresh rate presented at: when motion looks wrong and `fps`
175
+ looks fine, `frames` is the number to read - a picture that only changes
176
+ 26 times a second shows 26 there, and the stutter is the app's update
177
+ cadence, not the engine's. `frames: 0` means the picture did not change
178
+ at all in the window.
173
179
  - `/debug` - the app's registered debug commands; POST
174
180
  `/debug?name=<cmd>` with a JSON body as its args to call one.
175
181
  - POST `/input` with `{ "events": [...] }` - synthetic input through the
@@ -263,6 +269,21 @@ The loop is the same as over MCP: `/reload`, then `/logs?since=`, then
263
269
  Math.min(dt, cap) lets it through, and one bad frame can corrupt anything
264
270
  integrated from dt (positions fly off, accumulators go so negative they
265
271
  never recover). Math.max(0, Math.min(dt, cap)) costs nothing.
272
+ - A fixed-timestep simulation on that clamped dt still drifts: no panel
273
+ presents at exactly its nominal rate (a "60 Hz" panel measured 60.3) and
274
+ the paced tick tracks the real cadence, so a 16.667 ms step against a
275
+ 16.59 ms average dt comes up one step short every few seconds - one frame
276
+ runs no step (freeze), the next runs two (jump), and frame jitter
277
+ scatters which frame it lands on, so it reads as random stutter. The
278
+ runtime hands every callback the refresh rate,
279
+ `onFrame((tick, frame, rate) => ...)` (SDL's nominal Hz): when the step is
280
+ within a few percent of `1000 / rate`, run whole steps per frame
281
+ (`Math.round(dt / STEP_MS)`, clamped to [0, cap]) so the world rides the
282
+ refresh; only accumulate (`acc += dt; while (acc >= STEP_MS) ...`) when
283
+ the display is genuinely off-rate (50, 120, 144 Hz), or interpolate the
284
+ render by `acc / STEP_MS` if the game does not snap to whole pixels. It
285
+ is invisible in a five-second look and survives every renderer
286
+ optimisation; measure it with a steps-per-frame histogram, not by eye.
266
287
  - A registered onFrame is a standing request, not demand-gated: it re-requests
267
288
  the next frame every time it runs, so the runtime keeps calling it - and
268
289
  presents - every frame at the refresh rate until you deregister it (fps
@@ -1,4 +1,4 @@
1
- {"appId":"com.solidrt.console","org":"Console","displayName":"Console","icon":"assets/icon.svg","runtimeVersion":1,"solidrtVersion":"0.0.53","bundle":{"path":"bundle.bin","sha256":"61bad7f5442a7dc7eff593762727075a0678c7ef8ff47b637bdc425083829629","size":873391},"assets":[{"path":"assets/fonts/NotoSans.ttf","sha256":"bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d","size":2049096},{"path":"assets/fonts/NotoSansMono.ttf","sha256":"2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081","size":1708408},{"path":"assets/icon.svg","sha256":"fc78e748eefeade49c5c4a74944d9f43b8d7a7c61e7003123b8b13fedb7f1050","size":466}],"fonts":[{"path":"assets/fonts/NotoSans.ttf","alias":"sans"},{"path":"assets/fonts/NotoSansMono.ttf","alias":"mono"}]}�a�Z�mainflux:rendertreesrt:rendersrt:eventssrt:appflux:gpuflux:imageflux:svgflux:fsflux:processflux:subprocessrequestFramesetPointerLockrenderFrameononceexitdepthTexturedestroyTextureendBufferWriteresizeTexturesetTargetParamssetTargetRectsetTargetSize"setTargetTexturesuploadTexturecopyTexturedestroyBufferrenderTargetsetDrawaddDrawremoveDrawsetDrawBufferssetDrawOrdersetDrawParamssetDrawRangesetDrawTextures limitscompileShader(createRenderPipelinedestroyProgram*destroyRenderPipelinedestroyShaderlinkProgram"programAttributescaptureSnapshotreadTexturedecodeImageencodeImageparseSvgdirfile
1
+ {"appId":"com.solidrt.console","org":"Console","displayName":"Console","icon":"assets/icon.svg","runtimeVersion":1,"solidrtVersion":"0.0.54","bundle":{"path":"bundle.bin","sha256":"61bad7f5442a7dc7eff593762727075a0678c7ef8ff47b637bdc425083829629","size":873391},"assets":[{"path":"assets/fonts/NotoSans.ttf","sha256":"bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d","size":2049096},{"path":"assets/fonts/NotoSansMono.ttf","sha256":"2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081","size":1708408},{"path":"assets/icon.svg","sha256":"fc78e748eefeade49c5c4a74944d9f43b8d7a7c61e7003123b8b13fedb7f1050","size":466}],"fonts":[{"path":"assets/fonts/NotoSans.ttf","alias":"sans"},{"path":"assets/fonts/NotoSansMono.ttf","alias":"mono"}]}�a�Z�mainflux:rendertreesrt:rendersrt:eventssrt:appflux:gpuflux:imageflux:svgflux:fsflux:processflux:subprocessrequestFramesetPointerLockrenderFrameononceexitdepthTexturedestroyTextureendBufferWriteresizeTexturesetTargetParamssetTargetRectsetTargetSize"setTargetTexturesuploadTexturecopyTexturedestroyBufferrenderTargetsetDrawaddDrawremoveDrawsetDrawBufferssetDrawOrdersetDrawParamssetDrawRangesetDrawTextures limitscompileShader(createRenderPipelinedestroyProgram*destroyRenderPipelinedestroyShaderlinkProgram"programAttributescaptureSnapshotreadTexturedecodeImageencodeImageparseSvgdirfile
2
2
  aliveexecPathhomedirplatformcommandNotReadyErrorStatusErrorNoOwnerError(ContextNotFoundError
3
3
  QueueGlobalQueueCollectionQueue
4
4
  tree2on2tree
@@ -5112,7 +5112,7 @@ function releaseGeometryBuffers(acquired) {
5112
5112
  }
5113
5113
 
5114
5114
  // ../src/material.ts
5115
- var VERTEX_SRC = glsl`
5115
+ var UNLIT_VERTEX = glsl`
5116
5116
  in vec3 aPos;
5117
5117
  in vec2 aUV;
5118
5118
  out vec2 vUv;
@@ -5124,21 +5124,7 @@ var VERTEX_SRC = glsl`
5124
5124
  vUv = aUV;
5125
5125
  }
5126
5126
  `;
5127
- var FRAGMENT_COLOR_SRC = glsl`
5128
- uniform vec4 uColor;
5129
- void main() {
5130
- fragColor = uColor;
5131
- }
5132
- `;
5133
- var FRAGMENT_MAP_SRC = glsl`
5134
- in vec2 vUv;
5135
- uniform sampler2D uMap;
5136
- uniform vec4 uColor;
5137
- void main() {
5138
- fragColor = texture(uMap, vUv) * uColor;
5139
- }
5140
- `;
5141
- var pipelines = new Map;
5127
+ var unlitClasses = new Map;
5142
5128
  var litClasses = new Map;
5143
5129
  var SHADOW_DEPTH_VERTEX = glsl`
5144
5130
  in vec3 aPos;
@@ -5153,18 +5139,48 @@ var SHADOW_DEPTH_FRAGMENT = glsl`
5153
5139
  fragColor = vec4(1.0);
5154
5140
  }
5155
5141
  `;
5156
- var shadowDepth;
5157
- function shadowDepthMaterial() {
5158
- if (shadowDepth === undefined) {
5159
- shadowDepth = shaderMaterialClass({
5142
+ var shadowDepth = new Map;
5143
+ function shadowDepthMaterial(cull = "front") {
5144
+ let material = shadowDepth.get(cull);
5145
+ if (material === undefined) {
5146
+ material = shaderMaterialClass({
5160
5147
  vertex: SHADOW_DEPTH_VERTEX,
5161
5148
  fragment: SHADOW_DEPTH_FRAGMENT,
5162
- cull: "front",
5163
- label: "scene-shadow-depth"
5149
+ cull,
5150
+ label: "scene-shadow-depth-" + cull
5164
5151
  }).instance();
5152
+ shadowDepth.set(cull, material);
5165
5153
  }
5166
- return shadowDepth;
5154
+ return material;
5155
+ }
5156
+ function shadowCull(cull) {
5157
+ return cull === "none" ? "none" : cull === "back" ? "front" : "back";
5167
5158
  }
5159
+ function shadowVariant(cull) {
5160
+ return cull === "back" ? undefined : shadowDepthMaterial(shadowCull(cull));
5161
+ }
5162
+ var SHADOW_CUTOUT_VERTEX = glsl`
5163
+ in vec3 aPos;
5164
+ in vec2 aUV;
5165
+ out vec2 vUv;
5166
+ uniform mat4 uModel;
5167
+ uniform mat4 uViewProj;
5168
+ void main() {
5169
+ gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
5170
+ vUv = aUV;
5171
+ }
5172
+ `;
5173
+ var SHADOW_CUTOUT_FRAGMENT = glsl`
5174
+ in vec2 vUv;
5175
+ uniform sampler2D uMap;
5176
+ uniform vec4 uColor;
5177
+ uniform float uAlphaTest;
5178
+ void main() {
5179
+ if (texture(uMap, vUv).a * uColor.a < uAlphaTest) discard;
5180
+ fragColor = vec4(1.0);
5181
+ }
5182
+ `;
5183
+ var shadowCutout = new Map;
5168
5184
  var SPRITE_VERTEX_SRC = glsl`
5169
5185
  in vec3 aPos;
5170
5186
  in vec2 aUV;
@@ -5256,10 +5272,11 @@ function shaderMaterialClass(opts) {
5256
5272
  }
5257
5273
  }
5258
5274
  let program;
5259
- let pipelines2 = new Map;
5275
+ let pipelines = new Map;
5260
5276
  let normalMatrix2 = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment);
5261
5277
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none");
5262
5278
  let depth = opts.depth ?? true;
5279
+ let cull = opts.cull ?? "back";
5263
5280
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map((a) => ({
5264
5281
  ...a
5265
5282
  })) : undefined;
@@ -5282,7 +5299,7 @@ function shaderMaterialClass(opts) {
5282
5299
  let attributes = () => programAttributes(programFor()).filter((a) => !instanceAttributes?.some((i) => i.name === a.name));
5283
5300
  let pipelineFor = (layout) => {
5284
5301
  let key = layoutKey(layout);
5285
- let pipeline = pipelines2.get(key);
5302
+ let pipeline = pipelines.get(key);
5286
5303
  if (pipeline === undefined) {
5287
5304
  pipeline = createRenderPipeline(programFor(), {
5288
5305
  attributes: layoutAttributes(layout),
@@ -5290,11 +5307,11 @@ function shaderMaterialClass(opts) {
5290
5307
  depth,
5291
5308
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
5292
5309
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
5293
- cull: opts.cull ?? "back",
5310
+ cull,
5294
5311
  topology: opts.topology,
5295
5312
  label: opts.label
5296
5313
  });
5297
- pipelines2.set(key, pipeline);
5314
+ pipelines.set(key, pipeline);
5298
5315
  }
5299
5316
  return pipeline;
5300
5317
  };
@@ -5307,13 +5324,16 @@ function shaderMaterialClass(opts) {
5307
5324
  instanceAttributes,
5308
5325
  pipeline: pipelineFor,
5309
5326
  params: inst.params ?? {},
5310
- textures: inst.textures
5327
+ textures: inst.textures,
5328
+ get shadow() {
5329
+ return inst.shadow ?? shadowVariant(cull);
5330
+ }
5311
5331
  };
5312
5332
  },
5313
5333
  dispose() {
5314
- for (let pipeline of pipelines2.values())
5334
+ for (let pipeline of pipelines.values())
5315
5335
  destroyRenderPipeline(pipeline);
5316
- pipelines2.clear();
5336
+ pipelines.clear();
5317
5337
  if (program !== undefined) {
5318
5338
  destroyProgram(program);
5319
5339
  program = undefined;
@@ -5971,7 +5991,7 @@ function createScene(width, height, opts) {
5971
5991
  return;
5972
5992
  if (v.filter !== null && !v.filter(mesh))
5973
5993
  return;
5974
- let material = v.override ?? mesh.material;
5994
+ let material = v.override !== null ? v.filter !== null ? mesh.material.shadow ?? v.override : v.override : mesh.material;
5975
5995
  let bufs = mesh._buffers;
5976
5996
  let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
5977
5997
  buffer: bufs.buffer,
package/dist/server.js CHANGED
@@ -1083,6 +1083,7 @@ function clientList(withAddress = false) {
1083
1083
  os: info.os,
1084
1084
  kernel: info.kernel,
1085
1085
  videoDriver: info.videoDriver,
1086
+ refreshRate: info.refreshRate,
1086
1087
  gpu: info.gpu,
1087
1088
  ...withAddress ? { address: ws.remoteAddr ?? null } : {}
1088
1089
  }));
@@ -1335,8 +1336,14 @@ async function handleControl(req, path, query) {
1335
1336
  return handleQuery(query, "snapshot", extra);
1336
1337
  }
1337
1338
  case "/__control__/gpu": {
1339
+ let extra = {};
1338
1340
  let label = query.get("label");
1339
- return handleQuery(query, "gpu", label === undefined ? undefined : { label });
1341
+ if (label !== undefined)
1342
+ extra.label = label;
1343
+ let draw = parseInt(query.get("draw") ?? "", 10);
1344
+ if (Number.isFinite(draw))
1345
+ extra.draw = draw;
1346
+ return handleQuery(query, "gpu", extra);
1340
1347
  }
1341
1348
  case "/__control__/debug": {
1342
1349
  if (req.method !== "POST")
@@ -3765,6 +3772,7 @@ function onOpen(ws) {
3765
3772
  os: null,
3766
3773
  kernel: null,
3767
3774
  videoDriver: null,
3775
+ refreshRate: null,
3768
3776
  gpu: null
3769
3777
  });
3770
3778
  console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`);
@@ -3777,8 +3785,6 @@ function onClose(ws) {
3777
3785
  let info = state.clients.get(ws);
3778
3786
  state.clients.delete(ws);
3779
3787
  console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`);
3780
- if (config.client && localClientExited && state.clients.size === 0)
3781
- shutdown();
3782
3788
  }
3783
3789
  function onMessage(ws, msg) {
3784
3790
  try {
@@ -3801,6 +3807,7 @@ function onMessage(ws, msg) {
3801
3807
  os: text(data.os),
3802
3808
  kernel: text(data.kernel),
3803
3809
  videoDriver: text(data.videoDriver),
3810
+ refreshRate: typeof data.refreshRate === "number" ? data.refreshRate : null,
3804
3811
  gpu: data.gpu && typeof data.gpu === "object" ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" } : null
3805
3812
  });
3806
3813
  console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`);
@@ -3870,7 +3877,6 @@ var keepalive = setInterval(() => {
3870
3877
  var shuttingDown = false;
3871
3878
  var stopRepl = () => {};
3872
3879
  var localClient = null;
3873
- var localClientExited = false;
3874
3880
  var signalOffs = ["SIGINT", "SIGTERM"].map((signal) => onSignal(signal, () => {
3875
3881
  shutdown();
3876
3882
  }));
@@ -3924,13 +3930,7 @@ if (config.client) {
3924
3930
  pump(child.stderr, (line) => console.error(line));
3925
3931
  child.status().then(() => {
3926
3932
  localClient = null;
3927
- localClientExited = true;
3928
- if (shuttingDown)
3929
- return;
3930
- if (state.clients.size === 0) {
3931
- shutdown();
3932
- } else {
3933
- console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`);
3934
- }
3933
+ if (!shuttingDown)
3934
+ console.log("[cli] Local client exited; the server keeps running (srt client reattaches)");
3935
3935
  });
3936
3936
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -30,16 +30,16 @@
30
30
  "zod": "^4.4.3"
31
31
  },
32
32
  "optionalDependencies": {
33
- "@solidrt/darwin-arm64": "0.0.53",
34
- "@solidrt/linux-arm64-gnu": "0.0.53",
35
- "@solidrt/linux-x64-gnu": "0.0.53",
36
- "@solidrt/win32-x64-msvc": "0.0.53"
33
+ "@solidrt/darwin-arm64": "0.0.54",
34
+ "@solidrt/linux-arm64-gnu": "0.0.54",
35
+ "@solidrt/linux-x64-gnu": "0.0.54",
36
+ "@solidrt/win32-x64-msvc": "0.0.54"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "typescript": "^7"
40
40
  },
41
41
  "devDependencies": {
42
- "@solidrt/flux-types": "0.0.53",
42
+ "@solidrt/flux-types": "0.0.54",
43
43
  "@types/babel__core": "^7.20.5",
44
44
  "@types/bun": "latest"
45
45
  }
@@ -8,8 +8,11 @@ file) in the current directory, or `--port`. A running instance is restarted
8
8
  so it picks the server up. The command then waits a few seconds for the
9
9
  client to appear on the server and reports its client id. The server must
10
10
  run with `--lan` so the device can reach it; an emulator reaches a loopback
11
- server through its host alias. `--device` picks one of several connected
12
- devices by serial or unique prefix.
11
+ server through its host alias. Without a running server the client starts
12
+ on its own, into the launcher (`--port` must name a live server). With several
13
+ devices connected a terminal asks which ones (all preselected, so enter
14
+ launches on every device); `--device` picks one by serial or unique prefix
15
+ (a script must).
13
16
 
14
17
  `--install` installs (or updates) the client first, from the
15
18
  `@solidrt/android-<abi>` dev dependency matching the device's ABI; the
@@ -3,6 +3,8 @@ import { networkInterfaces } from "node:os"
3
3
  import { resolve } from "node:path"
4
4
  import { androidPackageVersion, resolveApk, ANDROID_PKG_MAP } from "../lib/artifacts"
5
5
  import { values, port } from "../lib/args"
6
+ import { CLI_VERSION } from "../lib/project"
7
+ import { multiselect } from "../lib/prompt"
6
8
  import { resolveByPort, resolveFromCwd } from "../lib/registry"
7
9
  import type { LiveRecord } from "../types/registry"
8
10
 
@@ -97,14 +99,19 @@ function devServerAddress(adb: string, target: string, server: LiveRecord): stri
97
99
  }
98
100
 
99
101
  // The dev server the device should dial: --port picks a local server by
100
- // port, otherwise the project (or file) in the current directory.
101
- async function resolveServer(): Promise<LiveRecord> {
102
- let resolved = port !== undefined ? await resolveByPort(port) : await resolveFromCwd(process.cwd())
103
- if (!resolved.ok) {
104
- console.error(resolved.message)
105
- process.exit(1)
102
+ // port (and must exist), otherwise the project (or file) in the current
103
+ // directory, or none: the client then starts on its own, into the launcher.
104
+ async function resolveServer(): Promise<LiveRecord | null> {
105
+ if (port !== undefined) {
106
+ let resolved = await resolveByPort(port)
107
+ if (!resolved.ok) {
108
+ console.error(resolved.message)
109
+ process.exit(1)
110
+ }
111
+ return resolved.record
106
112
  }
107
- return resolved.record
113
+ let resolved = await resolveFromCwd(process.cwd())
114
+ return resolved.ok ? resolved.record : null
108
115
  }
109
116
 
110
117
  // Serials of connected, authorized devices (excludes offline/unauthorized).
@@ -132,25 +139,39 @@ function deviceAbi(adb: string, target: string): string {
132
139
  return res.stdout.toString().trim()
133
140
  }
134
141
 
135
- // Always shown up front, regardless of how many devices are connected or
136
- // whether --device narrows it down: one line per device with the ABI its APK
137
- // build would target, then (with --install) a dev-dependency hint for each
138
- // ABI that isn't resolvable locally yet. Returns the device->abi map so
139
- // callers don't have to re-query it.
140
- function printDeviceStatus(adb: string, devices: string[]): Map<string, string> {
141
- let abiByDevice = new Map<string, string>()
142
- let missingAbis = new Set<string>()
143
- for (let d of devices) {
144
- let abi = deviceAbi(adb, d)
145
- abiByDevice.set(d, abi)
146
- console.log(`${d} - ${abi}`)
147
- if (!resolveApk(abi)) missingAbis.add(abi)
142
+ // One line per connected device with the ABI its APK build would target;
143
+ // the picker shows the same lines, so this is for the cases without one.
144
+ function printDeviceStatus(devices: string[], abiByDevice: Map<string, string>) {
145
+ for (let d of devices) console.log(`${d} - ${abiByDevice.get(d)}`)
146
+ }
147
+
148
+ // A published CLI version (x.y.z): the release action publishes the CLI and
149
+ // the android packages at one version, so that is the one to pin. A checkout
150
+ // reports a git describe (or the 0.0.0 placeholder), which npm does not have.
151
+ let RELEASE_VERSION = /^\d+\.\d+\.\d+$/
152
+
153
+ // The APK for `abi`, adding the project's @solidrt/android-<abi> dev
154
+ // dependency in the cwd first when it is not installed. ABIs without a
155
+ // published package (e.g. x86) only resolve through SRT_HOME.
156
+ function ensureApk(abi: string): string {
157
+ let apk = resolveApk(abi)
158
+ if (apk) return apk
159
+ let pkg = ANDROID_PKG_MAP[abi]
160
+ if (!pkg) {
161
+ console.error(`Could not find a SolidRT-Go APK for ABI "${abi}".`)
162
+ process.exit(1)
148
163
  }
149
- let missingPkgs = [...missingAbis].map((abi) => ANDROID_PKG_MAP[abi]).filter((pkg): pkg is string => Boolean(pkg))
150
- if (values.install && missingPkgs.length > 0) {
151
- console.log(`Add dev dependencies with: bun add -d ${missingPkgs.join(" ")}`)
164
+ let spec = RELEASE_VERSION.test(CLI_VERSION) && CLI_VERSION !== "0.0.0" ? `${pkg}@${CLI_VERSION}` : pkg
165
+ console.log(`[cli] Adding dev dependency ${spec}`)
166
+ let add = Bun.spawnSync(["bun", "add", "-d", spec], { cwd: process.cwd(), stdout: "inherit", stderr: "inherit" })
167
+ if (add.exitCode !== 0) {
168
+ console.error(`Could not add ${spec}; retry with bun add -d ${spec}`)
169
+ process.exit(1)
152
170
  }
153
- return abiByDevice
171
+ apk = resolveApk(abi)
172
+ if (apk) return apk
173
+ console.error(`${pkg} is installed but carries no solidrt-go.apk for ABI "${abi}".`)
174
+ process.exit(1)
154
175
  }
155
176
 
156
177
  // The versionName of the client installed on `target`, null when none is.
@@ -171,25 +192,31 @@ async function connectedClients(server: LiveRecord): Promise<Client[]> {
171
192
  }
172
193
  }
173
194
 
174
- // The first Android client that appears beyond `before`, or null after ~10 s.
175
- async function waitForClient(server: LiveRecord, before: Set<number>): Promise<Client | null> {
176
- for (let attempt = 0; attempt < 20; attempt++) {
195
+ // The Android clients that appear beyond `before`: all of them once `count`
196
+ // have, else whatever showed up within ~10 s.
197
+ async function waitForClients(server: LiveRecord, before: Set<number>, count: number): Promise<Client[]> {
198
+ let fresh: Client[] = []
199
+ for (let attempt = 0; attempt < 20 && fresh.length < count; attempt++) {
177
200
  await sleep(500)
178
- let fresh = (await connectedClients(server)).find((c) => c.platform === "android" && !before.has(c.id))
179
- if (fresh) return fresh
201
+ fresh = (await connectedClients(server)).filter((c) => c.platform === "android" && !before.has(c.id))
180
202
  }
181
- return null
203
+ return fresh
182
204
  }
183
205
 
184
- // Resolve the target device serial (and its ABI). With --device, treat the
206
+ type Device = { target: string; abi: string }
207
+
208
+ // Resolve the target devices (serial and ABI). With --device, treat the
185
209
  // value as a serial prefix and require it to match exactly one connected
186
- // device; without it, use the sole connected device. Exits with a clear
187
- // message on any ambiguity.
188
- function resolveTarget(adb: string): { target: string; abi: string } {
210
+ // device; without it, use the sole connected device, or pick any number of
211
+ // several on a terminal (all preselected: enter means every device). Exits
212
+ // with a clear message on any ambiguity.
213
+ async function resolveTargets(adb: string): Promise<Device[]> {
189
214
  let devices = listDevices(adb)
190
- let abiByDevice = printDeviceStatus(adb, devices)
215
+ let abiByDevice = new Map(devices.map((d) => [d, deviceAbi(adb, d)]))
216
+ let device = (target: string): Device => ({ target, abi: abiByDevice.get(target)! })
191
217
 
192
218
  if (values.device) {
219
+ printDeviceStatus(devices, abiByDevice)
193
220
  let prefix = values.device
194
221
  let matches = devices.filter((d) => d.startsWith(prefix))
195
222
  if (matches.length > 1) {
@@ -201,86 +228,108 @@ function resolveTarget(adb: string): { target: string; abi: string } {
201
228
  console.error(`No connected device matches --device "${prefix}".`)
202
229
  process.exit(1)
203
230
  }
204
- return { target: match, abi: abiByDevice.get(match)! }
231
+ return [device(match)]
205
232
  }
206
233
 
207
234
  if (devices.length > 1) {
208
- console.error("Pick one with --device <serial or prefix>.")
209
- process.exit(1)
235
+ // The prompt's non-TTY default is no way to pick devices, so a script has
236
+ // to say which.
237
+ if (!process.stdin.isTTY) {
238
+ printDeviceStatus(devices, abiByDevice)
239
+ console.error("Pick one with --device <serial or prefix>.")
240
+ process.exit(1)
241
+ }
242
+ let picked = await multiselect(
243
+ "Pick devices",
244
+ devices.map((d) => ({ label: `${d} - ${abiByDevice.get(d)}`, value: d, checked: true })),
245
+ )
246
+ if (picked.length === 0) {
247
+ console.error("No device picked.")
248
+ process.exit(1)
249
+ }
250
+ return picked.map(device)
210
251
  }
252
+ printDeviceStatus(devices, abiByDevice)
211
253
  let [only] = devices
212
254
  if (!only) {
213
255
  console.error("No authorized Android device found. Enable USB debugging and check `adb devices`.")
214
256
  process.exit(1)
215
257
  }
216
- return { target: only, abi: abiByDevice.get(only)! }
258
+ return [device(only)]
217
259
  }
218
260
 
219
- // Launch the Android client on a connected device over adb (installing it
220
- // first on --install), passing it the dev-server address to dial as a
221
- // launch-intent extra (see devServerAddress), then wait briefly for it to show
222
- // up on the dev server. The client is not a child process here: its lifecycle
223
- // is the WS connect/disconnect the server sees.
224
- export async function main() {
225
- let server = await resolveServer()
226
- let adb = requireAdb()
227
-
228
- let { target, abi } = resolveTarget(adb)
229
-
261
+ // Install the client on `target` (on --install), else check that one is there
262
+ // and note when its version is not the one the project's package carries.
263
+ async function prepare(adb: string, { target, abi }: Device) {
230
264
  if (values.install) {
231
- let apk = resolveApk(abi)
232
- if (!apk) {
233
- console.error(`Could not find a SolidRT-Go APK for ABI "${abi}".`)
234
- let pkg = ANDROID_PKG_MAP[abi]
235
- if (pkg) console.error(`Add it with: bun add -d ${pkg}`)
236
- process.exit(1)
237
- }
265
+ let apk = ensureApk(abi)
238
266
  console.log(`[cli] Installing SolidRT-Go on ${target}`)
239
267
  let install = Bun.spawn([adb, "-s", target, "install", "-r", apk], { stdout: "pipe", stderr: "pipe" })
240
268
  if ((await install.exited) !== 0) {
241
269
  console.error("adb install failed:\n" + (await new Response(install.stderr).text()))
242
270
  process.exit(1)
243
271
  }
244
- } else {
245
- let installed = installedVersion(adb, target)
246
- if (installed === null) {
247
- console.error(`No SolidRT-Go client on ${target}; install one with srt android --install`)
248
- process.exit(1)
249
- }
250
- let expected = androidPackageVersion(abi)
251
- if (expected !== null && expected !== installed) {
252
- console.log(
253
- `[cli] Installed client is ${installed}; the project's ${ANDROID_PKG_MAP[abi]} is ${expected} (srt android --install updates it)`,
254
- )
255
- }
272
+ return
273
+ }
274
+ let installed = installedVersion(adb, target)
275
+ if (installed === null) {
276
+ console.error(`No SolidRT-Go client on ${target}; install one with srt android --install`)
277
+ process.exit(1)
256
278
  }
279
+ let expected = androidPackageVersion(abi)
280
+ if (expected !== null && expected !== installed) {
281
+ console.log(
282
+ `[cli] Installed client is ${installed}; the project's ${ANDROID_PKG_MAP[abi]} is ${expected} (srt android --install updates it)`,
283
+ )
284
+ }
285
+ }
257
286
 
258
- // Hand the client the dev-server address to dial, as a launch-intent extra that
259
- // MainActivity forwards to native argv (--dev-server); the client auto-connects
260
- // to it. Replaces adb reverse, which never worked over wireless adb. -S stops
261
- // a running instance first: a delivered intent does not reach one, so without
262
- // it the client would keep whatever server it had.
263
- let before = new Set((await connectedClients(server)).map((c) => c.id))
264
- let devServer = devServerAddress(adb, target, server)
287
+ // Launch the client on `target`, handing it the dev-server address to dial as
288
+ // a launch-intent extra that MainActivity forwards to native argv
289
+ // (--dev-server); the client auto-connects to it. Replaces adb reverse, which
290
+ // never worked over wireless adb. -S stops a running instance first: a
291
+ // delivered intent does not reach one, so without it the client would keep
292
+ // whatever server it had. With no server there is no extra to pass.
293
+ async function launch(adb: string, { target }: Device, server: LiveRecord | null) {
294
+ let devServer = server ? devServerAddress(adb, target, server) : null
265
295
  let launchArgs = [adb, "-s", target, "shell", "am", "start", "-S", "-n", PACKAGE_ACTIVITY]
266
296
  if (devServer) {
267
- console.log(`[cli] Client will dial dev server at ${devServer}`)
297
+ console.log(`[cli] Client on ${target} will dial dev server at ${devServer}`)
268
298
  launchArgs.push("--es", "srt_dev_server", devServer)
269
- } else {
270
- console.log("[cli] Could not resolve a host address for the device; client will need a manual/QR connect")
299
+ } else if (server) {
300
+ console.log(`[cli] Could not resolve a host address for ${target}; client will need a manual/QR connect`)
271
301
  }
272
-
273
302
  let start = Bun.spawn(launchArgs, { stdout: "pipe", stderr: "pipe" })
274
303
  if ((await start.exited) !== 0) {
275
304
  console.error("adb start failed:\n" + (await new Response(start.stderr).text()))
276
305
  process.exit(1)
277
306
  }
307
+ console.log(`[cli] Launched SolidRT-Go on ${target}`)
308
+ }
309
+
310
+ // Launch the Android client on the connected devices over adb (installing it
311
+ // first on --install), then wait briefly for them to show up on the dev
312
+ // server. The clients are not child processes here: their lifecycle is the
313
+ // WS connect/disconnect the server sees.
314
+ export async function main() {
315
+ let server = await resolveServer()
316
+ let adb = requireAdb()
317
+
318
+ let devices = await resolveTargets(adb)
319
+ for (let device of devices) await prepare(adb, device)
320
+
321
+ let before = new Set(server ? (await connectedClients(server)).map((c) => c.id) : [])
322
+ for (let device of devices) await launch(adb, device, server)
323
+ if (!server) return
278
324
 
279
- console.log(`[cli] Launched SolidRT-Go on ${target}; waiting for it to connect to the dev server...`)
280
- let client = await waitForClient(server, before)
281
- if (client) {
325
+ console.log("[cli] Waiting for the client(s) to connect to the dev server...")
326
+ let clients = await waitForClients(server, before, devices.length)
327
+ for (let client of clients) {
282
328
  console.log(`[cli] Client ${client.id} connected (${client.platform}, ${client.version})`)
283
- } else {
284
- console.log(`[cli] No connection after 10 s. The server must run with --lan and the device must reach this machine.`)
285
329
  }
286
- }
330
+ if (clients.length < devices.length) {
331
+ console.log(
332
+ `[cli] ${devices.length - clients.length} of ${devices.length} not connected after 10 s. The server must run with --lan and the device must reach this machine.`,
333
+ )
334
+ }
335
+ }
@@ -6,4 +6,6 @@ The client half of [srt run](../server/docs.md), on its own. Without flags
6
6
  it attaches to the dev server of the project (or file) in the current
7
7
  directory; `--port` picks a local server by port and `--server` names any
8
8
  address, which is how a second machine joins a server started with `--lan`.
9
- A phone or tablet is [srt android](../android/docs.md).
9
+ Without a running server the client starts on its own, into the launcher
10
+ (`--port` and `--server` must name a live server). A phone or tablet is
11
+ [srt android](../android/docs.md).
@@ -4,13 +4,14 @@ import { resolveFromCwd } from "../lib/registry"
4
4
 
5
5
  // Standalone solidrt-go client (no dev server of its own). Without flags it
6
6
  // attaches to the dev server of the project (or file) in the current
7
- // directory, resolved from the registry; --port picks a local server by port
8
- // and --server names any address. A device is `srt android`.
7
+ // directory, resolved from the registry, and starts on its own (into the
8
+ // launcher) when there is none; --port picks a local server by port and
9
+ // --server names any address, and those must exist. A device is `srt android`.
9
10
  export async function main() {
10
11
  let runner = requireBinary("solidrt-go")
11
12
  let args: string[] = [...clientStorageArgs()]
12
13
  if (values.size) args.push("--size", values.size)
13
- let address: string
14
+ let address: string | null
14
15
  if (values.server) {
15
16
  if (!values.server.includes(":")) {
16
17
  console.error(`--server needs host:port (got "${values.server}"); dev servers have no fixed port`)
@@ -21,13 +22,9 @@ export async function main() {
21
22
  address = `127.0.0.1:${port}`
22
23
  } else {
23
24
  let resolved = await resolveFromCwd(process.cwd())
24
- if (!resolved.ok) {
25
- console.error(resolved.message)
26
- process.exit(1)
27
- }
28
- address = `127.0.0.1:${resolved.record.port}`
25
+ address = resolved.ok ? `127.0.0.1:${resolved.record.port}` : null
29
26
  }
30
- args.push("--dev-server", address)
27
+ if (address) args.push("--dev-server", address)
31
28
  let exit = await run(runner, args)
32
29
  process.exit(exit)
33
30
  }
package/src/init/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
2
  import { basename, dirname, join, resolve } from "node:path"
3
3
  import { source, values } from "../lib/args"
4
- import { multiselect, note, text } from "./prompt"
4
+ import { multiselect, note, text } from "../lib/prompt"
5
5
 
6
6
  const DEFAULT_NAME = "solidrt-app"
7
7
 
@@ -90,8 +90,10 @@ platform-wide and bite in every app:
90
90
 
91
91
  ## Run / verify
92
92
 
93
- - FIRST check whether a dev server and a client are already running and
94
- build against those; do not start a second `srt run` when one is up.
93
+ - FIRST check whether a dev server and its clients (possibly several) are
94
+ already running and build against those; do not start a second `srt run`
95
+ when one is up. `reload` reaches every connected client; the per-client
96
+ tools are listed in debugging.md.
95
97
  - The dev loop (reload, logs, snapshots, the holds on reload-on-save and on
96
98
  the user's input), typechecking, headless rendering and the MCP tools:
97
99
  node_modules/@solidrt/cli/AGENTS.md and its agents/debugging.md. Read it
@@ -10,14 +10,14 @@
10
10
  "android": "srt android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.53",
14
- "@solidrt/components": "0.0.53",
15
- "@solidrt/2d": "0.0.53",
16
- "@solidrt/3d": "0.0.53"
13
+ "@solidrt/core": "0.0.54",
14
+ "@solidrt/components": "0.0.54",
15
+ "@solidrt/2d": "0.0.54",
16
+ "@solidrt/3d": "0.0.54"
17
17
  },
18
18
  "devDependencies": {
19
- "@solidrt/cli": "0.0.53",
20
- "@solidrt/flux-types": "0.0.53",
19
+ "@solidrt/cli": "0.0.54",
20
+ "@solidrt/flux-types": "0.0.54",
21
21
  "typescript": "^7"
22
22
  }
23
23
  }
package/src/mcp/main.ts CHANGED
@@ -192,7 +192,7 @@ let TOOLS: {
192
192
  name: "list_clients",
193
193
  annotations: READ_ONLY,
194
194
  description:
195
- "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
195
+ "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...), `refreshRate` (the display's nominal refresh rate in Hz as SDL reported it at connect, what `onFrame`'s `rate` argument carries; null when the client connected before its window existed, a reconnect fills it in) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
196
196
  inputSchema: {},
197
197
  },
198
198
  {
@@ -232,7 +232,7 @@ let TOOLS: {
232
232
  name: "get_stats",
233
233
  annotations: READ_ONLY,
234
234
  description:
235
- "Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
235
+ "Performance statistics from a running app client. Start with `window`: a summary of the frames that changed the picture in the last window_ms (default 5000, max 10000): tree rebuilds, plus GPU content changes presented without one (a layer write, a shader param, an upload: a sprite or shader app's every frame, where the critical path is the render handler alone) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing changed the picture in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
236
236
  inputSchema: {
237
237
  window_ms: z
238
238
  .number()
@@ -321,9 +321,10 @@ let TOOLS: {
321
321
  name: "get_gpu_resources",
322
322
  annotations: READ_ONLY,
323
323
  description:
324
- "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change.",
324
+ "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change. In a draw target's entry list, uniforms wider than a vec4 (matrices) are elided to their length (\"[16]\") so a model's hundred entries stay readable; pass `draw` (an entry id from that list, with `label` to pin the target, since entry ids are per target) to get that one entry's params in full.",
325
325
  inputSchema: {
326
326
  label: z.string().describe("Keep only resources whose create label equals this").optional(),
327
+ draw: z.number().int().describe("Draw entry id whose params are reported in full (pair with label)").optional(),
327
328
  client: CLIENT_ARG,
328
329
  },
329
330
  },
@@ -565,6 +566,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
565
566
  case "get_gpu_resources": {
566
567
  let params = new URLSearchParams()
567
568
  if (typeof args?.label === "string") params.set("label", args.label)
569
+ if (typeof args?.draw === "number") params.set("draw", String(args.draw))
568
570
  if (typeof args?.client === "number") params.set("client", String(args.client))
569
571
  let qs = params.toString()
570
572
  return control(`/gpu${qs ? `?${qs}` : ""}`)
@@ -81,6 +81,7 @@ export function clientList(withAddress = false): (ClientEntry & { address?: stri
81
81
  os: info.os,
82
82
  kernel: info.kernel,
83
83
  videoDriver: info.videoDriver,
84
+ refreshRate: info.refreshRate,
84
85
  gpu: info.gpu,
85
86
  ...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
86
87
  }))
@@ -410,9 +411,15 @@ export async function handleControl(req: Request, path: string, query: Map<strin
410
411
  return handleQuery(query, "snapshot", extra)
411
412
  }
412
413
  case "/__control__/gpu": {
413
- // ?label=<text> keeps only resources created with exactly that label.
414
+ // ?label=<text> keeps only resources created with exactly that label;
415
+ // ?draw=<id> reports that draw entry's params in full (matrix-valued
416
+ // params are elided everywhere else).
417
+ let extra: Record<string, unknown> = {}
414
418
  let label = query.get("label")
415
- return handleQuery(query, "gpu", label === undefined ? undefined : { label })
419
+ if (label !== undefined) extra.label = label
420
+ let draw = parseInt(query.get("draw") ?? "", 10)
421
+ if (Number.isFinite(draw)) extra.draw = draw
422
+ return handleQuery(query, "gpu", extra)
416
423
  }
417
424
  case "/__control__/debug": {
418
425
  // GET lists the app's registered debug commands; POST calls one, with
@@ -2,6 +2,8 @@
2
2
 
3
3
  `run` is the everyday command: it starts the dev server and a local client
4
4
  window together, and it is what `bun run dev` calls in a scaffolded project.
5
+ The server outlives the client: closing (or killing) a wedged client keeps
6
+ the server up, and `srt client` reattaches a new one.
5
7
 
6
8
  {{ usage run }}
7
9
 
@@ -248,6 +248,7 @@ function onOpen(ws: ServerWebSocket) {
248
248
  os: null,
249
249
  kernel: null,
250
250
  videoDriver: null,
251
+ refreshRate: null,
251
252
  gpu: null,
252
253
  })
253
254
  console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
@@ -263,10 +264,6 @@ function onClose(ws: ServerWebSocket) {
263
264
  let info = state.clients.get(ws)
264
265
  state.clients.delete(ws)
265
266
  console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
266
- // `srt run` lives as long as its clients: once the local client is gone,
267
- // the last remote disconnect ends the server. `srt server` runs until
268
- // stopped.
269
- if (config.client && localClientExited && state.clients.size === 0) shutdown()
270
267
  }
271
268
 
272
269
  function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
@@ -290,6 +287,7 @@ function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
290
287
  os: text(data.os),
291
288
  kernel: text(data.kernel),
292
289
  videoDriver: text(data.videoDriver),
290
+ refreshRate: typeof data.refreshRate === "number" ? data.refreshRate : null,
293
291
  gpu:
294
292
  data.gpu && typeof data.gpu === "object"
295
293
  ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" }
@@ -399,7 +397,6 @@ let keepalive = setInterval(() => {
399
397
  let shuttingDown = false
400
398
  let stopRepl = () => {}
401
399
  let localClient: Child | null = null
402
- let localClientExited = false
403
400
  let signalOffs = ["SIGINT", "SIGTERM"].map((signal) =>
404
401
  onSignal(signal, () => {
405
402
  shutdown()
@@ -463,14 +460,11 @@ if (config.client) {
463
460
  localClient = child
464
461
  pump(child.stdout, (line) => console.log(line))
465
462
  pump(child.stderr, (line) => console.error(line))
463
+ // The server outlives its client: a wedged or crashed client is restarted
464
+ // with `srt client` (it reattaches by cwd) without losing the server, its
465
+ // bundle, the watcher or the MCP session. The server stops on quit/signal.
466
466
  child.status().then(() => {
467
467
  localClient = null
468
- localClientExited = true
469
- if (shuttingDown) return
470
- if (state.clients.size === 0) {
471
- shutdown()
472
- } else {
473
- console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
474
- }
468
+ if (!shuttingDown) console.log("[cli] Local client exited; the server keeps running (srt client reattaches)")
475
469
  })
476
470
  }
@@ -35,6 +35,11 @@ export type ClientEntry = {
35
35
  kernel: string | null
36
36
  /** The SDL video driver ("wayland", "x11", "android", "offscreen", ...). */
37
37
  videoDriver: string | null
38
+ /** The display's nominal refresh rate in Hz as SDL reported it when the
39
+ * client connected (what `onFrame`'s `rate` argument carries); null on a
40
+ * runtime that predates it, or on a client that connected before its
41
+ * window existed (a reconnect fills it in). */
42
+ refreshRate: number | null
38
43
  /** The GPU strings as GL reports them; null on a client that connected
39
44
  * before its GL context existed (a reconnect fills it in). */
40
45
  gpu: GpuInfo | null
File without changes