@solidrt/cli 0.0.26 → 0.0.28

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -18,22 +18,25 @@
18
18
  "@babel/core": "^7.0.0",
19
19
  "@babel/plugin-syntax-jsx": "^7.0.0",
20
20
  "@babel/preset-typescript": "^7.0.0",
21
+ "@jridgewell/remapping": "^2.3.0",
22
+ "@jridgewell/trace-mapping": "^0.3.25",
21
23
  "@modelcontextprotocol/sdk": "^1.29.0",
22
24
  "babel-preset-solid": "2.0.0-beta.17",
23
25
  "bonjour-service": "^1.4.0",
24
- "qrcode-generator": "^2.0.4"
26
+ "qrcode-generator": "^2.0.4",
27
+ "zod": "^4.4.3"
25
28
  },
26
29
  "optionalDependencies": {
27
- "@solidrt/darwin-arm64": "0.0.26",
28
- "@solidrt/linux-x64-gnu": "0.0.26",
29
- "@solidrt/win32-x64-msvc": "0.0.26"
30
+ "@solidrt/darwin-arm64": "0.0.28",
31
+ "@solidrt/linux-x64-gnu": "0.0.28",
32
+ "@solidrt/win32-x64-msvc": "0.0.28"
30
33
  },
31
34
  "peerDependencies": {
32
- "@solidrt/core": "0.0.26",
35
+ "@solidrt/core": "0.0.28",
33
36
  "typescript": "^7"
34
37
  },
35
38
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.26",
39
+ "@solidrt/flux-types": "0.0.28",
37
40
  "@types/bun": "latest"
38
41
  }
39
42
  }
@@ -4,6 +4,23 @@ This project uses SolidRT: a custom SolidJS renderer that paints through a Rust
4
4
  runtime. No DOM, no HTML, no CSS cascade. If you are an AI assistant, read this
5
5
  before writing or editing code here.
6
6
 
7
+ ## Levels: core, and frameworks on top
8
+
9
+ - @solidrt/core is the low-level foundation: host intrinsics (`<window>`,
10
+ `<view>`, `<text>`, the detached `d-*` drawing primitives) with flat props
11
+ that feed the layout and paint engine directly. An app can be written
12
+ entirely at this level.
13
+ - Higher-level component frameworks build on core. @solidrt/components is
14
+ the first-party one: themed widgets (Window, View, Text, Button,
15
+ ScrollView, SafeArea, ...) with the `layout={{...}}`/`style={{...}}` prop
16
+ split. It is not privileged - a framework is just functions returning core
17
+ JSX, and an app can use a third-party one or grow its own.
18
+
19
+ Match the level the code you are editing already uses. package.json shows
20
+ the choice this app made: if no component framework is among the
21
+ dependencies, the app is core-only - do not add one for a change core
22
+ covers.
23
+
7
24
  Authoritative references ship inside the installed packages - read them:
8
25
  - node_modules/solid-js/CHEATSHEET.md - SolidJS 2.0 reactivity/control-flow model
9
26
  - node_modules/@solidrt/components/AGENTS.md - the component vocabulary; build UI from these
@@ -24,10 +41,10 @@ Authoritative references ship inside the installed packages - read them:
24
41
 
25
42
  1. This is SolidJS 2.0 (see CHEATSHEET.md for the reactivity/control-flow
26
43
  model), rendering through a custom Rust runtime instead of the DOM. Build
27
- UI from @solidrt/components (Window, View, Text, Image, TextInput,
28
- ScrollView, Pressable, Button, SafeArea, theme/setTheme) - it is the
29
- higher-level, batteries-included vocabulary and is where most app code
30
- should live.
44
+ UI at the level the app uses (see "Levels" above): core intrinsics
45
+ directly, or a component framework such as @solidrt/components (Window,
46
+ View, Text, Image, TextInput, ScrollView, Pressable, Button, SafeArea,
47
+ theme/setTheme) - the first-party, batteries-included one.
31
48
  2. Most components split their props into two objects: `layout={{...}}` for
32
49
  anything that feeds the layout engine (flex/grid, sizing, padding/margin,
33
50
  position; font fields for Text) and `style={{...}}` for paint-only
@@ -52,7 +69,8 @@ Authoritative references ship inside the installed packages - read them:
52
69
  (runtime-paced, auto-cleans), re-exported from @solidrt/core.
53
70
  requestAnimationFrame(t => {}) exists as a web-standard one-shot but is
54
71
  not the preferred animation driver.
55
- 8. Reach for @solidrt/core directly only for what components doesn't wrap:
72
+ 8. In a components-based app, reach for @solidrt/core directly only for
73
+ what components doesn't wrap:
56
74
  raw host intrinsics and the `d-` (detached, non-layout) primitives like
57
75
  `d-rect`/`d-path`/`d-oval` for vector art or perf-sensitive positioned
58
76
  drawing, device/GPU subpath imports (@solidrt/core/camera, /microphone,
@@ -76,11 +94,29 @@ Authoritative references ship inside the installed packages - read them:
76
94
  `compute` is the tracked read phase; `apply(value, prev)` runs untracked
77
95
  and is where side effects/DOM-equivalent writes belong. The old
78
96
  single-arg `createEffect(fn)` form is gone - using it is an error.
97
+ 13. A scroll container (ScrollView, or anything on createScroll) needs an
98
+ explicit main-axis size - a height, or flex inside a sized parent. With
99
+ neither it resolves to 0 and its content silently vanishes; maxHeight
100
+ alone does not size it (the auto size it would clamp is already 0). The
101
+ runtime warns when this happens.
102
+ 14. Text `lineHeight` is a MULTIPLIER of fontSize (the theme uses 1.3-1.6),
103
+ not pixels. A CSS-reflex value like 22 makes each line box 22x the font
104
+ size: the text becomes blank space and the parent balloons.
105
+ 15. Signal writes flush on a microtask: a handler that sets a signal and
106
+ immediately reads it back gets the OLD value. Read the new value in an
107
+ effect, or call `flush()` (from @solidjs/signals) to force it through.
108
+ 16. Portals cannot mount during the app's initial render: a Modal (or any
109
+ createPortal content) that is visible at first mount throws "no mount
110
+ target". Gate it behind a signal that starts false and open it after
111
+ startup - overlay content is opened, not born open.
79
112
 
80
113
  ## Run / verify
81
114
 
82
115
  - bunx srt run src/index.tsx - dev server + window (needs a display)
83
- - bunx srt bundle src/index.tsx - exit 0 means it compiles
116
+ - bunx srt check src/index.tsx - exit 0 means it compiles and the app's
117
+ types hold (dependency-internal type errors are hidden). Builds in memory:
118
+ writes nothing and never triggers a dev-server reload, so use this while
119
+ iterating - `srt bundle` writes output files and reloads connected clients
84
120
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
85
121
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
86
122
  frames land)
@@ -93,10 +129,103 @@ its tools over guessing at runtime state:
93
129
 
94
130
  - list_clients: connected app clients, their platform and runtime capabilities
95
131
  - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
96
- to catch output right after a reload)
132
+ to catch output right after a reload; `level`/`contains` filters; repeated
133
+ lines collapse into one entry with a `repeats` count)
97
134
  - get_render_tree: what the app actually rendered - node kinds, text, and
98
- window-relative boxes
99
- - get_stats: fps, CPU/memory, frame phase timings, setProperty rate
135
+ window-relative boxes. Whole trees get large: `query` finds nodes by
136
+ kind/text, then `root` + `depth` inspect just that region
137
+ - client ids and log cursors die with the dev server: list_clients and
138
+ get_logs responses carry `generation`, and a changed generation means
139
+ re-fetch ids and restart cursors
140
+ - get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
141
+ layout-activity counters for the last rebuild (nodes, measureCalls,
142
+ paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
143
+ wrong, these say whether the cost is text shaping, invalidation breadth,
144
+ or a defeated layout cache (healthy incremental rebuilds show a near-100%
145
+ cacheHits rate)
146
+ - get_snapshot: PNG capture of any render-tree node's pixels (get node ids
147
+ from get_render_tree; the window node captures everything). Pass `save_to`
148
+ on get_snapshot or get_texture to also write the PNG to a file - the image
149
+ in the tool result cannot be saved afterwards, so decide before capturing
150
+ (e.g. keep a before/after pair to diff)
151
+ - get_gpu_resources: inventory of GPU state - textures (size, render target
152
+ or not), vertex buffers (byteLength), pipelines (draw count, attribute
153
+ layout, bound textures, last-applied uniform values)
154
+ - get_texture: any GPU texture read back as a PNG by id - atlases, data
155
+ textures, and shader/pipeline render targets alike (a render target is
156
+ "what this pipeline last drew", no frame or snapshot needed); crop with
157
+ x/y/width/height
158
+ - get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
159
+ per call) - verify geometry after a writeBuffer instead of inferring it
160
+ from pixels
161
+ - reload: rebuild from source and push to every client - THE dev loop is
162
+ edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
163
+ but not type errors; run `bunx srt check` for those.
100
164
 
101
165
  The tools need a running app: if list_clients is empty, ask the user to start
102
166
  `bunx srt run src/index.tsx`.
167
+
168
+ ## Debugging a running app (lessons that cost real time)
169
+
170
+ - console.log + get_logs is your primary probe into runtime state. For state
171
+ you will want repeatedly (a pose, a mode, a counter), bind a debug key that
172
+ logs it and read it back via get_logs.
173
+ - Key events are delivered ONLY to the focused node (no bubbling): call
174
+ setFocus(node.id) from the window's ref or onKeyDown never fires. This
175
+ runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
176
+ - Idle frames skip work: shaders/pipelines only re-render when their params
177
+ change, so measure performance while uniforms are actually changing.
178
+ get_snapshot works on an idle client (it requests its own frame); a
179
+ timeout means the JS thread is busy or wedged. get_texture on a pipeline's
180
+ render target reads the last-drawn frame without needing a new one.
181
+ - When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
182
+ in it before investigating, so you agree on the symptom. If you cannot see
183
+ the problem in the capture, say that instead of guessing.
184
+ - GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
185
+ for draw counts/uniforms/sizes, get_texture for atlas or data-texture
186
+ contents ("is this tile blank?" is a ten-second question), get_buffer for
187
+ vertex data. The pixels only tell you THAT something is wrong; the
188
+ resources tell you WHERE the data stops being right. In a one-big-pipeline
189
+ app the render tree is a single <texture> leaf and tells you nothing -
190
+ these tools are the visibility layer behind it. Only when the GPU data is
191
+ all correct (so the bug is in producing it, or in the shader), reproduce
192
+ the math CPU-side in a scratch bun script against the app's real data and
193
+ print values.
194
+ - Validate assets at load time and log anomalies (missing lumps/files,
195
+ fully-transparent composites, zero-sized images). Silent fallbacks hide
196
+ bugs for days; a one-line warning surfaces them the first run.
197
+ - After every reload the app restarts from its initial state. If reaching
198
+ the bug site takes navigation, add a dev shortcut (teleport key, noclip,
199
+ initial-state override) before iterating - the round trips add up fast.
200
+ - Clamp onFrame time deltas to [0, cap], not just capped: across a hot
201
+ reload the runtime's tick counter resets AFTER the new instance's first
202
+ frame, so the second frame computes a hugely NEGATIVE delta.
203
+ Math.min(dt, cap) lets it through, and one bad frame can corrupt anything
204
+ integrated from dt (positions fly off, accumulators go so negative they
205
+ never recover). Math.max(0, Math.min(dt, cap)) costs nothing.
206
+ - Frames are demand-gated: JS frame callbacks only run when the previous
207
+ frame changed something (input, signal write, GPU upload). An app whose
208
+ onFrame returns early without side effects on its first frame never gets
209
+ a second one - self-running animation (game clocks, shader-driven
210
+ effects) must make one state change at startup to prime the loop; after
211
+ that its own writes keep it awake.
212
+ - Layout is incremental: a change re-solves only the dirty path, and clean
213
+ subtrees answer from a per-node cache, so long lists no longer cap layout
214
+ (a thousand-node tree relays out in well under a millisecond). If layoutMs
215
+ still grows with tree size, read the get_stats counters - a low
216
+ cacheHits/cacheGets ratio means the layout cache is being defeated, high
217
+ paraShapes means text is actually reshaping. Very long lists still pay
218
+ for the initial mount and for memory, so windowing stays sensible at the
219
+ thousands-of-rows scale.
220
+ - Remote images: createImage (and Image) dedupes repeated URLs, caches the
221
+ bytes on disk, and the runtime rate-limits concurrent asset fetches per
222
+ host - do not build your own promise cache around it. Images are fetched
223
+ with no freshness check (an already-cached URL is never re-checked), so
224
+ use versioned URLs for content that changes. Use Image's `fallback` prop
225
+ (an image source) for the broken-image case instead of catching errors
226
+ yourself.
227
+ - fetch() never caches by default and ignores server cache headers. Caching
228
+ is explicit and per call: `fetch(url, { cache: "force-cache" })` for
229
+ assets (serve from disk or fetch-and-store, no freshness),
230
+ `{ cache: "reload" }` to refresh an entry. Image/createImage already do
231
+ this for you.
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.26",
13
- "@solidrt/components": "0.0.26"
12
+ "@solidrt/core": "0.0.28",
13
+ "@solidrt/components": "0.0.28"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.26",
17
- "@solidrt/flux-types": "0.0.26",
16
+ "@solidrt/cli": "0.0.28",
17
+ "@solidrt/flux-types": "0.0.28",
18
18
  "typescript": "^7"
19
19
  }
20
20
  }
@@ -0,0 +1,15 @@
1
+ // Components template: built with the @solidrt/components framework.
2
+ import { render } from "@solidrt/core"
3
+ import { Window, SafeArea, Text } from "@solidrt/components"
4
+
5
+ function App() {
6
+ return (
7
+ <Window>
8
+ <SafeArea>
9
+ <Text>Hello, World!</Text>
10
+ </SafeArea>
11
+ </Window>
12
+ )
13
+ }
14
+
15
+ render(() => <App />)
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, blank start"
4
+ }
@@ -1,5 +1,5 @@
1
+ // Core-only template: built from @solidrt/core primitives.
1
2
  import { render, For, onFrame, createSignal, createLinearGradient } from "@solidrt/core"
2
- import { View, Window } from "@solidrt/components"
3
3
 
4
4
  const FADE = 360
5
5
 
@@ -13,7 +13,7 @@ let SEGMENTS = [
13
13
  { base: 540, light: "#7ea9ea", dark: "#5681c1", d: "M100.000 50.000 L75.000 75.000 L75.000 65.830 C73.810 65.830 72.711 65.195 72.116 64.165 C71.521 63.135 71.521 61.865 72.116 60.835 C72.711 59.805 73.810 59.170 75.000 59.170 L75.000 50.000 L75.000 40.830 C73.810 40.830 72.711 40.195 72.116 39.165 C71.521 38.135 71.521 36.865 72.116 35.835 C72.711 34.805 73.810 34.170 75.000 34.170 L75.000 25.000 L100.000 50.000 Z" },
14
14
  ]
15
15
 
16
- let LAST = SEGMENTS[SEGMENTS.length - 1].base
16
+ let LAST = SEGMENTS[SEGMENTS.length - 1]!.base
17
17
  let IN_DONE = LAST + FADE
18
18
  let CYCLE = IN_DONE + LAST + FADE
19
19
 
@@ -32,7 +32,7 @@ function Icon() {
32
32
  let start = -1
33
33
  onFrame((t) => (start < 0 && (start = t), setClock((t - start) % CYCLE)))
34
34
  return (
35
- <View layout={{ width: 100, height: 100 }} style={{ scale: 3 }}>
35
+ <view width={100} height={100} scale={3}>
36
36
  <For each={SEGMENTS}>
37
37
  {(seg) => {
38
38
  let a = () => {
@@ -43,7 +43,7 @@ function Icon() {
43
43
  return <d-path d={seg.d} color={fill(seg, a())} />
44
44
  }}
45
45
  </For>
46
- </View>
46
+ </view>
47
47
  )
48
48
  }
49
49
 
@@ -54,12 +54,10 @@ function App() {
54
54
  ])
55
55
 
56
56
  return (
57
- <Window
58
- layout={{ alignItems: "center", justifyContent: "center" }}
59
- style={{ backgroundColor }}
60
- >
57
+ <window alignItems="center" justifyContent="center">
58
+ <d-rect color={backgroundColor} />
61
59
  <Icon />
62
- </Window>
60
+ </window>
63
61
  )
64
62
  }
65
63
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, animated logo"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, widget tour"
4
+ }
@@ -1,9 +1,12 @@
1
- import { render } from "@solidrt/core"
1
+ // Core-only template: built from @solidrt/core primitives.
2
+ import { render, safeArea } from "@solidrt/core"
2
3
 
3
4
  function App() {
4
5
  return (
5
6
  <window>
6
- <text>Hello, World!</text>
7
+ <view flex={1} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
8
+ <text>Hello, World!</text>
9
+ </view>
7
10
  </window>
8
11
  )
9
12
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, blank start"
4
+ }
@@ -6,6 +6,7 @@
6
6
  "jsxImportSource": "@solidrt/core",
7
7
  "moduleResolution": "bundler",
8
8
  "strict": true,
9
+ "skipLibCheck": true,
9
10
  "types": ["@solidrt/flux-types"]
10
11
  },
11
12
  "include": ["src"]
package/server/control.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { state } from "./state"
2
+ import { rebuildAndBroadcast } from "./rebuild"
3
+ import { remapPositions } from "./remap"
2
4
  import type { ServerWebSocket } from "flux:http"
3
5
 
4
6
  // The control API under /__control__/: read-only introspection of connected
@@ -29,8 +31,9 @@ function sleep(ms: number): Promise<void> {
29
31
  }
30
32
 
31
33
  /// A `log` message arrived from a client: buffer it and wake long-polls.
34
+ /// Bundle positions in stack traces are remapped to .tsx sources on the way in.
32
35
  export function appendLog(client: number, level: string, text: string) {
33
- logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
36
+ logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMap) })
34
37
  if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
35
38
  let waiters = logWaiters
36
39
  logWaiters = []
@@ -55,6 +58,7 @@ export function clientList(withAddress = false) {
55
58
  id: info.id,
56
59
  platform: info.platform,
57
60
  version: info.version,
61
+ profile: info.profile,
58
62
  capabilities: info.capabilities,
59
63
  ...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
60
64
  }))
@@ -71,7 +75,19 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
71
75
  }
72
76
  let id = parseInt(param, 10)
73
77
  let entry = entries.find(([, info]) => info.id === id)
74
- if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
78
+ if (!entry) {
79
+ let ids = entries.map(([, info]) => info.id)
80
+ return {
81
+ error: Response.json(
82
+ {
83
+ error:
84
+ `Client ${param} is gone (connected ids: ${ids.length ? ids.join(", ") : "none"}). ` +
85
+ "Ids reset when the dev server restarts; call list_clients for current ones.",
86
+ },
87
+ { status: 404 },
88
+ ),
89
+ }
90
+ }
75
91
  return { ws: entry[0] }
76
92
  }
77
93
 
@@ -85,40 +101,91 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
85
101
  target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
86
102
  let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
87
103
  pendingQueries.delete(id)
88
- if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
89
- if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
104
+ if (!msg)
105
+ return Response.json(
106
+ { error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
107
+ { status: 504 },
108
+ )
109
+ // Error strings may carry stack traces (e.g. a debug command threw); remap
110
+ // bundle positions to .tsx sources like appendLog does for forwarded logs.
111
+ if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
90
112
  return Response.json(msg.data)
91
113
  }
92
114
 
93
- // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
94
- // latest seq as the next cursor. With `wait`, holds the response until a new
95
- // entry arrives or the timeout passes (long-poll), so a caller can follow the
96
- // stream without tight polling.
115
+ // Merge runs of consecutive identical entries (same client, level, text) into
116
+ // one entry carrying `repeats` and the run's last seq/at, so 176 copies of one
117
+ // error read as a single line and a `since` cursor still skips the whole run.
118
+ function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })[] {
119
+ let out: (LogEntry & { repeats?: number })[] = []
120
+ for (let e of entries) {
121
+ let last = out[out.length - 1]
122
+ if (last && last.client === e.client && last.level === e.level && last.text === e.text) {
123
+ last.repeats = (last.repeats ?? 1) + 1
124
+ last.seq = e.seq
125
+ last.at = e.at
126
+ } else {
127
+ out.push({ ...e })
128
+ }
129
+ }
130
+ return out
131
+ }
132
+
133
+ // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT: entries with
134
+ // seq > since, plus the latest seq as the next cursor and the server
135
+ // generation. `level` keeps only the listed levels; `contains` keeps entries
136
+ // whose text has the substring (case-insensitive). Consecutive identical
137
+ // entries come back collapsed with a `repeats` count. With `wait`, holds the
138
+ // response until an entry passes the filters or the timeout expires
139
+ // (long-poll), so a caller can follow the stream without tight polling.
97
140
  async function handleLogs(query: Map<string, string>): Promise<Response> {
98
141
  let since = parseInt(query.get("since") ?? "0", 10) || 0
99
142
  let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
100
- let entries = logs.filter((e) => e.seq > since)
101
- if (entries.length === 0 && wait > 0) {
143
+ let levels = query
144
+ .get("level")
145
+ ?.split(",")
146
+ .map((l) => l.trim())
147
+ .filter(Boolean)
148
+ let contains = query.get("contains")?.toLowerCase()
149
+ let select = () =>
150
+ logs.filter(
151
+ (e) =>
152
+ e.seq > since &&
153
+ (!levels || levels.length === 0 || levels.includes(e.level)) &&
154
+ (!contains || e.text.toLowerCase().includes(contains)),
155
+ )
156
+ let entries = select()
157
+ // Filtered long-poll: an append may not pass the filters, so keep waiting
158
+ // until one does or the deadline runs out.
159
+ let deadline = Date.now() + wait
160
+ while (entries.length === 0 && Date.now() < deadline) {
102
161
  await new Promise<void>((resolve) => {
103
- let timer = setTimeout(resolve, wait)
162
+ let timer = setTimeout(resolve, deadline - Date.now())
104
163
  logWaiters.push(() => {
105
164
  clearTimeout(timer)
106
165
  resolve()
107
166
  })
108
167
  })
109
- entries = logs.filter((e) => e.seq > since)
168
+ entries = select()
110
169
  }
111
- return Response.json({ entries, latest: logSeq })
170
+ return Response.json({ entries: collapseRepeats(entries), latest: logSeq, generation: state.generation })
112
171
  }
113
172
 
114
173
  export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
115
174
  switch (path) {
116
175
  case "/__control__/clients":
117
- return Response.json(clientList())
176
+ return Response.json({ generation: state.generation, clients: clientList() })
118
177
  case "/__control__/logs":
119
178
  return handleLogs(query)
120
- case "/__control__/tree":
121
- return handleQuery(query, "tree")
179
+ case "/__control__/tree": {
180
+ let extra: Record<string, unknown> = {}
181
+ let root = parseInt(query.get("root") ?? "", 10)
182
+ if (Number.isFinite(root)) extra.root = root
183
+ let depth = parseInt(query.get("depth") ?? "", 10)
184
+ if (Number.isFinite(depth)) extra.depth = depth
185
+ let q = query.get("query")
186
+ if (q) extra.query = q
187
+ return handleQuery(query, "tree", extra)
188
+ }
122
189
  case "/__control__/stats":
123
190
  return handleQuery(query, "stats")
124
191
  case "/__control__/snapshot": {
@@ -126,6 +193,55 @@ export async function handleControl(req: Request, path: string, query: Map<strin
126
193
  if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
127
194
  return handleQuery(query, "snapshot", { nodeId })
128
195
  }
196
+ case "/__control__/gpu":
197
+ return handleQuery(query, "gpu")
198
+ case "/__control__/debug": {
199
+ // GET lists the app's registered debug commands; POST calls one, with
200
+ // an optional JSON body as its args.
201
+ if (req.method !== "POST") return handleQuery(query, "debug_list")
202
+ let name = query.get("name")
203
+ if (!name) return Response.json({ error: "Debug call requires ?name=<command>" }, { status: 400 })
204
+ let args: unknown = null
205
+ try {
206
+ args = await req.json()
207
+ } catch {}
208
+ return handleQuery(query, "debug_call", { name, args })
209
+ }
210
+ case "/__control__/texture": {
211
+ let textureId = parseInt(query.get("id") ?? "", 10)
212
+ if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=<textureId>" }, { status: 400 })
213
+ // Optional crop: all four of x/y/width/height, in texture pixels.
214
+ let rectParams = ["x", "y", "width", "height"].map((k) => query.get(k))
215
+ let extra: Record<string, unknown> = { textureId }
216
+ if (rectParams.some((v) => v !== undefined)) {
217
+ let [x, y, width, height] = rectParams.map((v) => parseInt(v ?? "", 10))
218
+ if (![x, y, width, height].every(Number.isFinite))
219
+ return Response.json({ error: "Texture rect requires all of x, y, width, height" }, { status: 400 })
220
+ extra.rect = { x, y, width, height }
221
+ }
222
+ return handleQuery(query, "texture", extra)
223
+ }
224
+ case "/__control__/buffer": {
225
+ let bufferId = parseInt(query.get("id") ?? "", 10)
226
+ if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=<bufferId>" }, { status: 400 })
227
+ let extra: Record<string, unknown> = { bufferId }
228
+ let byteOffset = parseInt(query.get("offset") ?? "", 10)
229
+ if (Number.isFinite(byteOffset)) extra.byteOffset = byteOffset
230
+ let length = parseInt(query.get("length") ?? "", 10)
231
+ if (Number.isFinite(length)) extra.length = length
232
+ let as = query.get("as")
233
+ if (as !== undefined) extra.as = as
234
+ return handleQuery(query, "buffer", extra)
235
+ }
236
+ case "/__control__/reload": {
237
+ // Explicit rebuild-and-push, the primary way a coding agent applies its
238
+ // edits (srt mcp's reload tool). Unlike the repl's file watcher this is
239
+ // on demand, so a burst of edits collapses into one reload.
240
+ if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
241
+ let error = await rebuildAndBroadcast()
242
+ if (error) return Response.json({ error }, { status: 502 })
243
+ return Response.json({ ok: true, clients: state.clients.size })
244
+ }
129
245
  default:
130
246
  return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
131
247
  }
package/server/main.ts CHANGED
@@ -73,13 +73,18 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
73
73
 
74
74
  switch (path) {
75
75
  case "/__internal__/reload": {
76
- // { message, clients?, latch?, sourceDir? }: send `message` (a full
76
+ // { message, clients?, latch?, sourceDir?, map? }: send `message` (a full
77
77
  // client-protocol message, built by srt) to the listed client ids, or to
78
78
  // all when omitted. `latch` keeps it for late-joining clients (code
79
79
  // reloads latch, one-shot bytecode loads do not); `sourceDir` moves the
80
- // file-serving root (repl `load`).
80
+ // file-serving root (repl `load`); `map` is the bundle's sourcemap for
81
+ // log remapping, replaced on every reload (absent means none).
81
82
  let body = await req.json()
82
83
  if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
84
+ // Keep the rebuild entry in sync when `load` moves it, so a later MCP
85
+ // reload bundles the newly loaded file, not the launch-time one.
86
+ if (typeof body.entry === "string") state.config.entry = body.entry
87
+ state.currentMap = typeof body.map === "string" ? body.map : null
83
88
  let text = JSON.stringify(body.message)
84
89
  if (body.latch) state.currentReload = text
85
90
  sendTo(body.clients, text)
@@ -89,7 +94,10 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
89
94
  let body = await req.json()
90
95
  // A broadcast stop also forgets the latched reload, so a client that
91
96
  // connects afterwards starts clean.
92
- if (!body.clients) state.currentReload = null
97
+ if (!body.clients) {
98
+ state.currentReload = null
99
+ state.currentMap = null
100
+ }
93
101
  sendTo(body.clients, JSON.stringify({ type: "stop" }))
94
102
  return new Response("", { status: 204 })
95
103
  }
@@ -215,7 +223,7 @@ serve({
215
223
  websocket: {
216
224
  open(ws) {
217
225
  let id = state.nextClientId++
218
- state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
226
+ state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [] })
219
227
  console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
220
228
  // Advertise our real LAN address so clients dialed over a loopback hop
221
229
  // can show/remember the directly reachable address (see connection.rs).
@@ -239,6 +247,7 @@ serve({
239
247
  state.clients.set(ws, {
240
248
  platform: data.platform ?? "unknown",
241
249
  version: data.version ?? "unknown",
250
+ profile: data.profile ?? "unknown",
242
251
  id: existing?.id ?? state.nextClientId++,
243
252
  capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
244
253
  })