@solidrt/cli 0.0.28 → 0.0.29
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 +7 -6
- package/scaffold/AGENTS.md +36 -0
- package/scaffold/package.json +4 -4
- package/server/control.ts +38 -0
- package/server/main.ts +7 -0
- package/server/state.ts +7 -0
- package/src/artifacts.ts +2 -0
- package/src/bundler.ts +3 -1
- package/src/commands/mcp.ts +57 -4
- package/src/dev-server.ts +20 -0
- package/src/repl.ts +8 -3
- package/src/watcher.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
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.
|
|
31
|
-
"@solidrt/linux-
|
|
32
|
-
"@solidrt/
|
|
30
|
+
"@solidrt/darwin-arm64": "0.0.29",
|
|
31
|
+
"@solidrt/linux-arm64-gnu": "0.0.29",
|
|
32
|
+
"@solidrt/linux-x64-gnu": "0.0.29",
|
|
33
|
+
"@solidrt/win32-x64-msvc": "0.0.29"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
|
-
"@solidrt/core": "0.0.
|
|
36
|
+
"@solidrt/core": "0.0.29",
|
|
36
37
|
"typescript": "^7"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@solidrt/flux-types": "0.0.
|
|
40
|
+
"@solidrt/flux-types": "0.0.29",
|
|
40
41
|
"@types/bun": "latest"
|
|
41
42
|
}
|
|
42
43
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -109,6 +109,14 @@ 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.
|
|
112
120
|
|
|
113
121
|
## Run / verify
|
|
114
122
|
|
|
@@ -161,10 +169,38 @@ its tools over guessing at runtime state:
|
|
|
161
169
|
- reload: rebuild from source and push to every client - THE dev loop is
|
|
162
170
|
edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
|
|
163
171
|
but not type errors; run `bunx srt check` for those.
|
|
172
|
+
- load: bundle a given source file and push it to every client, replacing
|
|
173
|
+
the running app; later reloads rebuild that entry. Use it when the dev
|
|
174
|
+
server has no app loaded yet, or to switch apps without restarting srt.
|
|
175
|
+
- watch: pause (enabled: false) or resume the automatic reload-on-save.
|
|
176
|
+
Pause BEFORE creating or editing source files so half-finished work is
|
|
177
|
+
not pushed to the user's screens mid-burst; a successful reload or load
|
|
178
|
+
resumes it, so pause again before the next burst. Never leave it paused
|
|
179
|
+
when you stop working - the user's own saves rely on it.
|
|
164
180
|
|
|
165
181
|
The tools need a running app: if list_clients is empty, ask the user to start
|
|
166
182
|
`bunx srt run src/index.tsx`.
|
|
167
183
|
|
|
184
|
+
- Permission prompts: agents typically ask approval per MCP tool. All of
|
|
185
|
+
these tools only talk to the local dev server the user started with
|
|
186
|
+
`bunx srt run` - nothing leaves the machine - so approving the server as
|
|
187
|
+
a whole is a reasonable default. If repeated prompts get in the way, do
|
|
188
|
+
not work around them; tell the user they can pre-approve the server in
|
|
189
|
+
their agent's settings (most agents have a per-server trust or allowlist
|
|
190
|
+
setting - in Claude Code, add "mcp__solidrt" to `permissions.allow` in
|
|
191
|
+
~/.claude/settings.json to cover every solidrt project). This is the
|
|
192
|
+
user's call to make, once, in their own tooling.
|
|
193
|
+
- Multiple clients: several clients may be attached (desktop window,
|
|
194
|
+
phone, tablet) with different sizes, display scales, and safe areas.
|
|
195
|
+
reload pushes to all of them, but call_debug / get_snapshot / log
|
|
196
|
+
cursors are per client, and interactive state does NOT sync - a flow
|
|
197
|
+
driven on one client leaves the others sitting on the initial screen,
|
|
198
|
+
which reads as a crash to a human holding that device. So: when driving
|
|
199
|
+
state via call_debug, send the same call to every client (or say which
|
|
200
|
+
client you are using); and before calling a visual change done,
|
|
201
|
+
snapshot each distinct form factor at least once - a layout that fits
|
|
202
|
+
one window can clip or overflow another.
|
|
203
|
+
|
|
168
204
|
## Debugging a running app (lessons that cost real time)
|
|
169
205
|
|
|
170
206
|
- console.log + get_logs is your primary probe into runtime state. For state
|
package/scaffold/package.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"android": "srt client --android"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@solidrt/core": "0.0.
|
|
13
|
-
"@solidrt/components": "0.0.
|
|
12
|
+
"@solidrt/core": "0.0.29",
|
|
13
|
+
"@solidrt/components": "0.0.29"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
16
|
+
"@solidrt/cli": "0.0.29",
|
|
17
|
+
"@solidrt/flux-types": "0.0.29",
|
|
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
|
|
package/src/commands/mcp.ts
CHANGED
|
@@ -52,15 +52,30 @@ let SAVE_TO_ARG = z
|
|
|
52
52
|
)
|
|
53
53
|
.optional()
|
|
54
54
|
|
|
55
|
-
|
|
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
|
-
{
|
|
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) {
|