@princeofscale/bloxforge 2.20.2

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.
Files changed (49) hide show
  1. package/assets/Baseplate.rbxl +0 -0
  2. package/dist/index.js +19391 -0
  3. package/package.json +62 -0
  4. package/studio-plugin/INSTALLATION.md +161 -0
  5. package/studio-plugin/MCPInspectorPlugin.rbxmx +171699 -0
  6. package/studio-plugin/MCPPlugin.rbxmx +171699 -0
  7. package/studio-plugin/default.project.json +19 -0
  8. package/studio-plugin/dev.project.json +23 -0
  9. package/studio-plugin/include/LibMP.lua +156378 -0
  10. package/studio-plugin/inspector-icon.png +0 -0
  11. package/studio-plugin/package-lock.json +692 -0
  12. package/studio-plugin/package.json +19 -0
  13. package/studio-plugin/plugin.json +10 -0
  14. package/studio-plugin/src/modules/ClientBroker.ts +447 -0
  15. package/studio-plugin/src/modules/Communication.ts +697 -0
  16. package/studio-plugin/src/modules/EvalBridges.ts +255 -0
  17. package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
  18. package/studio-plugin/src/modules/JobRegistry.ts +77 -0
  19. package/studio-plugin/src/modules/LuauExec.ts +465 -0
  20. package/studio-plugin/src/modules/Recording.ts +28 -0
  21. package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
  22. package/studio-plugin/src/modules/RuntimeLogBuffer.ts +152 -0
  23. package/studio-plugin/src/modules/ServerUrlSettings.ts +114 -0
  24. package/studio-plugin/src/modules/State.ts +101 -0
  25. package/studio-plugin/src/modules/StopPlayMonitor.ts +276 -0
  26. package/studio-plugin/src/modules/UI.ts +790 -0
  27. package/studio-plugin/src/modules/Utils.ts +318 -0
  28. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
  29. package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
  30. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
  31. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +203 -0
  32. package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
  33. package/studio-plugin/src/modules/handlers/InputHandlers.ts +160 -0
  34. package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
  35. package/studio-plugin/src/modules/handlers/JobHandlers.ts +111 -0
  36. package/studio-plugin/src/modules/handlers/LogHandlers.ts +14 -0
  37. package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
  38. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +354 -0
  39. package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +1263 -0
  40. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
  41. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +809 -0
  42. package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
  43. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
  44. package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
  45. package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
  46. package/studio-plugin/src/modules/handlers/TestHandlers.ts +535 -0
  47. package/studio-plugin/src/server/index.server.ts +134 -0
  48. package/studio-plugin/src/types/index.d.ts +83 -0
  49. package/studio-plugin/tsconfig.json +21 -0
@@ -0,0 +1,697 @@
1
+ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
2
+ import State from "./State";
3
+ import Utils from "./Utils";
4
+ import UI from "./UI";
5
+ import { cleanupLegacyEditBridges } from "./EvalBridges";
6
+ import QueryHandlers from "./handlers/QueryHandlers";
7
+ import PropertyHandlers from "./handlers/PropertyHandlers";
8
+ import InstanceHandlers from "./handlers/InstanceHandlers";
9
+ import ScriptHandlers from "./handlers/ScriptHandlers";
10
+ import MetadataHandlers from "./handlers/MetadataHandlers";
11
+ import TestHandlers from "./handlers/TestHandlers";
12
+ import BuildHandlers from "./handlers/BuildHandlers";
13
+ import AssetHandlers from "./handlers/AssetHandlers";
14
+ import CaptureHandlers from "./handlers/CaptureHandlers";
15
+ import InputHandlers from "./handlers/InputHandlers";
16
+ import LogHandlers from "./handlers/LogHandlers";
17
+ import SerializationHandlers from "./handlers/SerializationHandlers";
18
+ import MemoryHandlers from "./handlers/MemoryHandlers";
19
+ import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
20
+ import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
21
+ import BreakpointHandlers from "./handlers/BreakpointHandlers";
22
+ import ScriptProfilerHandlers from "./handlers/ScriptProfilerHandlers";
23
+ import MicroProfilerHandlers from "./handlers/MicroProfilerHandlers";
24
+ import JobHandlers from "./handlers/JobHandlers";
25
+ import ClientBroker from "./ClientBroker";
26
+ import ServerUrlSettings from "./ServerUrlSettings";
27
+ import HttpDiagnostics from "./HttpDiagnostics";
28
+ import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
29
+
30
+ // Per-plugin-load random GUID. Used as the /poll URL param so the server
31
+ // can tell our polls apart from any other plugin's polls. Not user-facing —
32
+ // MCP tools and the LLM operate on instanceId (the place identifier).
33
+ const pluginSessionId = HttpService.GenerateGUID(false);
34
+
35
+ // Place-level identifier shared by every plugin running in DataModels of
36
+ // the same place file (edit DM + playtest server DM + playtest clients).
37
+ // Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
38
+ // places where the UUID lives on ServerStorage's __MCPPlaceId attribute
39
+ // and travels with the .rbxl.
40
+ const MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId";
41
+
42
+ function computeInstanceId(): string {
43
+ if (game.PlaceId !== 0) {
44
+ return `place:${tostring(game.PlaceId)}`;
45
+ }
46
+ const existing = ServerStorage.GetAttribute(MCP_PLACE_ID_ATTRIBUTE);
47
+ if (typeIs(existing, "string") && existing !== "") {
48
+ return `anon:${existing as string}`;
49
+ }
50
+ const fresh = HttpService.GenerateGUID(false);
51
+ pcall(() => ServerStorage.SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh));
52
+ return `anon:${fresh}`;
53
+ }
54
+
55
+ let assignedRole: string | undefined;
56
+ let duplicateInstanceRole = false;
57
+ let hasVersionMismatch = false;
58
+ let lastVersionMismatchWarningKey: string | undefined;
59
+ let lastReadyInstanceId: string | undefined;
60
+ const readyFailureLogKeys = new Set<string>();
61
+
62
+ // Cache the published place name from MarketplaceService:GetProductInfo so
63
+ // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
64
+ // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
65
+ // once per plugin load; the published name doesn't change mid-session.
66
+ let cachedPlaceName: string | undefined;
67
+ let cachedPlaceNamePlaceId: number | undefined;
68
+
69
+ function resolvePlaceName(): string {
70
+ if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
71
+ cachedPlaceName = undefined;
72
+ cachedPlaceNamePlaceId = game.PlaceId;
73
+ if (game.PlaceId === 0) {
74
+ cachedPlaceName = game.Name;
75
+ return cachedPlaceName;
76
+ }
77
+ const MarketplaceService = game.GetService("MarketplaceService");
78
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
79
+ if (ok && info !== undefined) {
80
+ const name = (info as { Name?: string }).Name;
81
+ if (typeIs(name, "string") && name !== "") {
82
+ cachedPlaceName = name;
83
+ return cachedPlaceName;
84
+ }
85
+ }
86
+ // Don't cache failures — could be transient (offline, rate-limited).
87
+ // Next /ready will retry. Return game.Name as fallback.
88
+ return game.Name;
89
+ }
90
+
91
+ function detectRole(): string {
92
+ if (!RunService.IsRunning()) return "edit";
93
+ if (RunService.IsServer()) return "server";
94
+ return "client";
95
+ }
96
+
97
+ const initialRole = detectRole();
98
+
99
+ type Handler = (data: Record<string, unknown>) => unknown;
100
+
101
+ const routeMap: Record<string, Handler> = {
102
+
103
+ "/api/file-tree": QueryHandlers.getFileTree,
104
+ "/api/search-files": QueryHandlers.searchFiles,
105
+ "/api/place-info": QueryHandlers.getPlaceInfo,
106
+ "/api/services": QueryHandlers.getServices,
107
+ "/api/search-objects": QueryHandlers.searchObjects,
108
+ "/api/instance-properties": QueryHandlers.getInstanceProperties,
109
+ "/api/instance-children": QueryHandlers.getInstanceChildren,
110
+ "/api/search-by-property": QueryHandlers.searchByProperty,
111
+ "/api/class-info": QueryHandlers.getClassInfo,
112
+ "/api/project-structure": QueryHandlers.getProjectStructure,
113
+ "/api/grep-scripts": QueryHandlers.grepScripts,
114
+ "/api/get-descendants": QueryHandlers.getDescendants,
115
+ "/api/compare-instances": QueryHandlers.compareInstances,
116
+
117
+ "/api/set-property": PropertyHandlers.setProperty,
118
+ "/api/set-properties": PropertyHandlers.setProperties,
119
+ "/api/mass-set-property": PropertyHandlers.massSetProperty,
120
+ "/api/mass-get-property": PropertyHandlers.massGetProperty,
121
+ "/api/create-object": InstanceHandlers.createObject,
122
+ "/api/mass-create-objects": InstanceHandlers.massCreateObjects,
123
+ // Back-compat alias: pre-2.7.0 servers split this endpoint when properties were present.
124
+ "/api/mass-create-objects-with-properties": InstanceHandlers.massCreateObjects,
125
+ "/api/delete-object": InstanceHandlers.deleteObject,
126
+ "/api/smart-duplicate": InstanceHandlers.smartDuplicate,
127
+ "/api/mass-duplicate": InstanceHandlers.massDuplicate,
128
+ "/api/clone-object": InstanceHandlers.cloneObject,
129
+
130
+ "/api/get-script-source": ScriptHandlers.getScriptSource,
131
+ "/api/set-script-source": ScriptHandlers.setScriptSource,
132
+ "/api/edit-script-lines": ScriptHandlers.editScriptLines,
133
+ "/api/insert-script-lines": ScriptHandlers.insertScriptLines,
134
+ "/api/delete-script-lines": ScriptHandlers.deleteScriptLines,
135
+
136
+ "/api/set-attribute": MetadataHandlers.setAttribute,
137
+ "/api/get-attributes": MetadataHandlers.getAttributes,
138
+ "/api/delete-attribute": MetadataHandlers.deleteAttribute,
139
+ "/api/get-tags": MetadataHandlers.getTags,
140
+ "/api/add-tag": MetadataHandlers.addTag,
141
+ "/api/remove-tag": MetadataHandlers.removeTag,
142
+ "/api/get-tagged": MetadataHandlers.getTagged,
143
+ "/api/get-selection": MetadataHandlers.getSelection,
144
+ "/api/execute-luau": MetadataHandlers.executeLuau,
145
+ "/api/execute-luau-async": JobHandlers.executeLuauAsync,
146
+ "/api/get-job-status": JobHandlers.getJobStatus,
147
+ "/api/get-job-result": JobHandlers.getJobResult,
148
+ "/api/cancel-job": JobHandlers.cancelJob,
149
+ "/api/eval-runtime": EvalRuntimeHandlers.evalRuntime,
150
+ "/api/undo": MetadataHandlers.undo,
151
+ "/api/redo": MetadataHandlers.redo,
152
+ "/api/bulk-set-attributes": MetadataHandlers.bulkSetAttributes,
153
+
154
+ "/api/start-playtest": TestHandlers.startPlaytest,
155
+ "/api/stop-playtest": TestHandlers.stopPlaytest,
156
+ "/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
157
+ "/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
158
+ "/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
159
+ "/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
160
+ "/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
161
+ "/api/character-navigation": TestHandlers.characterNavigation,
162
+
163
+ "/api/export-build": BuildHandlers.exportBuild,
164
+ "/api/import-build": BuildHandlers.importBuild,
165
+ "/api/import-scene": BuildHandlers.importScene,
166
+ "/api/search-materials": BuildHandlers.searchMaterials,
167
+
168
+ "/api/insert-asset": AssetHandlers.insertAsset,
169
+ "/api/preview-asset": AssetHandlers.previewAsset,
170
+
171
+ "/api/capture-screenshot": CaptureHandlers.captureScreenshot,
172
+ "/api/capture-begin": CaptureHandlers.captureBegin,
173
+ "/api/capture-read": CaptureHandlers.captureRead,
174
+ "/api/simulate-mouse-input": InputHandlers.simulateMouseInput,
175
+ "/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
176
+
177
+ "/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
178
+
179
+ "/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
180
+ "/api/breakpoints": BreakpointHandlers.breakpoints,
181
+ "/api/capture-script-profiler": ScriptProfilerHandlers.captureScriptProfiler,
182
+ "/api/capture-micro-profiler": MicroProfilerHandlers.captureMicroProfiler,
183
+
184
+ "/api/export-rbxm": SerializationHandlers.exportRbxm,
185
+ "/api/import-rbxm": SerializationHandlers.importRbxm,
186
+
187
+ "/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
188
+ "/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
189
+ };
190
+
191
+ function processRequest(request: RequestPayload): unknown {
192
+ const endpoint = request.endpoint;
193
+ const data = request.data ?? {};
194
+
195
+ const handler = routeMap[endpoint];
196
+ if (handler) {
197
+ return handler(data as Record<string, unknown>);
198
+ } else {
199
+ return { error: `Unknown endpoint: ${endpoint}` };
200
+ }
201
+ }
202
+
203
+ function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
204
+ pcall(() => {
205
+ HttpService.RequestAsync({
206
+ Url: `${conn.serverUrl}/response`,
207
+ Method: "POST",
208
+ Headers: { "Content-Type": "application/json" },
209
+ Body: HttpService.JSONEncode({ requestId, response: responseData }),
210
+ });
211
+ });
212
+ }
213
+
214
+ function getConnectionStatus(connIndex: number): string {
215
+ const conn = State.getConnection(connIndex);
216
+ if (!conn || !conn.isActive) return "disconnected";
217
+ if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) return "error";
218
+ if (conn.lastHttpOk) return "connected";
219
+ return "connecting";
220
+ }
221
+
222
+ // Throttle for re-issuing /ready after the server reports knownInstance=false.
223
+ // Without this, every poll during the brief window where the server has just
224
+ // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
225
+ let lastReadyPostAt = 0;
226
+
227
+ // game.Name and game.PlaceId can both settle after plugin load. PlaceId also
228
+ // changes when an unpublished file is published while MCP is already active.
229
+ // Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
230
+ let nameChangeConn: RBXScriptConnection | undefined;
231
+ let placeIdChangeConn: RBXScriptConnection | undefined;
232
+ function ensureIdentityWatcher(conn: Connection): void {
233
+ if (!nameChangeConn) {
234
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
235
+ if (okSig && signal) {
236
+ nameChangeConn = signal.Connect(() => {
237
+ // sendReady has its own 2s throttle, so rapid burst changes coalesce.
238
+ sendReady(conn);
239
+ });
240
+ }
241
+ }
242
+ if (!placeIdChangeConn) {
243
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
244
+ if (okSig && signal) {
245
+ placeIdChangeConn = signal.Connect(() => {
246
+ cachedPlaceName = undefined;
247
+ cachedPlaceNamePlaceId = undefined;
248
+ sendReady(conn);
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ function sendReady(conn: Connection): void {
255
+ if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
256
+ const now = tick();
257
+ if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
258
+ lastReadyPostAt = now;
259
+ const instanceId = computeInstanceId();
260
+ task.spawn(() => {
261
+ const [readyOk, readyResult] = pcall(() => {
262
+ return HttpService.RequestAsync({
263
+ Url: `${conn.serverUrl}/ready`,
264
+ Method: "POST",
265
+ Headers: { "Content-Type": "application/json" },
266
+ Body: HttpService.JSONEncode({
267
+ pluginSessionId,
268
+ instanceId,
269
+ role: detectRole(),
270
+ placeId: game.PlaceId,
271
+ placeName: resolvePlaceName(),
272
+ dataModelName: game.Name,
273
+ isRunning: RunService.IsRunning(),
274
+ pluginVersion: State.CURRENT_VERSION,
275
+ pluginVariant: State.PLUGIN_VARIANT,
276
+ protocolVersion: State.PROTOCOL_VERSION,
277
+ pluginReady: true,
278
+ timestamp: tick(),
279
+ }),
280
+ });
281
+ });
282
+ const readyUrl = `${conn.serverUrl}/ready`;
283
+ const readyRole = detectRole();
284
+ const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
285
+ if (!readyOk) {
286
+ readyFailureLogKeys.add(readyLogKey);
287
+ warn(`[BloxForge] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
288
+ return;
289
+ }
290
+ if (!readyResult.Success) {
291
+ const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
292
+ readyFailureLogKeys.add(readyLogKey);
293
+ // 409 = duplicate_instance_role. Surface in UI and stop polling.
294
+ if (readyResult.StatusCode === 409) {
295
+ duplicateInstanceRole = true;
296
+ conn.isActive = false;
297
+ const ui = UI.getElements();
298
+ if (State.getActiveTabIndex() === 0) {
299
+ ui.statusLabel.Text = "Duplicate instance";
300
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
301
+ ui.detailStatusLabel.Text = reason;
302
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
303
+ }
304
+ warn(`[BloxForge] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
305
+ return;
306
+ }
307
+ warn(`[BloxForge] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
308
+ return;
309
+ }
310
+ const [parseOk, readyData] = pcall(
311
+ () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
312
+ );
313
+ if (parseOk && readyData.assignedRole) {
314
+ assignedRole = readyData.assignedRole;
315
+ }
316
+ lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
317
+ ? readyData.instanceId
318
+ : instanceId;
319
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl);
320
+ const connectedRole = assignedRole ?? detectRole();
321
+ if (readyFailureLogKeys.has(readyLogKey)) {
322
+ readyFailureLogKeys.delete(readyLogKey);
323
+ print(`[BloxForge] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
324
+ }
325
+ });
326
+ }
327
+
328
+ function streamUrl(serverUrl: string): string {
329
+ const [websocketUrl] = string.gsub(serverUrl, "^http", "ws");
330
+ return `${websocketUrl}/stream?pluginSessionId=${pluginSessionId}`;
331
+ }
332
+
333
+ function sendStreamResponse(conn: Connection, requestId: string, response: unknown) {
334
+ if (!conn.streamOpen || !conn.streamClient) return;
335
+ pcall(() => conn.streamClient!.Send(HttpService.JSONEncode({ type: "response", requestId, response })));
336
+ }
337
+
338
+ function startRequestStream(conn: Connection) {
339
+ if (!conn.isActive || conn.streamClient) return;
340
+ const [ok, stream] = pcall(() => HttpService.CreateWebStreamClient(
341
+ Enum.WebStreamClientType.WebSocket,
342
+ { Url: streamUrl(conn.serverUrl), Method: "GET", Headers: { "Content-Type": "application/json" } },
343
+ ));
344
+ if (!ok || !stream) return;
345
+
346
+ conn.streamClient = stream;
347
+ stream.Opened.Connect(() => {
348
+ if (conn.streamClient !== stream) return;
349
+ conn.streamOpen = true;
350
+ conn.consecutiveFailures = 0;
351
+ conn.currentRetryDelay = 0.5;
352
+ });
353
+ stream.MessageReceived.Connect((message) => {
354
+ if (conn.streamClient !== stream) return;
355
+ const [parsed, frame] = pcall(() => HttpService.JSONDecode(message) as { type?: string; requestId?: string; request?: RequestPayload });
356
+ if (!parsed || frame.type !== "request" || !frame.requestId || !frame.request) return;
357
+ task.spawn(() => {
358
+ const [handled, response] = pcall(() => processRequest(frame.request!));
359
+ sendStreamResponse(conn, frame.requestId!, handled ? response : { error: tostring(response) });
360
+ });
361
+ });
362
+ const close = () => {
363
+ if (conn.streamClient !== stream) return;
364
+ conn.streamClient = undefined;
365
+ conn.streamOpen = false;
366
+ conn.nextStreamRetryAt = tick() + 5;
367
+ };
368
+ stream.Closed.Connect(close);
369
+ stream.Error.Connect(close);
370
+ }
371
+
372
+ function pollForRequests(connIndex: number) {
373
+ const conn = State.getConnection(connIndex);
374
+ if (!conn || !conn.isActive) return;
375
+ if (conn.isPolling) return;
376
+
377
+ conn.isPolling = true;
378
+
379
+ const [success, result] = pcall(() => {
380
+ return HttpService.RequestAsync({
381
+ Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
382
+ Method: "GET",
383
+ Headers: { "Content-Type": "application/json" },
384
+ });
385
+ });
386
+
387
+ conn.isPolling = false;
388
+
389
+ const ui = UI.getElements();
390
+ UI.updateTabDot(connIndex);
391
+ if (connIndex === State.getActiveTabIndex()) UI.updateToolbarIcon();
392
+
393
+ if (success && (result.Success || result.StatusCode === 503)) {
394
+ // Recovery acceleration: detect the failing->ok transition. A transient
395
+ // throttling gap (Studio backgrounded/minimized throttles
396
+ // HttpService.RequestAsync) can make the server reap us as stale, so on
397
+ // the first successful poll after failures we re-fire /ready immediately
398
+ // to restore routing instead of waiting for knownInstance=false on a
399
+ // later poll. Throttled by lastReadyPostAt inside sendReady.
400
+ const wasFailing = conn.consecutiveFailures > 0;
401
+ conn.consecutiveFailures = 0;
402
+ conn.currentRetryDelay = 0.5;
403
+ conn.lastSuccessfulConnection = tick();
404
+
405
+ const data = HttpService.JSONDecode(result.Body) as PollResponse;
406
+ const mcpConnected = data.mcpConnected === true;
407
+ conn.lastHttpOk = true;
408
+ conn.lastMcpOk = mcpConnected;
409
+ const serverVersion = data.serverVersion ?? "unknown";
410
+ if (data.versionMismatch === true) {
411
+ hasVersionMismatch = true;
412
+ const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
413
+ if (lastVersionMismatchWarningKey !== warningKey) {
414
+ lastVersionMismatchWarningKey = warningKey;
415
+ warn(`[BloxForge] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @princeofscale/bloxforge@latest --auto-install-plugin and restart Studio.`);
416
+ }
417
+ UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
418
+ } else if (hasVersionMismatch) {
419
+ hasVersionMismatch = false;
420
+ UI.hideBanner("version-mismatch");
421
+ }
422
+
423
+ // Server tells us when its in-memory instances map doesn't have us
424
+ // (e.g. after an MCP process restart, or after it reaped us during a
425
+ // throttling gap). Re-issue /ready immediately so target=server/client-N
426
+ // start routing again. The throttle inside sendReady() prevents
427
+ // duplicate registrations while the server catches up. We also re-fire
428
+ // on the failing->ok transition (wasFailing) even when knownInstance is
429
+ // already true, because the server may still be mid-reap during the
430
+ // poll that returns to health.
431
+ if (data.knownInstance === false || wasFailing) {
432
+ task.spawn(() => sendReady(conn));
433
+ }
434
+
435
+ if (connIndex === State.getActiveTabIndex()) {
436
+ const el = ui;
437
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
438
+ el.step1Label.Text = "HTTP server (OK)";
439
+
440
+ if (mcpConnected && !el.statusLabel.Text.find("Connected")[0]) {
441
+ el.statusLabel.Text = "Connected";
442
+ el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
443
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
444
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
445
+ el.statusText.Text = "ONLINE";
446
+ el.detailStatusLabel.Text = "HTTP: OK MCP: OK";
447
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
448
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
449
+ el.step2Label.Text = "MCP bridge (OK)";
450
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
451
+ el.step3Label.Text = "Commands (OK)";
452
+ conn.mcpWaitStartTime = undefined;
453
+ el.troubleshootLabel.Visible = false;
454
+ UI.stopPulseAnimation();
455
+ } else if (!mcpConnected) {
456
+ el.statusLabel.Text = "Waiting for MCP server";
457
+ el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
458
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
459
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
460
+ el.statusText.Text = "WAITING";
461
+ el.detailStatusLabel.Text = "HTTP: OK MCP: ...";
462
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
463
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
464
+ el.step2Label.Text = "MCP bridge (waiting...)";
465
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
466
+ el.step3Label.Text = "Commands (waiting...)";
467
+ if (conn.mcpWaitStartTime === undefined) {
468
+ conn.mcpWaitStartTime = tick();
469
+ }
470
+ const elapsed = tick() - (conn.mcpWaitStartTime ?? tick());
471
+ el.troubleshootLabel.Visible = elapsed > 8;
472
+ UI.startPulseAnimation();
473
+ }
474
+ }
475
+
476
+ if (data.request && mcpConnected) {
477
+ task.spawn(() => {
478
+ const [ok, response] = pcall(() => processRequest(data.request!));
479
+ if (ok) {
480
+ sendResponse(conn, data.requestId!, response);
481
+ } else {
482
+ sendResponse(conn, data.requestId!, { error: tostring(response) });
483
+ }
484
+ });
485
+ }
486
+ } else if (conn.isActive) {
487
+ conn.consecutiveFailures++;
488
+
489
+ if (conn.consecutiveFailures > 1) {
490
+ conn.currentRetryDelay = math.min(
491
+ conn.currentRetryDelay * conn.retryBackoffMultiplier,
492
+ conn.maxRetryDelay,
493
+ );
494
+ }
495
+
496
+ if (connIndex === State.getActiveTabIndex()) {
497
+ const el = ui;
498
+ if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) {
499
+ el.statusLabel.Text = "Server unavailable";
500
+ el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
501
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
502
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
503
+ el.statusText.Text = "ERROR";
504
+ el.detailStatusLabel.Text = "HTTP: X MCP: X";
505
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
506
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
507
+ el.step1Label.Text = "HTTP server (error)";
508
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
509
+ el.step2Label.Text = "MCP bridge (error)";
510
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
511
+ el.step3Label.Text = "Commands (error)";
512
+ conn.mcpWaitStartTime = undefined;
513
+ el.troubleshootLabel.Visible = false;
514
+ UI.stopPulseAnimation();
515
+ } else if (conn.consecutiveFailures > 5) {
516
+ const waitTime = math.ceil(conn.currentRetryDelay);
517
+ el.statusLabel.Text = `Retrying (${waitTime}s)`;
518
+ el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
519
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
520
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
521
+ el.statusText.Text = "RETRY";
522
+ el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
523
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
524
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
525
+ el.step1Label.Text = "HTTP server (retrying...)";
526
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
527
+ el.step2Label.Text = "MCP bridge (retrying...)";
528
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
529
+ el.step3Label.Text = "Commands (retrying...)";
530
+ conn.mcpWaitStartTime = undefined;
531
+ el.troubleshootLabel.Visible = false;
532
+ UI.startPulseAnimation();
533
+ } else if (conn.consecutiveFailures > 1) {
534
+ el.statusLabel.Text = `Connecting (attempt ${conn.consecutiveFailures})`;
535
+ el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
536
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
537
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
538
+ el.statusText.Text = "CONNECTING";
539
+ el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
540
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
541
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
542
+ el.step1Label.Text = "HTTP server (connecting...)";
543
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
544
+ el.step2Label.Text = "MCP bridge (connecting...)";
545
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
546
+ el.step3Label.Text = "Commands (connecting...)";
547
+ conn.mcpWaitStartTime = undefined;
548
+ el.troubleshootLabel.Visible = false;
549
+ UI.startPulseAnimation();
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+
556
+ function activatePlugin(connIndex?: number) {
557
+ const idx = connIndex ?? State.getActiveTabIndex();
558
+ const conn = State.getConnection(idx);
559
+ if (!conn) return;
560
+
561
+ const ui = UI.getElements();
562
+
563
+ conn.isActive = true;
564
+ conn.consecutiveFailures = 0;
565
+ conn.currentRetryDelay = 0.5;
566
+
567
+ if (idx === State.getActiveTabIndex()) {
568
+ const normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text);
569
+ conn.serverUrl = normalizedUrl !== "" ? normalizedUrl : conn.serverUrl;
570
+ if (conn.serverUrl === "") conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
571
+ ui.urlInput.Text = conn.serverUrl;
572
+ const port = ServerUrlSettings.extractPort(conn.serverUrl);
573
+ if (port !== undefined) conn.port = port;
574
+ UI.updateTabLabel(idx);
575
+ UI.updateUIState();
576
+ }
577
+ UI.updateTabDot(idx);
578
+
579
+ if (!conn.heartbeatConnection) {
580
+ conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
581
+ const now = tick();
582
+ if (initialRole === "server" && !RunService.IsRunning()) {
583
+ ClientBroker.disconnectAllProxies();
584
+ deactivatePlugin(idx);
585
+ return;
586
+ }
587
+ const currentInstanceId = computeInstanceId();
588
+ if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
589
+ cachedPlaceName = undefined;
590
+ cachedPlaceNamePlaceId = undefined;
591
+ sendReady(conn);
592
+ }
593
+ if (!conn.streamClient && now >= conn.nextStreamRetryAt) {
594
+ conn.nextStreamRetryAt = now + 5;
595
+ startRequestStream(conn);
596
+ }
597
+ if (conn.streamOpen && conn.streamClient && now - conn.lastStreamHeartbeat > 15) {
598
+ conn.lastStreamHeartbeat = now;
599
+ pcall(() => conn.streamClient!.Send(HttpService.JSONEncode({ type: "heartbeat" })));
600
+ }
601
+ if (!conn.streamOpen) {
602
+ const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
603
+ if (now - conn.lastPoll > currentInterval) {
604
+ conn.lastPoll = now;
605
+ pollForRequests(idx);
606
+ }
607
+ }
608
+ });
609
+ }
610
+
611
+ // Initial /ready; pollForRequests will also re-fire ready if the server
612
+ // later reports knownInstance=false (process restart, etc).
613
+ sendReady(conn);
614
+
615
+ // Remove legacy edit-mode eval bridge scripts from older plugin builds.
616
+ // Current bridges are created only in running play DataModels.
617
+ if (!RunService.IsRunning()) {
618
+ task.spawn(cleanupLegacyEditBridges);
619
+ }
620
+
621
+ // Watch identity fields so stale name or anon instance ids are refreshed.
622
+ ensureIdentityWatcher(conn);
623
+ }
624
+
625
+ function deactivatePlugin(connIndex?: number) {
626
+ const idx = connIndex ?? State.getActiveTabIndex();
627
+ const conn = State.getConnection(idx);
628
+ if (!conn) return;
629
+
630
+ conn.isActive = false;
631
+ conn.lastMcpOk = false;
632
+
633
+ if (idx === State.getActiveTabIndex()) UI.updateUIState();
634
+ UI.updateTabDot(idx);
635
+
636
+ pcall(() => {
637
+ HttpService.RequestAsync({
638
+ Url: `${conn.serverUrl}/disconnect`,
639
+ Method: "POST",
640
+ Headers: { "Content-Type": "application/json" },
641
+ Body: HttpService.JSONEncode({ pluginSessionId, timestamp: tick() }),
642
+ });
643
+ });
644
+
645
+ if (conn.heartbeatConnection) {
646
+ conn.heartbeatConnection.Disconnect();
647
+ conn.heartbeatConnection = undefined;
648
+ }
649
+ if (conn.streamClient) {
650
+ conn.streamClient.Close();
651
+ conn.streamClient = undefined;
652
+ }
653
+ conn.streamOpen = false;
654
+
655
+ conn.consecutiveFailures = 0;
656
+ conn.currentRetryDelay = 0.5;
657
+ }
658
+
659
+ function deactivateAll() {
660
+ for (let i = 0; i < State.getConnections().size(); i++) {
661
+ if (State.getConnections()[i].isActive) {
662
+ deactivatePlugin(i);
663
+ }
664
+ }
665
+ }
666
+
667
+ function checkForUpdates() {
668
+ task.spawn(() => {
669
+ const [success, result] = pcall(() => {
670
+ return HttpService.RequestAsync({
671
+ Url: "https://registry.npmjs.org/@princeofscale/bloxforge/latest",
672
+ Method: "GET",
673
+ Headers: { Accept: "application/json" },
674
+ });
675
+ });
676
+
677
+ if (success && result.Success) {
678
+ const [ok, data] = pcall(() => HttpService.JSONDecode(result.Body) as { version?: string });
679
+ if (ok && data?.version) {
680
+ const latestVersion = data.version;
681
+ if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
682
+ if (!hasVersionMismatch) {
683
+ UI.showBanner("update", `v${latestVersion} available - github.com/princeofscale/bloxforge`);
684
+ }
685
+ }
686
+ }
687
+ }
688
+ });
689
+ }
690
+
691
+ export = {
692
+ getConnectionStatus,
693
+ activatePlugin,
694
+ deactivatePlugin,
695
+ deactivateAll,
696
+ checkForUpdates,
697
+ };