@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 +3 -1
- package/agents/debugging.md +29 -8
- package/dist/console.srtapp +1 -1
- package/dist/demos/3d/the-third-dimension/the-third-dimension.srt.js +51 -31
- package/dist/server.js +12 -12
- package/package.json +6 -6
- package/src/android/docs.md +5 -2
- package/src/android/main.ts +135 -86
- package/src/client/docs.md +3 -1
- package/src/client/main.ts +6 -9
- package/src/init/main.ts +1 -1
- package/src/init/scaffold/AGENTS.md +4 -2
- package/src/init/scaffold/package.json +6 -6
- package/src/mcp/main.ts +5 -3
- package/src/server/control.ts +9 -2
- package/src/server/docs.md +2 -0
- package/src/server/main.ts +6 -12
- package/src/types/control.d.ts +5 -0
- /package/src/{init → lib}/prompt.ts +0 -0
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.
|
|
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
|
package/agents/debugging.md
CHANGED
|
@@ -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
|
|
160
|
-
resources created with exactly that label (ids change on reload,
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
package/dist/console.srtapp
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{"appId":"com.solidrt.console","org":"Console","displayName":"Console","icon":"assets/icon.svg","runtimeVersion":1,"solidrtVersion":"0.0.
|
|
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"setTargetTexturesuploadTexturecopyTexturedestroyBufferrenderTargetsetDrawaddDrawremoveDrawsetDrawBufferssetDrawOrdersetDrawParamssetDrawRangesetDrawTextureslimitscompileShader(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
|
|
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
|
|
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
|
-
|
|
5159
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
5310
|
+
cull,
|
|
5294
5311
|
topology: opts.topology,
|
|
5295
5312
|
label: opts.label
|
|
5296
5313
|
});
|
|
5297
|
-
|
|
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
|
|
5334
|
+
for (let pipeline of pipelines.values())
|
|
5315
5335
|
destroyRenderPipeline(pipeline);
|
|
5316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3928
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@solidrt/linux-arm64-gnu": "0.0.
|
|
35
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
36
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
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.
|
|
42
|
+
"@solidrt/flux-types": "0.0.54",
|
|
43
43
|
"@types/babel__core": "^7.20.5",
|
|
44
44
|
"@types/bun": "latest"
|
|
45
45
|
}
|
package/src/android/docs.md
CHANGED
|
@@ -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.
|
|
12
|
-
|
|
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
|
package/src/android/main.ts
CHANGED
|
@@ -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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
if (fresh) return fresh
|
|
201
|
+
fresh = (await connectedClients(server)).filter((c) => c.platform === "android" && !before.has(c.id))
|
|
180
202
|
}
|
|
181
|
-
return
|
|
203
|
+
return fresh
|
|
182
204
|
}
|
|
183
205
|
|
|
184
|
-
|
|
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
|
|
187
|
-
//
|
|
188
|
-
|
|
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 =
|
|
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
|
|
231
|
+
return [device(match)]
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
if (devices.length > 1) {
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
258
|
+
return [device(only)]
|
|
217
259
|
}
|
|
218
260
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
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 =
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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(
|
|
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(
|
|
280
|
-
let
|
|
281
|
-
|
|
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
|
+
}
|
package/src/client/docs.md
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/src/client/main.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
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
|
-
|
|
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 "
|
|
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
|
|
94
|
-
build against those; do not start a second `srt run`
|
|
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.
|
|
14
|
-
"@solidrt/components": "0.0.
|
|
15
|
-
"@solidrt/2d": "0.0.
|
|
16
|
-
"@solidrt/3d": "0.0.
|
|
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.
|
|
20
|
-
"@solidrt/flux-types": "0.0.
|
|
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
|
|
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}` : ""}`)
|
package/src/server/control.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/server/docs.md
CHANGED
|
@@ -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
|
|
package/src/server/main.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/types/control.d.ts
CHANGED
|
@@ -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
|