@solidrt/cli 0.0.53 → 0.0.55

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/src/mcp/main.ts CHANGED
@@ -192,7 +192,7 @@ let TOOLS: {
192
192
  name: "list_clients",
193
193
  annotations: READ_ONLY,
194
194
  description:
195
- "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
195
+ "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...), `refreshRate` (the display's nominal refresh rate in Hz as SDL reported it at connect, what `onFrame`'s `rate` argument carries; null when the client connected before its window existed, a reconnect fills it in) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
196
196
  inputSchema: {},
197
197
  },
198
198
  {
@@ -232,7 +232,7 @@ let TOOLS: {
232
232
  name: "get_stats",
233
233
  annotations: READ_ONLY,
234
234
  description:
235
- "Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
235
+ "Performance statistics from a running app client. Start with `window`: a summary of the frames that changed the picture in the last window_ms (default 5000, max 10000): tree rebuilds, plus GPU content changes presented without one (a layer write, a shader param, an upload: a sprite or shader app's every frame, where the critical path is the render handler alone) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing changed the picture in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
236
236
  inputSchema: {
237
237
  window_ms: z
238
238
  .number()
@@ -321,9 +321,10 @@ let TOOLS: {
321
321
  name: "get_gpu_resources",
322
322
  annotations: READ_ONLY,
323
323
  description:
324
- "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change.",
324
+ "Inventory of a running app client's GPU resources: textures (id, size, format, whether a shader renders into it, and its declared sampler - filter/wrap/mipmap/anisotropy - the first thing to check when a map looks soft or aliased), 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
  },
@@ -415,14 +416,14 @@ let TOOLS: {
415
416
  name: "pause_watch",
416
417
  annotations: SETS_STATE,
417
418
  description:
418
- "Pause the dev server's reload-on-save until resume_watch, so your half-finished saves are not pushed to the user's screens while you edit; your explicit reload still is. Call it before an edit burst; when the edits are done, reload, then resume_watch. Changes saved while paused are not replayed on resume: reload is what pushes them. The pause lifts on resume_watch, when the dev server goes away, or when this bridge exits. ALWAYS resume when you are done: while paused, the human's own saves reach nothing.",
419
+ "Pause the dev server's reload-on-save until resume_watch, so your half-finished saves are not pushed to the user's screens while you edit; your explicit reload still is. Call it before an edit burst; when the edits are done, reload, then resume_watch. Changes saved while paused are not replayed on resume: reload is what pushes them. The pause lifts on resume_watch, when the dev server goes away, or when this bridge exits. ALWAYS resume when you are done: while paused, the human's own saves reach nothing. Returns `{ ok, watchPaused }`.",
419
420
  inputSchema: {},
420
421
  },
421
422
  {
422
423
  name: "resume_watch",
423
424
  annotations: SETS_STATE,
424
425
  description:
425
- "Lift the pause set by pause_watch: saves push again, the human's included. Call it after your reload, and always before you stop working.",
426
+ "Lift the pause set by pause_watch: saves push again, the human's included. Call it after your reload, and always before you stop working. Returns `{ ok, watchPaused }`.",
426
427
  inputSchema: {},
427
428
  },
428
429
  {
@@ -529,8 +530,12 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
529
530
  case "resume_watch": {
530
531
  let paused = name === "pause_watch"
531
532
  let result = await control(`/watch?active=${!paused}`, "POST")
532
- if (result.ok) watchPaused = paused
533
- return result
533
+ if (!result.ok) return result
534
+ watchPaused = paused
535
+ // The control API answers in its uniform `active` (whether it watches);
536
+ // name the state as list_clients does, so an `active: false` cannot be
537
+ // read as "the pause is not active".
538
+ return { ...result, body: { ok: true, watchPaused } }
534
539
  }
535
540
  case "get_snapshot": {
536
541
  if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
@@ -565,6 +570,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
565
570
  case "get_gpu_resources": {
566
571
  let params = new URLSearchParams()
567
572
  if (typeof args?.label === "string") params.set("label", args.label)
573
+ if (typeof args?.draw === "number") params.set("draw", String(args.draw))
568
574
  if (typeof args?.client === "number") params.set("client", String(args.client))
569
575
  let qs = params.toString()
570
576
  return control(`/gpu${qs ? `?${qs}` : ""}`)
@@ -81,6 +81,7 @@ export function clientList(withAddress = false): (ClientEntry & { address?: stri
81
81
  os: info.os,
82
82
  kernel: info.kernel,
83
83
  videoDriver: info.videoDriver,
84
+ refreshRate: info.refreshRate,
84
85
  gpu: info.gpu,
85
86
  ...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
86
87
  }))
@@ -410,9 +411,15 @@ export async function handleControl(req: Request, path: string, query: Map<strin
410
411
  return handleQuery(query, "snapshot", extra)
411
412
  }
412
413
  case "/__control__/gpu": {
413
- // ?label=<text> keeps only resources created with exactly that label.
414
+ // ?label=<text> keeps only resources created with exactly that label;
415
+ // ?draw=<id> reports that draw entry's params in full (matrix-valued
416
+ // params are elided everywhere else).
417
+ let extra: Record<string, unknown> = {}
414
418
  let label = query.get("label")
415
- return handleQuery(query, "gpu", label === undefined ? undefined : { label })
419
+ if (label !== undefined) extra.label = label
420
+ let draw = parseInt(query.get("draw") ?? "", 10)
421
+ if (Number.isFinite(draw)) extra.draw = draw
422
+ return handleQuery(query, "gpu", extra)
416
423
  }
417
424
  case "/__control__/debug": {
418
425
  // GET lists the app's registered debug commands; POST calls one, with
@@ -2,6 +2,8 @@
2
2
 
3
3
  `run` is the everyday command: it starts the dev server and a local client
4
4
  window together, and it is what `bun run dev` calls in a scaffolded project.
5
+ The server outlives the client: closing (or killing) a wedged client keeps
6
+ the server up, and `srt client` reattaches a new one.
5
7
 
6
8
  {{ usage run }}
7
9
 
@@ -248,6 +248,7 @@ function onOpen(ws: ServerWebSocket) {
248
248
  os: null,
249
249
  kernel: null,
250
250
  videoDriver: null,
251
+ refreshRate: null,
251
252
  gpu: null,
252
253
  })
253
254
  console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
@@ -263,10 +264,6 @@ function onClose(ws: ServerWebSocket) {
263
264
  let info = state.clients.get(ws)
264
265
  state.clients.delete(ws)
265
266
  console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
266
- // `srt run` lives as long as its clients: once the local client is gone,
267
- // the last remote disconnect ends the server. `srt server` runs until
268
- // stopped.
269
- if (config.client && localClientExited && state.clients.size === 0) shutdown()
270
267
  }
271
268
 
272
269
  function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
@@ -290,6 +287,7 @@ function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
290
287
  os: text(data.os),
291
288
  kernel: text(data.kernel),
292
289
  videoDriver: text(data.videoDriver),
290
+ refreshRate: typeof data.refreshRate === "number" ? data.refreshRate : null,
293
291
  gpu:
294
292
  data.gpu && typeof data.gpu === "object"
295
293
  ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" }
@@ -399,7 +397,6 @@ let keepalive = setInterval(() => {
399
397
  let shuttingDown = false
400
398
  let stopRepl = () => {}
401
399
  let localClient: Child | null = null
402
- let localClientExited = false
403
400
  let signalOffs = ["SIGINT", "SIGTERM"].map((signal) =>
404
401
  onSignal(signal, () => {
405
402
  shutdown()
@@ -463,14 +460,11 @@ if (config.client) {
463
460
  localClient = child
464
461
  pump(child.stdout, (line) => console.log(line))
465
462
  pump(child.stderr, (line) => console.error(line))
463
+ // The server outlives its client: a wedged or crashed client is restarted
464
+ // with `srt client` (it reattaches by cwd) without losing the server, its
465
+ // bundle, the watcher or the MCP session. The server stops on quit/signal.
466
466
  child.status().then(() => {
467
467
  localClient = null
468
- localClientExited = true
469
- if (shuttingDown) return
470
- if (state.clients.size === 0) {
471
- shutdown()
472
- } else {
473
- console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
474
- }
468
+ if (!shuttingDown) console.log("[cli] Local client exited; the server keeps running (srt client reattaches)")
475
469
  })
476
470
  }
@@ -35,6 +35,11 @@ export type ClientEntry = {
35
35
  kernel: string | null
36
36
  /** The SDL video driver ("wayland", "x11", "android", "offscreen", ...). */
37
37
  videoDriver: string | null
38
+ /** The display's nominal refresh rate in Hz as SDL reported it when the
39
+ * client connected (what `onFrame`'s `rate` argument carries); null on a
40
+ * runtime that predates it, or on a client that connected before its
41
+ * window existed (a reconnect fills it in). */
42
+ refreshRate: number | null
38
43
  /** The GPU strings as GL reports them; null on a client that connected
39
44
  * before its GL context existed (a reconnect fills it in). */
40
45
  gpu: GpuInfo | null
File without changes