@solidrt/cli 0.0.46 → 0.0.47
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/package.json +6 -6
- package/scaffold/AGENTS.md +147 -20
- package/scaffold/package.json +4 -4
- package/server/control.ts +126 -13
- package/server/main.ts +9 -1
- package/server/state.ts +10 -1
- package/src/commands/mcp.ts +119 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"zod": "^4.4.3"
|
|
29
29
|
},
|
|
30
30
|
"optionalDependencies": {
|
|
31
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
32
|
-
"@solidrt/linux-arm64-gnu": "0.0.
|
|
33
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
34
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
31
|
+
"@solidrt/darwin-arm64": "0.0.47",
|
|
32
|
+
"@solidrt/linux-arm64-gnu": "0.0.47",
|
|
33
|
+
"@solidrt/linux-x64-gnu": "0.0.47",
|
|
34
|
+
"@solidrt/win32-x64-msvc": "0.0.47"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"typescript": "^7"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@solidrt/flux-types": "0.0.
|
|
40
|
+
"@solidrt/flux-types": "0.0.47",
|
|
41
41
|
"@types/babel__core": "^7.20.5",
|
|
42
42
|
"@types/bun": "latest"
|
|
43
43
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -37,6 +37,61 @@ Authoritative references ship inside the installed packages - read them:
|
|
|
37
37
|
@./node_modules/@solidrt/core/AGENTS.md
|
|
38
38
|
@./node_modules/@solidrt/cli/AGENTS.md
|
|
39
39
|
|
|
40
|
+
## What you paint with (there is no CSS layer)
|
|
41
|
+
|
|
42
|
+
Layout and props are half the model. There is no stylesheet: no filters, no
|
|
43
|
+
box-shadow, no keyframes, no canvas. The visual range a web app gets from CSS
|
|
44
|
+
comes from the tiers below instead, and reaching past tier 1 is ordinary
|
|
45
|
+
app-building here, not optimization - a screen built only from view
|
|
46
|
+
backgrounds and text is using a fraction of the runtime. Pick the tier the
|
|
47
|
+
CONTENT calls for, not the one that looks safest.
|
|
48
|
+
|
|
49
|
+
1. Laid-out elements - `<view>`/`<text>`, with `<rect>` (or a filling
|
|
50
|
+
`<d-rect>` child) for background, border, radius. The structure of a
|
|
51
|
+
screen, not its finish.
|
|
52
|
+
2. Vector art, detached from layout - `d-path`/`d-rect`/`d-oval`/`d-line`,
|
|
53
|
+
whose `color` takes a gradient (createLinearGradient /
|
|
54
|
+
createRadialGradient) and which honour `blendMode`, plus `parseSvg` to
|
|
55
|
+
draw a whole SVG document as one subtree. Free-form shapes, decoration,
|
|
56
|
+
diagrams, charts, anything positioned rather than flowed. Examples:
|
|
57
|
+
parse-svg, detached-positioning, text-paint-styling.
|
|
58
|
+
3. GPU textures - `createShaderTexture` puts a fragment shader in a
|
|
59
|
+
`<texture>` (moving gradients, noise, glow, dissolves, a background that
|
|
60
|
+
is alive), `createPipelineTexture` draws geometry you generate yourself
|
|
61
|
+
(particles, point clouds, splats), and the `shader` prop post-processes
|
|
62
|
+
content that already exists: on a `<view>` it grades, warps or dissolves
|
|
63
|
+
that subtree, on `<window>` the whole frame. Stack `<texture>` elements
|
|
64
|
+
with `blendMode` to combine passes. Examples: gpu-shader, gpu-particles,
|
|
65
|
+
gpu-pipeline, gpu-instancing, gpu-texture-blend, view-shader,
|
|
66
|
+
window-shader.
|
|
67
|
+
4. 3D scenes - add `@solidrt/3d` (not a scaffold dependency): meshes,
|
|
68
|
+
materials and a camera declared as Solid components, rendered into a
|
|
69
|
+
texture that sits in the UI tree like any other element.
|
|
70
|
+
|
|
71
|
+
Tier 3 is cheaper than it looks. A shader costs one property write per frame
|
|
72
|
+
no matter how complex the effect, which is why the performance notes below
|
|
73
|
+
reach for it first rather than as a last resort.
|
|
74
|
+
|
|
75
|
+
Web reflexes and what replaces them:
|
|
76
|
+
- gradient background -> a gradient `color` on a `d-rect` (gradients are
|
|
77
|
+
paint values, usable anywhere a color is)
|
|
78
|
+
- `filter: blur/grayscale/hue-rotate`, and any "make this look processed" ->
|
|
79
|
+
a `shader` on the view (requires repaintBoundary="snapshot"), or on
|
|
80
|
+
`<window>` for the whole frame
|
|
81
|
+
- `box-shadow` / `text-shadow` / glow -> no shadow prop exists: draw an
|
|
82
|
+
offset `d-*` shape under the content, or a view shader with `outset` (the
|
|
83
|
+
transparent margin an effect bleeds into)
|
|
84
|
+
- `backdrop-filter` -> no equivalent. A view shader sees only its own
|
|
85
|
+
subtree's pixels, never what is behind it. Frost the whole frame with a
|
|
86
|
+
window shader, or fake the layer with your own content
|
|
87
|
+
- `@keyframes` / transitions -> `onFrame` writing a signal for discrete
|
|
88
|
+
motion; a `uTime` uniform when the animation is continuous and visual
|
|
89
|
+
- `<canvas>` 2D -> `d-*` primitives (rebuild one `d-path` string per frame
|
|
90
|
+
rather than animating N elements)
|
|
91
|
+
- `<canvas>` WebGL, three.js -> `createPipelineTexture`, or `@solidrt/3d`
|
|
92
|
+
- video background, animated hero, particle field -> a shader texture; this
|
|
93
|
+
is the case the runtime is built for
|
|
94
|
+
|
|
40
95
|
## The things assistants get wrong (this is not React/DOM)
|
|
41
96
|
|
|
42
97
|
1. This is SolidJS 2.0 (see CHEATSHEET.md for the reactivity/control-flow
|
|
@@ -209,6 +264,22 @@ work stops being free" below is where it does not. Rules, in order of leverage:
|
|
|
209
264
|
5. "snapshot" boundaries pay first-frame texture allocation + raster:
|
|
210
265
|
creating many at once (dealing a board of 64 sprites) is a visible
|
|
211
266
|
one-frame hiccup - pool or pre-warm if that moment matters.
|
|
267
|
+
6. Shading pixels the app already drew is a different mechanism from rule 1's
|
|
268
|
+
generated textures, and both forms are a `shader` prop taking a linked
|
|
269
|
+
program from compileShader/linkProgram (@solidrt/core/gpu), not a
|
|
270
|
+
createShaderTexture source. On `<window>`, `shader={{ program, params }}`
|
|
271
|
+
runs the finished frame through the program as the last step before it
|
|
272
|
+
reaches the screen: the frame binds as `uniform sampler2D uSource`,
|
|
273
|
+
`iResolution` fills by name, and `previous: true` retains the last frame as
|
|
274
|
+
`uPrevious` for motion echo or frame differencing. On a `<view>` the same
|
|
275
|
+
prop shades that subtree in place and REQUIRES repaintBoundary="snapshot"
|
|
276
|
+
(without it the shader is ignored with a warning); the pass sees only the
|
|
277
|
+
subtree's own pixels - grading, warping or dissolving the panel works,
|
|
278
|
+
anything needing what is behind it does not - and is split from content
|
|
279
|
+
invalidation, so a params-only change re-runs the pass against the cached
|
|
280
|
+
snapshot instead of re-rasterizing. A window shader's output is invisible
|
|
281
|
+
to get_snapshot and every other MCP tool; `srt render` is the only way to
|
|
282
|
+
see it (Run / verify below).
|
|
212
283
|
|
|
213
284
|
### Where GPU work stops being free
|
|
214
285
|
|
|
@@ -290,7 +361,10 @@ up to the frame period, because work outside the frame call is not in them.
|
|
|
290
361
|
iterating - `srt bundle` writes output files and reloads connected clients
|
|
291
362
|
- bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
|
|
292
363
|
render to PNG frames (proves it renders; see the cli AGENTS.md for where the
|
|
293
|
-
frames land)
|
|
364
|
+
frames land). It is also the ONLY way to see the output of a window shader
|
|
365
|
+
(the `shader` prop on `<window>`): that pass runs on the finished frame on
|
|
366
|
+
its way to the screen, past the point every other capture reads, so `render`
|
|
367
|
+
frames are the only programmatic view of what it produces
|
|
294
368
|
|
|
295
369
|
## MCP: inspect the running app
|
|
296
370
|
|
|
@@ -298,12 +372,22 @@ The project ships an MCP server (.mcp.json, `srt mcp`) that talks to the dev
|
|
|
298
372
|
server `bunx srt run` starts. When it is loaded in your environment, prefer
|
|
299
373
|
its tools over guessing at runtime state:
|
|
300
374
|
|
|
301
|
-
- list_clients: connected app clients, their platform and runtime
|
|
375
|
+
- list_clients: connected app clients, their platform and runtime
|
|
376
|
+
capabilities, plus the server's `entry` (the app source it serves) and
|
|
377
|
+
`projectDir` - check entry matches the app you think you are driving; the
|
|
378
|
+
dev port is fixed, so another project's server answers on the same port.
|
|
379
|
+
Each client also lists `queries`, the dev-tool query kinds its runtime
|
|
380
|
+
answers - check it before planning verification against a mixed-version
|
|
381
|
+
fleet (no "input" = the client predates send_input)
|
|
302
382
|
- get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
|
|
303
383
|
to catch output right after a reload; `level`/`contains` filters; repeated
|
|
304
384
|
lines collapse into one entry with a `repeats` count)
|
|
305
385
|
- get_render_tree: what the app actually rendered - node kinds, text, and
|
|
306
|
-
window-relative boxes.
|
|
386
|
+
window-relative boxes. Pass `props: true` for each node's current
|
|
387
|
+
property values (JSX names, off-default only - "is rotate/color/overflow/d
|
|
388
|
+
applied right now" is one call, not a probe entry) and, on transformed nodes, the
|
|
389
|
+
painted `quad` (four corners after transforms; the box is just its
|
|
390
|
+
axis-aligned bounds). Whole trees get large: `query` finds nodes by
|
|
307
391
|
kind/text, then `root` + `depth` inspect just that region
|
|
308
392
|
- client ids and log cursors die with the dev server: list_clients and
|
|
309
393
|
get_logs responses carry `generation`, and a changed generation means
|
|
@@ -313,12 +397,46 @@ its tools over guessing at runtime state:
|
|
|
313
397
|
paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
|
|
314
398
|
wrong, these say whether the cost is text shaping, invalidation breadth,
|
|
315
399
|
or a defeated layout cache (healthy incremental rebuilds show a near-100%
|
|
316
|
-
cacheHits rate)
|
|
400
|
+
cacheHits rate). reusedPerSec/skippedPerSec are the demand gate's visible
|
|
401
|
+
signal: frames presented from the cached display list without a rebuild
|
|
402
|
+
(texture content changed, no property writes - expect reusedPerSec near
|
|
403
|
+
fps on texture-driven apps) and frames skipped entirely (nothing
|
|
404
|
+
requested one)
|
|
317
405
|
- get_snapshot: PNG capture of any render-tree node's pixels (get node ids
|
|
318
|
-
from get_render_tree; the window node captures everything).
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
406
|
+
from get_render_tree; the window node captures everything). A subtree
|
|
407
|
+
capture renders with NO ancestor paint: pixels the subtree does not draw
|
|
408
|
+
come back transparent, not the background behind the node. Captures
|
|
409
|
+
re-rasterize the tree offscreen, so they are also PRE window shader: an app
|
|
410
|
+
with a `shader` on its `<window>` snapshots as its unshaded content, window
|
|
411
|
+
node included, and no MCP tool reads the shaded result (its layer is
|
|
412
|
+
runtime-owned, so get_texture has no id for it; get_gpu_resources reports
|
|
413
|
+
only that the pass exists). Use `srt render` for that one. Crop with
|
|
414
|
+
x/y/width/height (captured-image pixels) and magnify with `scale` (1-8,
|
|
415
|
+
nearest-neighbour) - a tight crop at 4-8x is how small geometry gets
|
|
416
|
+
verified. Pass `save_to` on get_snapshot or get_texture to also write the
|
|
417
|
+
PNG to a file - the image in the tool result cannot be saved afterwards,
|
|
418
|
+
so decide before capturing (e.g. keep a before/after pair to diff)
|
|
419
|
+
- set_time_scale / step_frames: the runtime clock. `set_time_scale 0`
|
|
420
|
+
freezes app time (onFrame, requestAnimationFrame, timers, and
|
|
421
|
+
performance.now all stop; Date.now stays wall time), so a snapshot can
|
|
422
|
+
catch an exact frame of any animation instead of racing it; `step_frames
|
|
423
|
+
n` then advances exactly n frames (one refresh period each). Pause,
|
|
424
|
+
snapshot, step, snapshot again to see precisely what changed. ALWAYS set
|
|
425
|
+
the scale back to 1 when done - a paused client looks wedged to the human
|
|
426
|
+
watching - though reload/load also reset it
|
|
427
|
+
- send_input: synthetic pointer/key/wheel/text events through the REAL
|
|
428
|
+
input pipeline (hit testing, focus, bubbling) - the way to verify an
|
|
429
|
+
interaction actually works, where call_debug would bypass it. A click is
|
|
430
|
+
one call ({type: "pointer", action: "tap", x, y} - logical points, the
|
|
431
|
+
same space get_render_tree reports); a key hold is {type: "key", action:
|
|
432
|
+
"tap", key: "w", holdMs: 500}; text needs the field focused first (tap
|
|
433
|
+
it), then {type: "text", text: "go"}; drags are down + moves (delayMs
|
|
434
|
+
~16 apiece) + up. Sequences run in order with per-event delayMs and the
|
|
435
|
+
call returns after the last event is delivered, so a following snapshot
|
|
436
|
+
sees the result. A synthetic mouse keeps hovering at its last position
|
|
437
|
+
(like a real cursor at rest); use pointerType: "touch" for gestures that
|
|
438
|
+
should end hover-free. Composes with the clock: pause, send_input,
|
|
439
|
+
step_frames, snapshot = a deterministic interaction test
|
|
322
440
|
- get_gpu_resources: inventory of GPU state - textures (size, render target
|
|
323
441
|
or not), vertex buffers (byteLength), pipelines (draw count, attribute
|
|
324
442
|
layout, bound textures, current uniform values - the most recent writes,
|
|
@@ -326,7 +444,7 @@ its tools over guessing at runtime state:
|
|
|
326
444
|
- get_texture: any GPU texture read back as a PNG by id - atlases, data
|
|
327
445
|
textures, and shader/pipeline render targets alike (a render target reads
|
|
328
446
|
as its current output, pending writes included, with no frame or snapshot
|
|
329
|
-
needed); crop with x/y/width/height
|
|
447
|
+
needed); crop with x/y/width/height, magnify with `scale`
|
|
330
448
|
- get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
|
|
331
449
|
per call) - verify geometry after a writeBuffer instead of inferring it
|
|
332
450
|
from pixels
|
|
@@ -361,8 +479,8 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
|
|
|
361
479
|
user's call to make, once, in their own tooling.
|
|
362
480
|
- Multiple clients: several clients may be attached (desktop window,
|
|
363
481
|
phone, tablet) with different sizes, display scales, and safe areas.
|
|
364
|
-
reload pushes to all of them, but call_debug /
|
|
365
|
-
cursors are per client, and interactive state does NOT sync - a flow
|
|
482
|
+
reload pushes to all of them, but call_debug / send_input / get_snapshot
|
|
483
|
+
/ log cursors are per client, and interactive state does NOT sync - a flow
|
|
366
484
|
driven on one client leaves the others sitting on the initial screen,
|
|
367
485
|
which reads as a crash to a human holding that device. So: when driving
|
|
368
486
|
state via call_debug, send the same call to every client (or say which
|
|
@@ -377,11 +495,18 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
|
|
|
377
495
|
logs it and read it back via get_logs.
|
|
378
496
|
- Better than debug keys when driving the app over MCP: register debug
|
|
379
497
|
COMMANDS - `registerDebug(name, fn)` from `srt:dev`, invoked via the
|
|
380
|
-
list_debug/call_debug tools.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
498
|
+
list_debug/call_debug tools. Use them to SET UP state (jump to a level,
|
|
499
|
+
force a mode, seed a scenario); then the runtime-level tools take over -
|
|
500
|
+
set_time_scale 0 freezes the result for as many snapshots as you need,
|
|
501
|
+
and step_frames walks it forward deterministically. Set state, pause,
|
|
502
|
+
snapshot. Registrations reset on hot reload, so register at module init;
|
|
503
|
+
sync return values only - and note a signal you just wrote flushes on a
|
|
504
|
+
microtask, so returning a signal read straight after setting it returns
|
|
505
|
+
the OLD value.
|
|
506
|
+
- call_debug sets state directly, skipping focus, key routing, and
|
|
507
|
+
TextInput - fine for SETUP, but "the interaction works" is only shown by
|
|
508
|
+
the real pipeline: verify clicks, typing, and drags with send_input,
|
|
509
|
+
which enters events where SDL input does.
|
|
385
510
|
- Key events start at the focused node and bubble to the window root; with
|
|
386
511
|
nothing focused they go to the window root alone. So a debug key bound via
|
|
387
512
|
`<window onKeyDown>` always fires (unless a focused component consumes the
|
|
@@ -402,10 +527,12 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
|
|
|
402
527
|
- Snapshots are downscaled by the time you see them, so a full-window capture
|
|
403
528
|
cannot show you a defect a few pixels across. Whenever you hand-author
|
|
404
529
|
geometry - a `d-path` from raw path math, a `radius` where two shapes meet,
|
|
405
|
-
a stroke join - inspect it MAGNIFIED once, when you write it:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
530
|
+
a stroke join - inspect it MAGNIFIED once, when you write it: get_snapshot
|
|
531
|
+
with a tight crop at scale 4-8 shows the actual rendered pixels enlarged,
|
|
532
|
+
in one call, on the real app. Verifying that a shape is in the right place
|
|
533
|
+
is not the same check as verifying it is drawn right - and get_render_tree
|
|
534
|
+
props answers the third question, whether the value you set is the value
|
|
535
|
+
the renderer holds.
|
|
409
536
|
- GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
|
|
410
537
|
for draw counts/uniforms/sizes, get_texture for atlas or data-texture
|
|
411
538
|
contents ("is this tile blank?" is a ten-second question), get_buffer for
|
package/scaffold/package.json
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"android": "srt client --android"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@solidrt/core": "0.0.
|
|
14
|
-
"@solidrt/components": "0.0.
|
|
13
|
+
"@solidrt/core": "0.0.47",
|
|
14
|
+
"@solidrt/components": "0.0.47"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@solidrt/cli": "0.0.
|
|
18
|
-
"@solidrt/flux-types": "0.0.
|
|
17
|
+
"@solidrt/cli": "0.0.47",
|
|
18
|
+
"@solidrt/flux-types": "0.0.47",
|
|
19
19
|
"typescript": "^7"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/server/control.ts
CHANGED
|
@@ -61,6 +61,7 @@ export function clientList(withAddress = false) {
|
|
|
61
61
|
version: info.version,
|
|
62
62
|
profile: info.profile,
|
|
63
63
|
capabilities: info.capabilities,
|
|
64
|
+
queries: info.queries,
|
|
64
65
|
...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
|
|
65
66
|
}))
|
|
66
67
|
}
|
|
@@ -72,7 +73,7 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
|
|
|
72
73
|
if (param === undefined) {
|
|
73
74
|
if (entries.length === 1) return { ws: entries[0]![0] }
|
|
74
75
|
if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
|
|
75
|
-
return { error: Response.json({ error: "Multiple clients connected; pass
|
|
76
|
+
return { error: Response.json({ error: "Multiple clients connected; pass a client id (list_clients has the ids)" }, { status: 400 }) }
|
|
76
77
|
}
|
|
77
78
|
let id = parseInt(param, 10)
|
|
78
79
|
let entry = entries.find(([, info]) => info.id === id)
|
|
@@ -92,7 +93,35 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
|
|
|
92
93
|
return { ws: entry[0] }
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
// Optional crop rect shared by /snapshot and /texture: all four of
|
|
97
|
+
// x/y/width/height in captured/texture pixels, or none. Returns undefined
|
|
98
|
+
// when absent, a 400 Response when malformed.
|
|
99
|
+
function parseRect(query: Map<string, string>): { x: number; y: number; width: number; height: number } | Response | undefined {
|
|
100
|
+
let params = ["x", "y", "width", "height"].map((k) => query.get(k))
|
|
101
|
+
if (params.every((v) => v === undefined)) return undefined
|
|
102
|
+
let [x, y, width, height] = params.map((v) => parseInt(v ?? "", 10))
|
|
103
|
+
if (![x, y, width, height].every(Number.isFinite))
|
|
104
|
+
return Response.json({ error: "Crop rect requires all of x, y, width, height" }, { status: 400 })
|
|
105
|
+
return { x: x!, y: y!, width: width!, height: height! }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Optional integer magnification shared by /snapshot and /texture. Returns
|
|
109
|
+
// undefined when absent or 1, a 400 Response when out of range.
|
|
110
|
+
function parseScale(query: Map<string, string>): number | Response | undefined {
|
|
111
|
+
let param = query.get("scale")
|
|
112
|
+
if (param === undefined) return undefined
|
|
113
|
+
let scale = parseInt(param, 10)
|
|
114
|
+
if (!Number.isFinite(scale) || scale < 1 || scale > 8)
|
|
115
|
+
return Response.json({ error: "Scale must be an integer between 1 and 8" }, { status: 400 })
|
|
116
|
+
return scale === 1 ? undefined : scale
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleQuery(
|
|
120
|
+
query: Map<string, string>,
|
|
121
|
+
kind: string,
|
|
122
|
+
extra?: Record<string, unknown>,
|
|
123
|
+
timeoutMs: number = QUERY_TIMEOUT_MS,
|
|
124
|
+
): Promise<Response> {
|
|
96
125
|
let target = findClient(query.get("client"))
|
|
97
126
|
if ("error" in target) return target.error
|
|
98
127
|
let id = nextQueryId++
|
|
@@ -100,7 +129,7 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
|
|
|
100
129
|
pendingQueries.set(id, resolve)
|
|
101
130
|
})
|
|
102
131
|
target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
|
|
103
|
-
let msg = await Promise.race([reply, sleep(
|
|
132
|
+
let msg = await Promise.race([reply, sleep(timeoutMs)])
|
|
104
133
|
pendingQueries.delete(id)
|
|
105
134
|
if (!msg)
|
|
106
135
|
return Response.json(
|
|
@@ -174,7 +203,15 @@ async function handleLogs(query: Map<string, string>): Promise<Response> {
|
|
|
174
203
|
export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
|
|
175
204
|
switch (path) {
|
|
176
205
|
case "/__control__/clients":
|
|
177
|
-
|
|
206
|
+
// `entry`/`projectDir` identify which app this server is serving: the
|
|
207
|
+
// fixed dev port means an agent can reach a different project's server
|
|
208
|
+
// than it thinks, and a repl/MCP `load` moves the entry mid-session.
|
|
209
|
+
return Response.json({
|
|
210
|
+
generation: state.generation,
|
|
211
|
+
entry: state.config.entry ?? null,
|
|
212
|
+
projectDir: state.projectDir,
|
|
213
|
+
clients: clientList(),
|
|
214
|
+
})
|
|
178
215
|
case "/__control__/logs":
|
|
179
216
|
return handleLogs(query)
|
|
180
217
|
case "/__control__/tree": {
|
|
@@ -185,6 +222,7 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
185
222
|
if (Number.isFinite(depth)) extra.depth = depth
|
|
186
223
|
let q = query.get("query")
|
|
187
224
|
if (q) extra.query = q
|
|
225
|
+
if (query.get("props") === "true") extra.props = true
|
|
188
226
|
return handleQuery(query, "tree", extra)
|
|
189
227
|
}
|
|
190
228
|
case "/__control__/stats":
|
|
@@ -192,7 +230,14 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
192
230
|
case "/__control__/snapshot": {
|
|
193
231
|
let nodeId = parseInt(query.get("node") ?? "", 10)
|
|
194
232
|
if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
|
|
195
|
-
|
|
233
|
+
let extra: Record<string, unknown> = { nodeId }
|
|
234
|
+
let rect = parseRect(query)
|
|
235
|
+
if (rect instanceof Response) return rect
|
|
236
|
+
if (rect) extra.rect = rect
|
|
237
|
+
let scale = parseScale(query)
|
|
238
|
+
if (scale instanceof Response) return scale
|
|
239
|
+
if (scale) extra.scale = scale
|
|
240
|
+
return handleQuery(query, "snapshot", extra)
|
|
196
241
|
}
|
|
197
242
|
case "/__control__/gpu":
|
|
198
243
|
return handleQuery(query, "gpu")
|
|
@@ -211,17 +256,72 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
211
256
|
case "/__control__/texture": {
|
|
212
257
|
let textureId = parseInt(query.get("id") ?? "", 10)
|
|
213
258
|
if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=<textureId>" }, { status: 400 })
|
|
214
|
-
// Optional crop: all four of x/y/width/height, in texture pixels.
|
|
215
|
-
let rectParams = ["x", "y", "width", "height"].map((k) => query.get(k))
|
|
216
259
|
let extra: Record<string, unknown> = { textureId }
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
260
|
+
let rect = parseRect(query)
|
|
261
|
+
if (rect instanceof Response) return rect
|
|
262
|
+
if (rect) extra.rect = rect
|
|
263
|
+
let scale = parseScale(query)
|
|
264
|
+
if (scale instanceof Response) return scale
|
|
265
|
+
if (scale) extra.scale = scale
|
|
223
266
|
return handleQuery(query, "texture", extra)
|
|
224
267
|
}
|
|
268
|
+
case "/__control__/clock": {
|
|
269
|
+
// Clock control: ?scale=<x> sets the client's time scale (0 pauses),
|
|
270
|
+
// ?step=<n> advances n frames while paused. Applied by the client
|
|
271
|
+
// runtime; the reply carries the resulting clock state.
|
|
272
|
+
if (req.method !== "POST") return Response.json({ error: "Clock requires POST" }, { status: 405 })
|
|
273
|
+
let extra: Record<string, unknown> = {}
|
|
274
|
+
let scaleParam = query.get("scale")
|
|
275
|
+
if (scaleParam !== undefined) {
|
|
276
|
+
let scale = parseFloat(scaleParam)
|
|
277
|
+
if (!Number.isFinite(scale) || scale < 0)
|
|
278
|
+
return Response.json({ error: "Clock scale must be a number >= 0" }, { status: 400 })
|
|
279
|
+
extra.scale = scale
|
|
280
|
+
}
|
|
281
|
+
let stepParam = query.get("step")
|
|
282
|
+
if (stepParam !== undefined) {
|
|
283
|
+
let step = parseInt(stepParam, 10)
|
|
284
|
+
if (!Number.isFinite(step) || step < 1 || step > 1000)
|
|
285
|
+
return Response.json({ error: "Clock step must be an integer between 1 and 1000" }, { status: 400 })
|
|
286
|
+
extra.step = step
|
|
287
|
+
}
|
|
288
|
+
if (!("scale" in extra) && !("step" in extra))
|
|
289
|
+
return Response.json({ error: "Clock requires ?scale=<x> or ?step=<n>" }, { status: 400 })
|
|
290
|
+
return handleQuery(query, "clock", extra)
|
|
291
|
+
}
|
|
292
|
+
case "/__control__/input": {
|
|
293
|
+
// Synthetic input injection: POST {events: [...]} forwards a timed
|
|
294
|
+
// event sequence to the client, which feeds it through the real input
|
|
295
|
+
// pipeline. Shape checks only here - the runtime validates each event
|
|
296
|
+
// and rejects the whole sequence on any bad one. The query timeout
|
|
297
|
+
// stretches by the sequence's own delays, since the client replies
|
|
298
|
+
// only after the last event has been sent.
|
|
299
|
+
if (req.method !== "POST") return Response.json({ error: "Input requires POST" }, { status: 405 })
|
|
300
|
+
let body: any = null
|
|
301
|
+
try {
|
|
302
|
+
body = await req.json()
|
|
303
|
+
} catch {}
|
|
304
|
+
let events = body?.events
|
|
305
|
+
if (!Array.isArray(events) || events.length === 0)
|
|
306
|
+
return Response.json({ error: "Input requires a body {events: [...]} with at least one event" }, { status: 400 })
|
|
307
|
+
if (events.length > 200) return Response.json({ error: "Input sequences are capped at 200 events" }, { status: 400 })
|
|
308
|
+
let totalMs = 0
|
|
309
|
+
for (let e of events) {
|
|
310
|
+
if (typeof e !== "object" || e === null)
|
|
311
|
+
return Response.json({ error: "Each event must be an object" }, { status: 400 })
|
|
312
|
+
for (let f of ["delayMs", "holdMs"]) {
|
|
313
|
+
let v = e[f]
|
|
314
|
+
if (v !== undefined) {
|
|
315
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 5000)
|
|
316
|
+
return Response.json({ error: `Event ${f} must be an integer between 0 and 5000` }, { status: 400 })
|
|
317
|
+
totalMs += v
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (totalMs > 30000)
|
|
322
|
+
return Response.json({ error: "Input sequence too long: delays and holds total over 30000 ms" }, { status: 400 })
|
|
323
|
+
return handleQuery(query, "input", { events }, QUERY_TIMEOUT_MS + totalMs)
|
|
324
|
+
}
|
|
225
325
|
case "/__control__/buffer": {
|
|
226
326
|
let bufferId = parseInt(query.get("id") ?? "", 10)
|
|
227
327
|
if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=<bufferId>" }, { status: 400 })
|
|
@@ -258,6 +358,19 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
258
358
|
if (!(await file(entry).exists())) {
|
|
259
359
|
return Response.json({ error: `Entry not found: ${entry}` }, { status: 400 })
|
|
260
360
|
}
|
|
361
|
+
// An entry outside the project root cannot resolve the project's
|
|
362
|
+
// dependencies, so the bundler would fail with misleading "bun install"
|
|
363
|
+
// advice; name the real constraint instead.
|
|
364
|
+
let norm = (p: string) => p.replace(/\\/g, "/")
|
|
365
|
+
let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
|
|
366
|
+
if (!norm(entry).startsWith(root)) {
|
|
367
|
+
return Response.json(
|
|
368
|
+
{
|
|
369
|
+
error: `Entry is outside the project root: ${entry} is not under ${state.projectDir}. The dev server can only bundle sources inside the project it was started in - move the file into the project or start srt there.`,
|
|
370
|
+
},
|
|
371
|
+
{ status: 400 },
|
|
372
|
+
)
|
|
373
|
+
}
|
|
261
374
|
state.config.entry = entry
|
|
262
375
|
let cut = Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\"))
|
|
263
376
|
if (cut > 0) state.sourceDir = entry.slice(0, cut)
|
package/server/main.ts
CHANGED
|
@@ -216,7 +216,14 @@ serve({
|
|
|
216
216
|
websocket: {
|
|
217
217
|
open(ws) {
|
|
218
218
|
let id = state.nextClientId++
|
|
219
|
-
state.clients.set(ws, {
|
|
219
|
+
state.clients.set(ws, {
|
|
220
|
+
platform: "unknown",
|
|
221
|
+
version: "unknown",
|
|
222
|
+
profile: "unknown",
|
|
223
|
+
id,
|
|
224
|
+
capabilities: [],
|
|
225
|
+
queries: [],
|
|
226
|
+
})
|
|
220
227
|
console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
|
|
221
228
|
// Advertise our real LAN address so clients dialed over a loopback hop
|
|
222
229
|
// can show/remember the directly reachable address (see connection.rs).
|
|
@@ -243,6 +250,7 @@ serve({
|
|
|
243
250
|
profile: data.profile ?? "unknown",
|
|
244
251
|
id: existing?.id ?? state.nextClientId++,
|
|
245
252
|
capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
|
|
253
|
+
queries: Array.isArray(data.queries) ? data.queries.map(String) : [],
|
|
246
254
|
})
|
|
247
255
|
console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`)
|
|
248
256
|
} else if (data.type === "log") {
|
package/server/state.ts
CHANGED
|
@@ -38,7 +38,16 @@ export type Config = {
|
|
|
38
38
|
tunnel: boolean
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export type ClientInfo = {
|
|
41
|
+
export type ClientInfo = {
|
|
42
|
+
platform: string
|
|
43
|
+
version: string
|
|
44
|
+
profile: string
|
|
45
|
+
id: number
|
|
46
|
+
capabilities: string[]
|
|
47
|
+
/** Query kinds this client's runtime answers (empty on runtimes that predate
|
|
48
|
+
* the advertisement); dev tools plan their verification surface from it. */
|
|
49
|
+
queries: string[]
|
|
50
|
+
}
|
|
42
51
|
|
|
43
52
|
export let state = {
|
|
44
53
|
config: undefined as unknown as Config,
|
package/src/commands/mcp.ts
CHANGED
|
@@ -42,7 +42,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
|
|
|
42
42
|
let CLIENT_ARG = z
|
|
43
43
|
.number()
|
|
44
44
|
.int()
|
|
45
|
-
.describe("Client id from list_clients (default: the only connected client)")
|
|
45
|
+
.describe("Client id from list_clients (default: the only connected client; required when several are connected)")
|
|
46
46
|
.optional()
|
|
47
47
|
|
|
48
48
|
let SAVE_TO_ARG = z
|
|
@@ -54,8 +54,9 @@ let SAVE_TO_ARG = z
|
|
|
54
54
|
|
|
55
55
|
// readOnly marks tools that only inspect state; it is surfaced as the
|
|
56
56
|
// MCP-standard readOnlyHint annotation so agent harnesses that honor it can
|
|
57
|
-
// auto-approve the inspection majority. load, reload,
|
|
58
|
-
// the running app and keep the default hints (destructive,
|
|
57
|
+
// auto-approve the inspection majority. load, reload, call_debug, and
|
|
58
|
+
// send_input mutate the running app and keep the default hints (destructive,
|
|
59
|
+
// not idempotent);
|
|
59
60
|
// `annotations` overrides those defaults where a mutating tool is benign
|
|
60
61
|
// (watch: a reversible, idempotent toggle). Every tool gets
|
|
61
62
|
// openWorldHint: false - the bridge only ever talks to the local dev server.
|
|
@@ -70,7 +71,7 @@ let TOOLS: {
|
|
|
70
71
|
name: "list_clients",
|
|
71
72
|
readOnly: true,
|
|
72
73
|
description:
|
|
73
|
-
"List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors) and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
|
|
74
|
+
"List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors), `entry` (the app source file this server currently serves and rebuilds - check it matches the app you intend to drive before acting, since the dev port is fixed and a `load` moves the entry mid-session), `projectDir` (the project root the server was started in), and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime, and `queries` - the dev-tool query kinds that client's runtime answers (clock, input, snapshot, tree, ...). Check `queries` before planning a verification strategy: a client whose list lacks \"input\" predates send_input, one that lacks \"clock\" predates set_time_scale/step_frames (an empty list means the runtime predates the advertisement itself). Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
|
|
74
75
|
inputSchema: {},
|
|
75
76
|
},
|
|
76
77
|
{
|
|
@@ -110,7 +111,7 @@ let TOOLS: {
|
|
|
110
111
|
name: "get_render_tree",
|
|
111
112
|
readOnly: true,
|
|
112
113
|
description:
|
|
113
|
-
"Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
|
|
114
|
+
"Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Pass props: true to also get each node's current property values (JSX names, only values that differ from the defaults - so an empty/absent props object means everything is at its default) and, for nodes moved off their box by a rotate/scale/3D transform anywhere on their ancestor chain, `quad`: the four painted corners in window coordinates [x0,y0, x1,y1, x2,y2, x3,y3] (pre-transform top-left, top-right, bottom-right, bottom-left). The box is always the quad's axis-aligned bounds, so under a transform the box alone overstates the footprint - read the quad for where edges actually landed. Use props to answer 'is rotate/color/d applied right now' in one call instead of loading probe entries. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` (+ props) to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
|
|
114
115
|
inputSchema: {
|
|
115
116
|
root: z
|
|
116
117
|
.number()
|
|
@@ -130,6 +131,10 @@ let TOOLS: {
|
|
|
130
131
|
"scope the search; `depth` is ignored.",
|
|
131
132
|
)
|
|
132
133
|
.optional(),
|
|
134
|
+
props: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.describe("Include each node's current off-default property values and, for transformed nodes, the painted quad")
|
|
137
|
+
.optional(),
|
|
133
138
|
client: CLIENT_ARG,
|
|
134
139
|
},
|
|
135
140
|
},
|
|
@@ -137,12 +142,30 @@ let TOOLS: {
|
|
|
137
142
|
name: "get_snapshot",
|
|
138
143
|
readOnly: true,
|
|
139
144
|
description:
|
|
140
|
-
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the <texture> leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
|
|
145
|
+
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the <texture> leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). A capture renders only that node's subtree, with no ancestor paint: pixels nothing in the subtree draws come back transparent, not the background an ancestor draws behind the node - capture the window root when the background matters. Pass x/y/width/height to crop and `scale` to magnify: captures may be downscaled before you see them, so verify small hand-authored geometry (sprites, path data, icons) with a tight crop at 4x-8x rather than squinting at a full capture. Crop coordinates are in captured-image pixels (the width x height a capture of that node reports - device pixels), not the logical units get_render_tree reports. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
|
|
141
146
|
inputSchema: {
|
|
142
147
|
nodeId: z
|
|
143
148
|
.number()
|
|
144
149
|
.int()
|
|
145
150
|
.describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"),
|
|
151
|
+
x: z
|
|
152
|
+
.number()
|
|
153
|
+
.int()
|
|
154
|
+
.describe("Crop rect left edge in captured-image pixels (requires y, width, height)")
|
|
155
|
+
.optional(),
|
|
156
|
+
y: z.number().int().describe("Crop rect top edge in captured-image pixels").optional(),
|
|
157
|
+
width: z.number().int().describe("Crop rect width in captured-image pixels").optional(),
|
|
158
|
+
height: z.number().int().describe("Crop rect height in captured-image pixels").optional(),
|
|
159
|
+
scale: z
|
|
160
|
+
.number()
|
|
161
|
+
.int()
|
|
162
|
+
.min(1)
|
|
163
|
+
.max(8)
|
|
164
|
+
.describe(
|
|
165
|
+
"Integer magnification, 1-8: each captured pixel becomes an NxN block (nearest-neighbour), so you see " +
|
|
166
|
+
"the actual rendered pixels enlarged. Combine with a crop; the scaled output is capped at 8192 px per side",
|
|
167
|
+
)
|
|
168
|
+
.optional(),
|
|
146
169
|
save_to: SAVE_TO_ARG,
|
|
147
170
|
client: CLIENT_ARG,
|
|
148
171
|
},
|
|
@@ -158,13 +181,23 @@ let TOOLS: {
|
|
|
158
181
|
name: "get_texture",
|
|
159
182
|
readOnly: true,
|
|
160
183
|
description:
|
|
161
|
-
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShaderTexture/createPipelineTexture in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
|
|
184
|
+
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShaderTexture/createPipelineTexture in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas, and `scale` to magnify small content like a single tile or glyph.",
|
|
162
185
|
inputSchema: {
|
|
163
186
|
id: z.number().int().describe("Texture id, from get_gpu_resources"),
|
|
164
187
|
x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
|
|
165
188
|
y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
|
|
166
189
|
width: z.number().int().describe("Crop rect width in texture pixels").optional(),
|
|
167
190
|
height: z.number().int().describe("Crop rect height in texture pixels").optional(),
|
|
191
|
+
scale: z
|
|
192
|
+
.number()
|
|
193
|
+
.int()
|
|
194
|
+
.min(1)
|
|
195
|
+
.max(8)
|
|
196
|
+
.describe(
|
|
197
|
+
"Integer magnification, 1-8: each texture pixel becomes an NxN block (nearest-neighbour). Combine with " +
|
|
198
|
+
"a crop; the scaled output is capped at 8192 px per side",
|
|
199
|
+
)
|
|
200
|
+
.optional(),
|
|
168
201
|
save_to: SAVE_TO_ARG,
|
|
169
202
|
client: CLIENT_ARG,
|
|
170
203
|
},
|
|
@@ -213,6 +246,61 @@ let TOOLS: {
|
|
|
213
246
|
entry: z.string().describe("App entry source file to load (relative paths resolve against the project root)"),
|
|
214
247
|
},
|
|
215
248
|
},
|
|
249
|
+
{
|
|
250
|
+
name: "set_time_scale",
|
|
251
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
252
|
+
description:
|
|
253
|
+
"Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, performance.now() holds still, and the picture stops - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. Date.now() stays wall time throughout. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.",
|
|
254
|
+
inputSchema: {
|
|
255
|
+
scale: z
|
|
256
|
+
.number()
|
|
257
|
+
.min(0)
|
|
258
|
+
.describe("Time scale: 0 = pause, 1 = normal, 0.5 = half speed, 2 = double speed"),
|
|
259
|
+
client: CLIENT_ARG,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "step_frames",
|
|
264
|
+
annotations: { destructiveHint: false },
|
|
265
|
+
description:
|
|
266
|
+
"While paused (set_time_scale 0), advance a running app client by exactly n frames: each frame moves app time forward one refresh period (~16.7 ms at 60 Hz), runs onFrame/requestAnimationFrame and any timers that come due, and presents the result. Deterministic single-stepping for animations and game logic: pause, snapshot, step, snapshot again to see exactly what changed in n frames. With the clock running this is a no-op (frames already flow). Steps are applied at the client's frame rate, so n frames take about n refresh periods of wall time before a following snapshot shows the result.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
n: z.number().int().min(1).max(1000).describe("Number of frames to advance (1-1000)"),
|
|
269
|
+
client: CLIENT_ARG,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "send_input",
|
|
274
|
+
description:
|
|
275
|
+
"Send synthetic input to a running app client through the real input pipeline (hit testing, focus, event bubbling) - the same path physical input takes, unlike call_debug which sets state directly, so use this to verify interactions actually work. Events run in order; each may wait delayMs (0-5000 ms) before firing, and the call returns after the last event has entered the pipeline, so a following get_snapshot sees the result. Event kinds: {type:'pointer', action:'down'|'up'|'move'|'tap', x, y} for clicks and drags - coordinates in logical points, the same space get_render_tree reports; 'tap' is down+up with an optional holdMs between; button 0 = left (default), 1 = middle, 2 = right; pointerType 'mouse' (default) keeps hovering at its last position afterwards like a real cursor, use 'touch' for gestures that should end hover-free. {type:'key', action:'down'|'up'|'tap', key} with W3C key names exactly as the runtime reports them ('w', 'ArrowLeft', 'Enter', ' '); a 'tap' with holdMs holds the key down that long, e.g. holdMs 500 = walk forward half a second in one call; modifier booleans shift/ctrl/alt/meta. {type:'text', text} enters text through the TextInput path - focus the target first with a pointer tap on it (the tap also activates the text session). {type:'wheel', x, y, deltaX, deltaY} scrolls; positive deltaY scrolls content down. Recipes: click a button = [{type:'pointer',action:'tap',x:400,y:300}]. Drag = down, then moves with delayMs 16 each, then up. Deterministic interaction test = set_time_scale 0, send_input, step_frames, get_snapshot. A down/up over empty space hits nothing, exactly like real input - check coordinates against get_render_tree when a click seems to do nothing.",
|
|
276
|
+
inputSchema: {
|
|
277
|
+
events: z
|
|
278
|
+
.array(
|
|
279
|
+
z.object({
|
|
280
|
+
type: z.enum(["key", "pointer", "wheel", "text"]),
|
|
281
|
+
action: z.enum(["down", "up", "move", "tap"]).optional(),
|
|
282
|
+
key: z.string().optional().describe("W3C key name, required for type key"),
|
|
283
|
+
text: z.string().optional().describe("Text to enter, required for type text"),
|
|
284
|
+
x: z.number().optional().describe("Logical points, required for pointer and wheel"),
|
|
285
|
+
y: z.number().optional().describe("Logical points, required for pointer and wheel"),
|
|
286
|
+
deltaX: z.number().optional(),
|
|
287
|
+
deltaY: z.number().optional(),
|
|
288
|
+
button: z.number().int().min(0).max(4).optional(),
|
|
289
|
+
pointerType: z.enum(["mouse", "touch"]).optional(),
|
|
290
|
+
delayMs: z.number().int().min(0).max(5000).optional().describe("Wait before this event"),
|
|
291
|
+
holdMs: z.number().int().min(0).max(5000).optional().describe("Tap only: time between down and up"),
|
|
292
|
+
shift: z.boolean().optional(),
|
|
293
|
+
ctrl: z.boolean().optional(),
|
|
294
|
+
alt: z.boolean().optional(),
|
|
295
|
+
meta: z.boolean().optional(),
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
.min(1)
|
|
299
|
+
.max(200)
|
|
300
|
+
.describe("Event sequence, executed in order"),
|
|
301
|
+
client: CLIENT_ARG,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
216
304
|
{
|
|
217
305
|
name: "watch",
|
|
218
306
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
@@ -248,6 +336,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
248
336
|
if (typeof args?.root === "number") params.set("root", String(args.root))
|
|
249
337
|
if (typeof args?.depth === "number") params.set("depth", String(args.depth))
|
|
250
338
|
if (typeof args?.query === "string") params.set("query", args.query)
|
|
339
|
+
if (args?.props === true) params.set("props", "true")
|
|
251
340
|
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
252
341
|
let qs = params.toString()
|
|
253
342
|
return control(qs ? `/tree?${qs}` : "/tree")
|
|
@@ -267,9 +356,31 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
267
356
|
case "get_snapshot": {
|
|
268
357
|
if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
|
|
269
358
|
let params = new URLSearchParams({ node: String(args.nodeId) })
|
|
359
|
+
for (let key of ["x", "y", "width", "height", "scale"]) {
|
|
360
|
+
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
361
|
+
}
|
|
270
362
|
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
271
363
|
return control(`/snapshot?${params.toString()}`)
|
|
272
364
|
}
|
|
365
|
+
case "set_time_scale": {
|
|
366
|
+
if (typeof args?.scale !== "number" || !(args.scale >= 0)) {
|
|
367
|
+
return { ok: false, message: "set_time_scale requires scale >= 0" }
|
|
368
|
+
}
|
|
369
|
+
let params = new URLSearchParams({ scale: String(args.scale) })
|
|
370
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
371
|
+
return control(`/clock?${params.toString()}`, "POST")
|
|
372
|
+
}
|
|
373
|
+
case "step_frames": {
|
|
374
|
+
if (typeof args?.n !== "number" || !(args.n >= 1)) return { ok: false, message: "step_frames requires n >= 1" }
|
|
375
|
+
let params = new URLSearchParams({ step: String(args.n) })
|
|
376
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
377
|
+
return control(`/clock?${params.toString()}`, "POST")
|
|
378
|
+
}
|
|
379
|
+
case "send_input": {
|
|
380
|
+
if (!Array.isArray(args?.events) || args.events.length === 0)
|
|
381
|
+
return { ok: false, message: "send_input requires a non-empty events array" }
|
|
382
|
+
return control(`/input${clientParam(args)}`, "POST", { events: args.events })
|
|
383
|
+
}
|
|
273
384
|
case "get_gpu_resources":
|
|
274
385
|
return control(`/gpu${clientParam(args)}`)
|
|
275
386
|
case "list_debug":
|
|
@@ -283,7 +394,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
283
394
|
case "get_texture": {
|
|
284
395
|
if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
|
|
285
396
|
let params = new URLSearchParams({ id: String(args.id) })
|
|
286
|
-
for (let key of ["x", "y", "width", "height"]) {
|
|
397
|
+
for (let key of ["x", "y", "width", "height", "scale"]) {
|
|
287
398
|
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
288
399
|
}
|
|
289
400
|
if (typeof args?.client === "number") params.set("client", String(args.client))
|