@solidrt/cli 0.0.28 → 0.0.30

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.28",
3
+ "version": "0.0.30",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,16 +27,17 @@
27
27
  "zod": "^4.4.3"
28
28
  },
29
29
  "optionalDependencies": {
30
- "@solidrt/darwin-arm64": "0.0.28",
31
- "@solidrt/linux-x64-gnu": "0.0.28",
32
- "@solidrt/win32-x64-msvc": "0.0.28"
30
+ "@solidrt/darwin-arm64": "0.0.30",
31
+ "@solidrt/linux-arm64-gnu": "0.0.30",
32
+ "@solidrt/linux-x64-gnu": "0.0.30",
33
+ "@solidrt/win32-x64-msvc": "0.0.30"
33
34
  },
34
35
  "peerDependencies": {
35
- "@solidrt/core": "0.0.28",
36
+ "@solidrt/core": "0.0.30",
36
37
  "typescript": "^7"
37
38
  },
38
39
  "devDependencies": {
39
- "@solidrt/flux-types": "0.0.28",
40
+ "@solidrt/flux-types": "0.0.30",
40
41
  "@types/bun": "latest"
41
42
  }
42
43
  }
@@ -109,6 +109,49 @@ Authoritative references ship inside the installed packages - read them:
109
109
  createPortal content) that is visible at first mount throws "no mount
110
110
  target". Gate it behind a signal that starts false and open it after
111
111
  startup - overlay content is opened, not born open.
112
+ 17. An element-valued prop (children, a content/icon slot) compiles to a
113
+ getter that builds a fresh native subtree on EVERY read, and a subtree
114
+ that is never inserted is never freed - native nodes are not garbage
115
+ collected, so what is only wasted work in DOM Solid is a permanent
116
+ memory leak here. Read such props exactly once, at the place they are
117
+ mounted. To inspect children (a typeof probe, counting), resolve them
118
+ first with the children() helper (re-exported from @solidrt/core) and
119
+ probe the resolved memo - never `typeof props.children` on the raw prop.
120
+
121
+ ## Performance model (JS is the slow lane)
122
+
123
+ The JS engine is interpreted and every property write crosses an FFI boundary
124
+ into the runtime, so per-frame JS work is the expensive path while GPU work is
125
+ nearly free. Rules, in order of leverage:
126
+
127
+ 1. Continuous effects (snow, particles, animated backgrounds) belong in a
128
+ fragment shader: createShader (from @solidrt/core/gpu) + `<texture
129
+ params={{ iTime }}>`. The whole effect then costs one setProperty per
130
+ frame - the iTime write - regardless of visual complexity. Shader output
131
+ must be premultiplied alpha (white flakes are `vec4(vec3(a), a)`);
132
+ straight alpha (`vec4(1,1,1,a)`) composites as opaque white.
133
+ 2. Reduce setProperty calls wherever possible: one path string rebuilt per
134
+ frame beats N elements with N animated positions; a shader beats the path
135
+ string. get_stats' setPropsPerFrame is the counter to watch.
136
+ 3. Never leave onFrame registered while nothing animates: a pending onFrame
137
+ is a standing frame request, so the runtime renders and presents every
138
+ vsync even when the callback body does nothing - an invisible 60fps GPU
139
+ burn that also drags the OS compositor along with it. For an on-demand
140
+ animation pump (tweens), use a self-rechaining one-shot
141
+ requestAnimationFrame that stops re-requesting when its work list
142
+ empties. (Registering onFrame outside a component body also warns
143
+ NO_OWNER_CLEANUP - it assumes a reactive owner.)
144
+ 4. repaintBoundary works like Flutter's: transforms and opacity on the
145
+ boundary node itself (or any ancestor) are hoisted out of the cache and
146
+ applied at composite time, so animating x/y/scale/rotate/opacity of a
147
+ boundary does NOT re-raster it (verified by A/B measurement - the damage
148
+ system classifies these as Transform and keeps the node's own cache).
149
+ What DOES invalidate the cache is any paint or content change inside the
150
+ subtree - colors, path data, text, a Show toggling - so drive animation
151
+ with transforms and keep the cached content itself static.
152
+ 5. "snapshot" boundaries pay first-frame texture allocation + raster:
153
+ creating many at once (dealing a board of 64 sprites) is a visible
154
+ one-frame hiccup - pool or pre-warm if that moment matters.
112
155
 
113
156
  ## Run / verify
114
157
 
@@ -161,10 +204,38 @@ its tools over guessing at runtime state:
161
204
  - reload: rebuild from source and push to every client - THE dev loop is
162
205
  edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
163
206
  but not type errors; run `bunx srt check` for those.
207
+ - load: bundle a given source file and push it to every client, replacing
208
+ the running app; later reloads rebuild that entry. Use it when the dev
209
+ server has no app loaded yet, or to switch apps without restarting srt.
210
+ - watch: pause (enabled: false) or resume the automatic reload-on-save.
211
+ Pause BEFORE creating or editing source files so half-finished work is
212
+ not pushed to the user's screens mid-burst; a successful reload or load
213
+ resumes it, so pause again before the next burst. Never leave it paused
214
+ when you stop working - the user's own saves rely on it.
164
215
 
165
216
  The tools need a running app: if list_clients is empty, ask the user to start
166
217
  `bunx srt run src/index.tsx`.
167
218
 
219
+ - Permission prompts: agents typically ask approval per MCP tool. All of
220
+ these tools only talk to the local dev server the user started with
221
+ `bunx srt run` - nothing leaves the machine - so approving the server as
222
+ a whole is a reasonable default. If repeated prompts get in the way, do
223
+ not work around them; tell the user they can pre-approve the server in
224
+ their agent's settings (most agents have a per-server trust or allowlist
225
+ setting - in Claude Code, add "mcp__solidrt" to `permissions.allow` in
226
+ ~/.claude/settings.json to cover every solidrt project). This is the
227
+ user's call to make, once, in their own tooling.
228
+ - Multiple clients: several clients may be attached (desktop window,
229
+ phone, tablet) with different sizes, display scales, and safe areas.
230
+ reload pushes to all of them, but call_debug / get_snapshot / log
231
+ cursors are per client, and interactive state does NOT sync - a flow
232
+ driven on one client leaves the others sitting on the initial screen,
233
+ which reads as a crash to a human holding that device. So: when driving
234
+ state via call_debug, send the same call to every client (or say which
235
+ client you are using); and before calling a visual change done,
236
+ snapshot each distinct form factor at least once - a layout that fits
237
+ one window can clip or overflow another.
238
+
168
239
  ## Debugging a running app (lessons that cost real time)
169
240
 
170
241
  - console.log + get_logs is your primary probe into runtime state. For state
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.28",
13
- "@solidrt/components": "0.0.28"
12
+ "@solidrt/core": "0.0.30",
13
+ "@solidrt/components": "0.0.30"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.28",
17
- "@solidrt/flux-types": "0.0.28",
16
+ "@solidrt/cli": "0.0.30",
17
+ "@solidrt/flux-types": "0.0.30",
18
18
  "typescript": "^7"
19
19
  }
20
20
  }
package/server/control.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { file } from "flux:fs"
1
2
  import { state } from "./state"
2
3
  import { rebuildAndBroadcast } from "./rebuild"
3
4
  import { remapPositions } from "./remap"
@@ -240,8 +241,45 @@ export async function handleControl(req: Request, path: string, query: Map<strin
240
241
  if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
241
242
  let error = await rebuildAndBroadcast()
242
243
  if (error) return Response.json({ error }, { status: 502 })
244
+ state.watch = true
243
245
  return Response.json({ ok: true, clients: state.clients.size })
244
246
  }
247
+ case "/__control__/load": {
248
+ // Load (or switch) the app entry and push it: srt mcp's load tool.
249
+ // Moves the rebuild entry and the file-serving root like the repl's
250
+ // `load` command, then reuses the reload path, so a later /reload
251
+ // rebuilds the newly loaded file. The srt process is not told: a
252
+ // watcher started on the launch-time source keeps watching that file.
253
+ if (req.method !== "POST") return Response.json({ error: "Load requires POST" }, { status: 405 })
254
+ let entry = (await req.json().catch(() => null))?.entry
255
+ if (typeof entry !== "string" || !entry) {
256
+ return Response.json({ error: "Load requires { entry: <absolute source path> }" }, { status: 400 })
257
+ }
258
+ if (!(await file(entry).exists())) {
259
+ return Response.json({ error: `Entry not found: ${entry}` }, { status: 400 })
260
+ }
261
+ state.config.entry = entry
262
+ let cut = Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\"))
263
+ if (cut > 0) state.sourceDir = entry.slice(0, cut)
264
+ let error = await rebuildAndBroadcast()
265
+ if (error) return Response.json({ error }, { status: 502 })
266
+ state.watch = true
267
+ return Response.json({ ok: true, entry, clients: state.clients.size })
268
+ }
269
+ case "/__control__/watch": {
270
+ // Pause/resume srt's auto-reload-on-save: the MCP watch tool. Latched
271
+ // here because the watcher lives in the srt process; it reads the flag
272
+ // via /__internal__/watch before acting on a change event. An agent
273
+ // pauses while creating or editing files so half-finished work is not
274
+ // pushed; a successful /reload or /load turns it back on.
275
+ if (req.method !== "POST") return Response.json({ error: "Watch requires POST" }, { status: 405 })
276
+ let enabled = (await req.json().catch(() => null))?.enabled
277
+ if (typeof enabled !== "boolean") {
278
+ return Response.json({ error: "Watch requires { enabled: <boolean> }" }, { status: 400 })
279
+ }
280
+ state.watch = enabled
281
+ return Response.json({ ok: true, enabled })
282
+ }
245
283
  default:
246
284
  return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
247
285
  }
package/server/main.ts CHANGED
@@ -69,6 +69,7 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
69
69
  if (!loopback) return new Response("Forbidden", { status: 403 })
70
70
 
71
71
  if (path === "/__internal__/clients") return Response.json(clientList(true))
72
+ if (path === "/__internal__/watch" && req.method === "GET") return Response.json({ enabled: state.watch })
72
73
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
73
74
 
74
75
  switch (path) {
@@ -101,6 +102,12 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
101
102
  sendTo(body.clients, JSON.stringify({ type: "stop" }))
102
103
  return new Response("", { status: 204 })
103
104
  }
105
+ case "/__internal__/watch": {
106
+ // The repl's `watch on|off`; agents use /__control__/watch instead.
107
+ let body = await req.json()
108
+ state.watch = !!body.enabled
109
+ return new Response("", { status: 204 })
110
+ }
104
111
  case "/__internal__/stats": {
105
112
  let body = await req.json()
106
113
  state.stats = !!body.stats
package/server/state.ts CHANGED
@@ -59,6 +59,13 @@ export let state = {
59
59
  sourceDir: "",
60
60
  serverUrl: "",
61
61
  stats: false,
62
+ /**
63
+ * Whether srt's file watcher may auto-reload on source changes. Agents
64
+ * pause it (MCP watch tool -> /__control__/watch) while creating or
65
+ * editing files; a successful /reload or /load re-enables it. srt reads
66
+ * it via /__internal__/watch before acting on a change event.
67
+ */
68
+ watch: true,
62
69
  // Capture events from all connected clients share one clock (captureStartMs,
63
70
  // integer milliseconds) so they merge into one coherent timeline, tagged by
64
71
  // `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
package/src/artifacts.ts CHANGED
@@ -7,12 +7,14 @@ let require = createRequire(import.meta.url)
7
7
 
8
8
  let TRIPLE_MAP: Record<string, string> = {
9
9
  "linux-x64": "linux-x64-gnu",
10
+ "linux-arm64": "linux-arm64-gnu",
10
11
  "darwin-arm64": "darwin-arm64",
11
12
  "win32-x64": "win32-x64-msvc",
12
13
  }
13
14
 
14
15
  let PKG_MAP: Record<string, string> = {
15
16
  "linux-x64": "@solidrt/linux-x64-gnu",
17
+ "linux-arm64": "@solidrt/linux-arm64-gnu",
16
18
  "darwin-arm64": "@solidrt/darwin-arm64",
17
19
  "win32-x64": "@solidrt/win32-x64-msvc",
18
20
  }
package/src/bundler.ts CHANGED
@@ -103,8 +103,10 @@ export type BundleResult = {
103
103
  // spawns. It never touches the ambient args/state singletons and never prints
104
104
  // progress (callers own that), so its stdout stays clean for subprocess use.
105
105
  export async function bundleWith(opts: BundleOptions): Promise<BundleResult | null> {
106
+ // Define values are parsed as expressions, so string values need embedded
107
+ // quotes - a bare word substitutes as an identifier and crashes at runtime.
106
108
  let define: Record<string, string> = {
107
- "process.env.NODE_ENV": opts.dev ? "development" : "production",
109
+ "process.env.NODE_ENV": opts.dev ? '"development"' : '"production"',
108
110
  }
109
111
  if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
110
112
 
@@ -52,15 +52,30 @@ let SAVE_TO_ARG = z
52
52
  )
53
53
  .optional()
54
54
 
55
- let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
55
+ // readOnly marks tools that only inspect state; it is surfaced as the
56
+ // MCP-standard readOnlyHint annotation so agent harnesses that honor it can
57
+ // auto-approve the inspection majority. load, reload, and call_debug mutate
58
+ // the running app and keep the default hints (destructive, not idempotent);
59
+ // `annotations` overrides those defaults where a mutating tool is benign
60
+ // (watch: a reversible, idempotent toggle). Every tool gets
61
+ // openWorldHint: false - the bridge only ever talks to the local dev server.
62
+ let TOOLS: {
63
+ name: string
64
+ description: string
65
+ inputSchema: Record<string, z.ZodTypeAny>
66
+ readOnly?: boolean
67
+ annotations?: { destructiveHint?: boolean; idempotentHint?: boolean }
68
+ }[] = [
56
69
  {
57
70
  name: "list_clients",
71
+ readOnly: true,
58
72
  description:
59
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.",
60
74
  inputSchema: {},
61
75
  },
62
76
  {
63
77
  name: "get_logs",
78
+ readOnly: true,
64
79
  description:
65
80
  "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text; consecutive identical entries are collapsed into one with a `repeats` count and the run's last seq), plus `latest` (the newest seq) and `generation` (identity of this server run; if it changed since your last call, your seq cursor and client ids are stale - start over from since 0). Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload; pass `level`/`contains` to filter, e.g. level \"error\" to skip chatty output.",
66
81
  inputSchema: {
@@ -86,12 +101,14 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
86
101
  },
87
102
  {
88
103
  name: "get_stats",
104
+ readOnly: true,
89
105
  description:
90
- "Performance statistics from a running app client: 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), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), 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).",
106
+ "Performance statistics from a running app client: 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; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), 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).",
91
107
  inputSchema: { client: CLIENT_ARG },
92
108
  },
93
109
  {
94
110
  name: "get_render_tree",
111
+ readOnly: true,
95
112
  description:
96
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>.",
97
114
  inputSchema: {
@@ -118,6 +135,7 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
118
135
  },
119
136
  {
120
137
  name: "get_snapshot",
138
+ readOnly: true,
121
139
  description:
122
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. The node must be currently mounted and have a non-zero layout box. 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.",
123
141
  inputSchema: {
@@ -128,12 +146,14 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
128
146
  },
129
147
  {
130
148
  name: "get_gpu_resources",
149
+ readOnly: true,
131
150
  description:
132
151
  "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, depth, attribute layout, bound sampler texture ids, last-applied uniform values). 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.",
133
152
  inputSchema: { client: CLIENT_ARG },
134
153
  },
135
154
  {
136
155
  name: "get_texture",
156
+ readOnly: true,
137
157
  description:
138
158
  "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/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
139
159
  inputSchema: {
@@ -148,6 +168,7 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
148
168
  },
149
169
  {
150
170
  name: "get_buffer",
171
+ readOnly: true,
151
172
  description:
152
173
  "Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
153
174
  inputSchema: {
@@ -160,6 +181,7 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
160
181
  },
161
182
  {
162
183
  name: "list_debug",
184
+ readOnly: true,
163
185
  description:
164
186
  "List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
165
187
  inputSchema: { client: CLIENT_ARG },
@@ -177,9 +199,26 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
177
199
  {
178
200
  name: "reload",
179
201
  description:
180
- "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
202
+ "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. A successful reload re-enables the file watcher if you paused it with the watch tool. Follow with get_logs to see runtime output from the reloaded app.",
181
203
  inputSchema: {},
182
204
  },
205
+ {
206
+ name: "load",
207
+ description:
208
+ "Load an app entry: bundle the given .tsx/.jsx source file and push it to every connected client, replacing whatever is running. Use it when the dev server has no app loaded yet, or to switch to a different app; later reload calls rebuild this entry. Returns the number of clients loaded, or a build error if the source failed to compile. A successful load re-enables the file watcher if you paused it with the watch tool.",
209
+ inputSchema: {
210
+ entry: z.string().describe("App entry source file to load (relative paths resolve against the project root)"),
211
+ },
212
+ },
213
+ {
214
+ name: "watch",
215
+ annotations: { destructiveHint: false, idempotentHint: true },
216
+ description:
217
+ "Pause or resume the dev server's automatic reload-on-save. The srt file watcher pushes a rebuild whenever app source changes on disk; call watch with enabled: false BEFORE creating or editing source files so your half-finished work is not pushed to the user's screens mid-burst, then apply everything with one explicit reload (a successful reload or load re-enables the watcher, so pause again before the next burst of file changes). The human's own saves auto-reload only while the watcher is enabled, so do not leave it paused when you stop working.",
218
+ inputSchema: {
219
+ enabled: z.boolean().describe("false pauses auto-reload-on-save, true resumes it"),
220
+ },
221
+ },
183
222
  ]
184
223
 
185
224
  function clientParam(args: any): string {
@@ -212,6 +251,16 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
212
251
  }
213
252
  case "reload":
214
253
  return control("/reload", "POST")
254
+ case "load": {
255
+ if (typeof args?.entry !== "string" || !args.entry) return { ok: false, message: "load requires an entry path" }
256
+ // Resolved here in the bridge: this process runs at the project root,
257
+ // the dev server may not.
258
+ return control("/load", "POST", { entry: resolve(args.entry) })
259
+ }
260
+ case "watch": {
261
+ if (typeof args?.enabled !== "boolean") return { ok: false, message: "watch requires enabled: true or false" }
262
+ return control("/watch", "POST", { enabled: args.enabled })
263
+ }
215
264
  case "get_snapshot": {
216
265
  if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
217
266
  let params = new URLSearchParams({ node: String(args.nodeId) })
@@ -286,7 +335,11 @@ export async function runMcpCommand() {
286
335
  for (let tool of TOOLS) {
287
336
  server.registerTool(
288
337
  tool.name,
289
- { description: tool.description, inputSchema: tool.inputSchema },
338
+ {
339
+ description: tool.description,
340
+ inputSchema: tool.inputSchema,
341
+ annotations: { readOnlyHint: !!tool.readOnly, openWorldHint: false, ...tool.annotations },
342
+ },
290
343
  async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
291
344
  )
292
345
  }
package/src/dev-server.ts CHANGED
@@ -51,6 +51,26 @@ export async function sendStats(stats: boolean) {
51
51
  await post("/stats", { stats })
52
52
  }
53
53
 
54
+ /** Latch the auto-reload flag on the server (repl `watch on|off`). */
55
+ export async function sendWatch(enabled: boolean) {
56
+ await post("/watch", { enabled })
57
+ }
58
+
59
+ /**
60
+ * Whether the watcher may auto-reload: agents pause it via the MCP watch
61
+ * tool, latched on the server. Fails open so an unreachable server surfaces
62
+ * as a reload error, not a silently ignored change.
63
+ */
64
+ export async function watchAllowed(): Promise<boolean> {
65
+ try {
66
+ let resp = await fetch(`${INTERNAL_BASE}/watch`)
67
+ if (!resp.ok) return true
68
+ return (await resp.json()).enabled !== false
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
54
74
  export type ClientEntry = {
55
75
  id: number
56
76
  platform: string
package/src/repl.ts CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline"
2
2
  import { resolve, dirname } from "path"
3
3
  import { readdirSync } from "node:fs"
4
4
  import { state, print, printErr, shutdown } from "./util"
5
- import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
5
+ import { buildReload, getClients, sendReload, sendStop, sendStats, sendWatch, showBuildFailure } from "./dev-server"
6
6
  import { bundle } from "./bundler"
7
7
  import { startWatcher, stopWatcher } from "./watcher"
8
8
 
@@ -133,7 +133,7 @@ async function cmdLoad(file: string) {
133
133
  print(`[cli] Loaded ${file}`)
134
134
  }
135
135
 
136
- let COMMANDS = ["load ", "stop", "reload", "list", "stats", "quit", "exit", "help"]
136
+ let COMMANDS = ["load ", "stop", "reload", "list", "stats", "watch ", "quit", "exit", "help"]
137
137
  let LOAD_EXTENSIONS = [".tsx", ".srt.js", ".srt.bin"]
138
138
 
139
139
  function completer(line: string): [string[], string] {
@@ -186,6 +186,11 @@ export function startRepl() {
186
186
  guard(cmdList())
187
187
  } else if (cmd === "stats" || cmd.startsWith("stats ")) {
188
188
  guard(cmdStats(cmd.slice(6).trim()))
189
+ } else if (cmd === "watch on" || cmd === "watch off") {
190
+ // Manual override for the agent-latched auto-reload pause (an agent
191
+ // that died mid-edit leaves it off; its reload normally restores it).
192
+ let enabled = cmd === "watch on"
193
+ guard(sendWatch(enabled).then(() => print(`[cli] Auto-reload on change ${enabled ? "on" : "off"}`)))
189
194
  } else if (cmd === "quit" || cmd === "exit") {
190
195
  shutdown()
191
196
  } else if (cmd.startsWith("!")) {
@@ -201,7 +206,7 @@ export function startRepl() {
201
206
  )
202
207
  }
203
208
  } else if (cmd === "help") {
204
- print("Commands: load, stop, reload, list, stats, !<cmd>, quit, help")
209
+ print("Commands: load, stop, reload, list, stats, watch on|off, !<cmd>, quit, help")
205
210
  } else if (cmd) {
206
211
  print(`Unknown command: ${cmd}`)
207
212
  }
package/src/watcher.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { watch } from "node:fs"
2
2
  import { resolve, dirname } from "path"
3
3
  import { state, print, printErr } from "./util"
4
- import { buildReload, sendReload, showBuildFailure } from "./dev-server"
4
+ import { buildReload, sendReload, showBuildFailure, watchAllowed } from "./dev-server"
5
5
  import { bundle } from "./bundler"
6
6
 
7
7
  let currentWatcher: ReturnType<typeof watch> | null = null
@@ -24,6 +24,11 @@ export function startWatcher() {
24
24
  if (!filename) return
25
25
  if (!/\.(tsx?|jsx?)$/.test(filename)) return
26
26
 
27
+ if (!(await watchAllowed())) {
28
+ print(`[cli] Change detected: ${filename} (auto-reload paused by agent; "watch on" resumes)`)
29
+ return
30
+ }
31
+
27
32
  print(`[cli] Change detected: ${filename}`)
28
33
  let result = await bundle(state.source)
29
34
  if (!result) {