@chrrxs/robloxstudio-mcp-inspector 3.1.2 → 3.1.4

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/dist/index.js CHANGED
@@ -1945,6 +1945,15 @@ import { toNodeHandler } from "@modelcontextprotocol/node";
1945
1945
 
1946
1946
  // ../core/dist/bridge-service.js
1947
1947
  import { createHash, randomUUID } from "crypto";
1948
+ var MultiplayerGroupInUseError = class extends Error {
1949
+ groupId;
1950
+ code = "multiplayer_group_in_use";
1951
+ constructor(groupId) {
1952
+ super(`Multiplayer Group "${groupId}" still has connected runtime peers and cannot be removed.`);
1953
+ this.groupId = groupId;
1954
+ this.name = "MultiplayerGroupInUseError";
1955
+ }
1956
+ };
1948
1957
  function isExecutionOutcome(value) {
1949
1958
  return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
1950
1959
  }
@@ -2233,6 +2242,11 @@ var BridgeService = class {
2233
2242
  const group = this.multiplayerGroupsById.get(groupId);
2234
2243
  if (!group)
2235
2244
  return void 0;
2245
+ for (const peer of this.peersById.values()) {
2246
+ if (peer.multiplayerGroupId === groupId && isRuntimeRole(peer.role)) {
2247
+ throw new MultiplayerGroupInUseError(groupId);
2248
+ }
2249
+ }
2236
2250
  this.multiplayerGroupsById.delete(groupId);
2237
2251
  for (const peer of this.peersById.values()) {
2238
2252
  if (peer.multiplayerGroupId === groupId)
@@ -2483,6 +2497,12 @@ var BridgeService = class {
2483
2497
  multiplayerGroups: this.getMultiplayerGroups()
2484
2498
  };
2485
2499
  }
2500
+ /** Local topology is authoritative; remote adapters must refresh before fanout. */
2501
+ refreshTopologyForRouting(signal) {
2502
+ if (signal?.aborted)
2503
+ throw new Error("Request aborted before topology resolution");
2504
+ return void 0;
2505
+ }
2486
2506
  resolveConnectedInstanceId(instanceId) {
2487
2507
  const exact = this.getInstances().find((instance) => instance.id === instanceId);
2488
2508
  const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
@@ -5832,20 +5852,20 @@ var StudioInstanceManager = class {
5832
5852
  };
5833
5853
 
5834
5854
  // ../core/dist/mcp-runtime.js
5835
- var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
5836
- "bundleModifiedAt",
5837
- "bundlePath",
5838
- "bundleSha256",
5839
- "connectedAt",
5840
- "debug",
5841
- "diagnostics",
5842
- "internal",
5843
- "lastActivity",
5844
- "transportPeerId",
5845
- "pluginVariant",
5846
- "pluginVersion",
5847
- "serverVersion"
5848
- ]);
5855
+ var INTERNAL_ENVELOPE_KEYS = {
5856
+ bundleModifiedAt: true,
5857
+ bundlePath: true,
5858
+ bundleSha256: true,
5859
+ connectedAt: true,
5860
+ debug: true,
5861
+ diagnostics: true,
5862
+ internal: true,
5863
+ lastActivity: true,
5864
+ transportPeerId: true,
5865
+ pluginVariant: true,
5866
+ pluginVersion: true,
5867
+ serverVersion: true
5868
+ };
5849
5869
  var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
5850
5870
  var SIDE_EFFECTING_READ_TOOLS = /* @__PURE__ */ new Set([
5851
5871
  "capture_micro_profiler",
@@ -5894,18 +5914,15 @@ function compactPublicValue(value) {
5894
5914
  return value.map(compactPublicValue);
5895
5915
  if (!value || typeof value !== "object")
5896
5916
  return value;
5897
- const compact = {};
5898
- for (const [key, child] of Object.entries(value)) {
5899
- if (child === void 0 || INTERNAL_RESULT_KEYS.has(key))
5900
- continue;
5901
- compact[key] = compactPublicValue(child);
5902
- }
5903
- return compact;
5917
+ return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== void 0).map(([key, child]) => [key, compactPublicValue(child)]));
5918
+ }
5919
+ function compactPublicEnvelope(value) {
5920
+ return Object.fromEntries(Object.entries(value).filter(([key, child]) => child !== void 0 && !Object.hasOwn(INTERNAL_ENVELOPE_KEYS, key)).map(([key, child]) => [key, compactPublicValue(child)]));
5904
5921
  }
5905
5922
  function asStructuredObject(value) {
5906
5923
  if (!value || typeof value !== "object" || Array.isArray(value))
5907
5924
  return void 0;
5908
- return compactPublicValue(value);
5925
+ return compactPublicEnvelope(value);
5909
5926
  }
5910
5927
  function parseJsonObject(text) {
5911
5928
  try {
@@ -5930,6 +5947,7 @@ function normalizeToolResult(raw, era) {
5930
5947
  ...result.isError ? { isError: true } : {}
5931
5948
  };
5932
5949
  }
5950
+ const isError = result.isError || structured.success === false || typeof structured.error === "string" && structured.error.length > 0;
5933
5951
  const content = era === "modern" ? originalContent.filter((_, index) => index !== jsonTextIndex) : [
5934
5952
  { type: "text", text: JSON.stringify(structured) },
5935
5953
  ...originalContent.filter((_, index) => index !== jsonTextIndex)
@@ -5937,7 +5955,7 @@ function normalizeToolResult(raw, era) {
5937
5955
  return {
5938
5956
  content,
5939
5957
  structuredContent: structured,
5940
- ...result.isError ? { isError: true } : {}
5958
+ ...isError ? { isError: true } : {}
5941
5959
  };
5942
5960
  }
5943
5961
  function publicRoutingError(error) {
@@ -5960,8 +5978,11 @@ function publicRoutingError(error) {
5960
5978
  };
5961
5979
  }
5962
5980
  function publicToolErrorBody(name, error) {
5981
+ if (error instanceof MultiplayerGroupInUseError) {
5982
+ return { error: error.code, message: error.message, multiplayer_group_id: error.groupId };
5983
+ }
5963
5984
  if (error instanceof StudioLaunchPreDispatchError) {
5964
- return compactPublicValue(error.toResponseBody());
5985
+ return compactPublicEnvelope(error.toResponseBody());
5965
5986
  }
5966
5987
  if (error instanceof RoutingFailure)
5967
5988
  return publicRoutingError(error);
@@ -6435,6 +6456,47 @@ var WebSocketStudioTransport = class {
6435
6456
  };
6436
6457
 
6437
6458
  // ../core/dist/http-server.js
6459
+ var TOOL_PROXY_ENDPOINTS = {
6460
+ get_place_info: ["/api/place-info"],
6461
+ search_objects: ["/api/search-objects"],
6462
+ get_instance_properties: ["/api/instance-properties"],
6463
+ get_project_structure: ["/api/project-structure"],
6464
+ set_properties: ["/api/set-properties"],
6465
+ get_script_source: ["/api/get-script-source"],
6466
+ set_script_source: ["/api/set-script-source"],
6467
+ edit_script_lines: ["/api/edit-script-lines"],
6468
+ insert_script_lines: ["/api/insert-script-lines"],
6469
+ delete_script_lines: ["/api/delete-script-lines"],
6470
+ get_attributes: ["/api/get-attributes"],
6471
+ selection: ["/api/get-selection", "/api/set-selection", "/api/focus-viewport"],
6472
+ execute_luau: ["/api/execute-luau"],
6473
+ eval_server_runtime: ["/api/eval-runtime"],
6474
+ eval_client_runtime: ["/api/eval-runtime"],
6475
+ grep_scripts: ["/api/grep-scripts"],
6476
+ solo_playtest: ["/api/start-playtest", "/api/stop-playtest", "/api/multiplayer-test-state"],
6477
+ multiplayer_playtest: [
6478
+ "/api/multiplayer-test-start",
6479
+ "/api/multiplayer-test-add-players",
6480
+ "/api/multiplayer-test-leave-client",
6481
+ "/api/multiplayer-test-end",
6482
+ "/api/multiplayer-test-state"
6483
+ ],
6484
+ get_runtime_logs: ["/api/get-runtime-logs"],
6485
+ capture_script_profiler: ["/api/capture-script-profiler"],
6486
+ capture_micro_profiler: ["/api/capture-micro-profiler"],
6487
+ breakpoints: ["/api/breakpoints"],
6488
+ insert_asset: ["/api/insert-asset"],
6489
+ generate_model: ["/api/generate-model"],
6490
+ preview_asset: ["/api/preview-asset"],
6491
+ capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read"],
6492
+ simulate_mouse_input: ["/api/simulate-mouse-input"],
6493
+ simulate_keyboard_input: ["/api/simulate-keyboard-input"],
6494
+ get_memory_breakdown: ["/api/get-memory-breakdown"],
6495
+ get_scene_analysis: ["/api/get-scene-analysis"],
6496
+ export_rbxm: ["/api/export-rbxm"],
6497
+ import_rbxm: ["/api/import-rbxm"],
6498
+ find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"]
6499
+ };
6438
6500
  function toPassivePeer(peer) {
6439
6501
  return {
6440
6502
  instanceId: peer.instanceId,
@@ -6621,6 +6683,7 @@ ${body}`);
6621
6683
  }
6622
6684
  function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6623
6685
  const app = express();
6686
+ const allowedProxyEndpoints = allowedTools ? new Set(Object.entries(TOOL_PROXY_ENDPOINTS).filter(([toolName]) => allowedTools.has(toolName)).flatMap(([, endpoints]) => endpoints)) : void 0;
6624
6687
  const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
6625
6688
  const studioLifecycleCapabilities = studioLifecycleCallable ? tools.getStudioLifecycleCapabilities() : void 0;
6626
6689
  let mcpServerActive = false;
@@ -7028,14 +7091,27 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7028
7091
  const group = await bridge.createMultiplayerGroupEverywhere(groupId, controllerInstanceId);
7029
7092
  res.json({ success: true, group });
7030
7093
  });
7031
- app.post("/remove-multiplayer-group", async (req, res) => {
7094
+ app.post("/remove-multiplayer-group", async (req, res, next) => {
7032
7095
  const { groupId } = req.body;
7033
7096
  if (typeof groupId !== "string" || groupId.length === 0) {
7034
7097
  res.status(400).json({ error: "groupId is required" });
7035
7098
  return;
7036
7099
  }
7037
- const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
7038
- res.json({ success: true, removed });
7100
+ try {
7101
+ const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
7102
+ res.json({ success: true, removed });
7103
+ } catch (error) {
7104
+ if (error instanceof MultiplayerGroupInUseError) {
7105
+ res.status(409).json({
7106
+ success: false,
7107
+ error: error.code,
7108
+ message: error.message,
7109
+ groupId: error.groupId
7110
+ });
7111
+ return;
7112
+ }
7113
+ next(error);
7114
+ }
7039
7115
  });
7040
7116
  app.get("/status", (req, res) => {
7041
7117
  const peers = bridge.getPublicPeers().map(toPassivePeer);
@@ -7082,6 +7158,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7082
7158
  res.status(400).json({ error: "endpoint and targetPeerId are required" });
7083
7159
  return;
7084
7160
  }
7161
+ if (allowedProxyEndpoints && !allowedProxyEndpoints.has(endpoint)) {
7162
+ res.status(403).json({ error: "forbidden_endpoint" });
7163
+ return;
7164
+ }
7085
7165
  if (timeoutMs !== void 0 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e5)) {
7086
7166
  res.status(400).json({ error: "timeoutMs must be an integer between 1 and 300000" });
7087
7167
  return;
@@ -10880,7 +10960,10 @@ var RobloxStudioTools = class {
10880
10960
  _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
10881
10961
  return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
10882
10962
  }
10883
- _request(endpoint, data, instanceId, role, timeoutMs, signal) {
10963
+ async _request(endpoint, data, instanceId, role, timeoutMs, signal) {
10964
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
10965
+ if (refresh)
10966
+ await refresh;
10884
10967
  const peer = this._peerForRoleInScope(instanceId, role);
10885
10968
  if (!peer) {
10886
10969
  throw new RoutingFailure({
@@ -10893,6 +10976,9 @@ var RobloxStudioTools = class {
10893
10976
  }
10894
10977
  // Resolve an optional Studio process plus role to one exact Peer and dispatch.
10895
10978
  async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
10979
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
10980
+ if (refresh)
10981
+ await refresh;
10896
10982
  const resolved = this.bridge.resolveTarget({ instance_id, target });
10897
10983
  if (!resolved.ok)
10898
10984
  throw new RoutingFailure(resolved.error);
@@ -10924,6 +11010,7 @@ var RobloxStudioTools = class {
10924
11010
  clientRole: client?.role
10925
11011
  };
10926
11012
  }
11013
+ // Resolve synchronously after the caller has refreshed and captured its topology.
10927
11014
  _resolveInstanceIdOnly(instance_id) {
10928
11015
  if (instance_id !== void 0) {
10929
11016
  const resolvedInstanceId = this.bridge.resolveConnectedInstanceId(instance_id);
@@ -11137,6 +11224,9 @@ var RobloxStudioTools = class {
11137
11224
  async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
11138
11225
  const deadline = Date.now() + timeoutSec * 1e3;
11139
11226
  while (Date.now() < deadline) {
11227
+ const refresh = this.bridge.refreshTopologyForRouting();
11228
+ if (refresh)
11229
+ await refresh;
11140
11230
  const roles = this._rolesForScope(instanceId);
11141
11231
  const clientRoles = this._clientRolesForScope(instanceId);
11142
11232
  const hasServer = !opts.server || roles.includes("server");
@@ -11158,6 +11248,9 @@ var RobloxStudioTools = class {
11158
11248
  const deadline = Date.now() + timeoutSec * 1e3;
11159
11249
  let exactSince;
11160
11250
  while (Date.now() < deadline) {
11251
+ const refresh = this.bridge.refreshTopologyForRouting();
11252
+ if (refresh)
11253
+ await refresh;
11161
11254
  const roles2 = this._rolesForScope(instanceId);
11162
11255
  const clientCount2 = this._clientRolesForScope(instanceId).length;
11163
11256
  if (clientCount2 > expectedClientCount) {
@@ -11180,6 +11273,9 @@ var RobloxStudioTools = class {
11180
11273
  async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
11181
11274
  const deadline = Date.now() + timeoutSec * 1e3;
11182
11275
  while (Date.now() < deadline) {
11276
+ const refresh = this.bridge.refreshTopologyForRouting();
11277
+ if (refresh)
11278
+ await refresh;
11183
11279
  const peers = this.bridge.getPeersInScope(instanceId);
11184
11280
  const roles = peers.map((peer) => peer.role);
11185
11281
  const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
@@ -11477,6 +11573,9 @@ var RobloxStudioTools = class {
11477
11573
  if (angleY !== void 0 && (angleY < -89 || angleY > 89)) {
11478
11574
  throw new Error("selection angleY must be between -89 and 89");
11479
11575
  }
11576
+ const refresh = this.bridge.refreshTopologyForRouting();
11577
+ if (refresh)
11578
+ await refresh;
11480
11579
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
11481
11580
  const response = await this._callSingle("/api/focus-viewport", {
11482
11581
  path: instancePath,
@@ -11541,6 +11640,9 @@ var RobloxStudioTools = class {
11541
11640
  }
11542
11641
  async setNetworkProfile(profile, target, overrides, instance_id) {
11543
11642
  const values = normalizeNetworkProfile(profile, overrides);
11643
+ const refresh = this.bridge.refreshTopologyForRouting();
11644
+ if (refresh)
11645
+ await refresh;
11544
11646
  const instanceId = this._resolveInstanceIdOnly(instance_id);
11545
11647
  const clientRoles = this._clientRolesForScope(instanceId);
11546
11648
  const selectedTarget = target ?? "client-1";
@@ -11607,6 +11709,9 @@ var RobloxStudioTools = class {
11607
11709
  const selectedInclude = this._normalizeSimulationInclude(include);
11608
11710
  const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
11609
11711
  const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
11712
+ const refresh = this.bridge.refreshTopologyForRouting();
11713
+ if (refresh)
11714
+ await refresh;
11610
11715
  const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
11611
11716
  const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
11612
11717
  const state = {};
@@ -11653,6 +11758,9 @@ var RobloxStudioTools = class {
11653
11758
  if (!resetNetwork && !resetDeviceSimulator) {
11654
11759
  throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
11655
11760
  }
11761
+ const refresh = this.bridge.refreshTopologyForRouting();
11762
+ if (refresh)
11763
+ await refresh;
11656
11764
  const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
11657
11765
  const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
11658
11766
  const result = {};
@@ -11711,6 +11819,9 @@ var RobloxStudioTools = class {
11711
11819
  if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
11712
11820
  throw new Error("deviceId must be a non-empty string when provided");
11713
11821
  }
11822
+ const refresh = this.bridge.refreshTopologyForRouting();
11823
+ if (refresh)
11824
+ await refresh;
11714
11825
  const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
11715
11826
  const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
11716
11827
  includeDeviceList: includeDeviceList !== false,
@@ -11735,6 +11846,9 @@ var RobloxStudioTools = class {
11735
11846
  if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
11736
11847
  throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
11737
11848
  }
11849
+ const refresh = this.bridge.refreshTopologyForRouting();
11850
+ if (refresh)
11851
+ await refresh;
11738
11852
  const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
11739
11853
  const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
11740
11854
  const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
@@ -11793,6 +11907,9 @@ var RobloxStudioTools = class {
11793
11907
  label: raw.label
11794
11908
  };
11795
11909
  });
11910
+ const refresh = this.bridge.refreshTopologyForRouting();
11911
+ if (refresh)
11912
+ await refresh;
11796
11913
  const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
11797
11914
  if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
11798
11915
  throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
@@ -11908,6 +12025,9 @@ var RobloxStudioTools = class {
11908
12025
  if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
11909
12026
  throw new Error("get_runtime_logs tail must be a non-negative integer.");
11910
12027
  }
12028
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
12029
+ if (refresh)
12030
+ await refresh;
11911
12031
  const instances = this.bridge.getInstances();
11912
12032
  const groups = this.bridge.getMultiplayerGroups();
11913
12033
  let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
@@ -12141,6 +12261,9 @@ var RobloxStudioTools = class {
12141
12261
  if (outputPath) {
12142
12262
  data.__mcp_include_raw_json = true;
12143
12263
  }
12264
+ const refresh = this.bridge.refreshTopologyForRouting();
12265
+ if (refresh)
12266
+ await refresh;
12144
12267
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12145
12268
  if (!resolved.ok)
12146
12269
  throw new RoutingFailure(resolved.error);
@@ -12201,6 +12324,9 @@ var RobloxStudioTools = class {
12201
12324
  if (summaryOutputPath || baselinePath || baseline || includeComparisonIndex) {
12202
12325
  data.__mcp_include_comparison_index = true;
12203
12326
  }
12327
+ const refresh = this.bridge.refreshTopologyForRouting();
12328
+ if (refresh)
12329
+ await refresh;
12204
12330
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12205
12331
  if (!resolved.ok)
12206
12332
  throw new RoutingFailure(resolved.error);
@@ -12255,6 +12381,9 @@ var RobloxStudioTools = class {
12255
12381
  const data = { ...request, action };
12256
12382
  delete data.target;
12257
12383
  delete data.instance_id;
12384
+ const refresh = this.bridge.refreshTopologyForRouting();
12385
+ if (refresh)
12386
+ await refresh;
12258
12387
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12259
12388
  if (!resolved.ok)
12260
12389
  throw new RoutingFailure(resolved.error);
@@ -12605,6 +12734,9 @@ var RobloxStudioTools = class {
12605
12734
  throw new Error("solo_playtest requires action=start|stop|status");
12606
12735
  }
12607
12736
  if (action === "status") {
12737
+ const refresh = this.bridge.refreshTopologyForRouting();
12738
+ if (refresh)
12739
+ await refresh;
12608
12740
  const instanceId = this._resolveInstanceIdOnly(instance_id);
12609
12741
  const { roles, runtimeRoles } = this._briefRoles(instanceId);
12610
12742
  return this._textResult({
@@ -12628,6 +12760,8 @@ var RobloxStudioTools = class {
12628
12760
  });
12629
12761
  }
12630
12762
  return this._textResult({
12763
+ // Keep lifecycle diagnostics on failures; only successful responses are brief.
12764
+ ...body2,
12631
12765
  success: false,
12632
12766
  action,
12633
12767
  error: body2.error ?? "start_failed",
@@ -12644,6 +12778,7 @@ var RobloxStudioTools = class {
12644
12778
  });
12645
12779
  }
12646
12780
  return this._textResult({
12781
+ ...body,
12647
12782
  success: false,
12648
12783
  action,
12649
12784
  error: body.error ?? "stop_failed",
@@ -12662,6 +12797,9 @@ var RobloxStudioTools = class {
12662
12797
  }
12663
12798
  const data = { mode };
12664
12799
  const startedAt = Date.now();
12800
+ const refresh = this.bridge.refreshTopologyForRouting();
12801
+ if (refresh)
12802
+ await refresh;
12665
12803
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
12666
12804
  if (!resolved.ok)
12667
12805
  throw new RoutingFailure(resolved.error);
@@ -12712,6 +12850,9 @@ var RobloxStudioTools = class {
12712
12850
  };
12713
12851
  }
12714
12852
  async stopPlaytest(instance_id, timeout = 15) {
12853
+ const refresh = this.bridge.refreshTopologyForRouting();
12854
+ if (refresh)
12855
+ await refresh;
12715
12856
  const { instanceId } = this._resolveSingleTarget("edit", instance_id);
12716
12857
  let response;
12717
12858
  let stopRequestError;
@@ -12732,7 +12873,7 @@ var RobloxStudioTools = class {
12732
12873
  wait = {
12733
12874
  ok: false,
12734
12875
  roles: this._rolesForScope(instanceId),
12735
- timedOut: false
12876
+ timedOut: response.timedOut === true
12736
12877
  };
12737
12878
  }
12738
12879
  const body = wait ? {
@@ -12747,8 +12888,8 @@ var RobloxStudioTools = class {
12747
12888
  ...body,
12748
12889
  success: false,
12749
12890
  error: "Playtest teardown did not complete.",
12750
- message: response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
12751
- stopSignalAccepted: response?.success === true,
12891
+ message: response.stopSignalAccepted === true && typeof response.message === "string" ? response.message : response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
12892
+ stopSignalAccepted: response?.success === true || response.stopSignalAccepted === true,
12752
12893
  stopRequestError,
12753
12894
  runtimeRoles,
12754
12895
  possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
@@ -12762,6 +12903,9 @@ var RobloxStudioTools = class {
12762
12903
  };
12763
12904
  }
12764
12905
  async _buildMultiplayerState(instanceId) {
12906
+ const refresh = this.bridge.refreshTopologyForRouting();
12907
+ if (refresh)
12908
+ await refresh;
12765
12909
  const peers = this.bridge.getPublicPeers().filter((peer) => this.bridge.getInstanceIdsInScope(instanceId).includes(peer.instanceId)).sort((a, b) => a.role.localeCompare(b.role));
12766
12910
  const multiplayerGroup = this.bridge.getMultiplayerGroups().find((group) => group.instanceIds.includes(instanceId));
12767
12911
  const body = {
@@ -12807,6 +12951,9 @@ var RobloxStudioTools = class {
12807
12951
  async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
12808
12952
  const deadline = Date.now() + timeoutSec * 1e3;
12809
12953
  while (Date.now() < deadline) {
12954
+ const refresh = this.bridge.refreshTopologyForRouting();
12955
+ if (refresh)
12956
+ await refresh;
12810
12957
  if (!this._rolesForScope(instanceId).includes("edit"))
12811
12958
  return false;
12812
12959
  try {
@@ -12821,6 +12968,9 @@ var RobloxStudioTools = class {
12821
12968
  return false;
12822
12969
  }
12823
12970
  async _isMultiplayerTestRunning(instanceId) {
12971
+ const refresh = this.bridge.refreshTopologyForRouting();
12972
+ if (refresh)
12973
+ await refresh;
12824
12974
  return this.bridge.getMultiplayerGroups().some((group) => group.instanceIds.includes(instanceId));
12825
12975
  }
12826
12976
  async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
@@ -12861,6 +13011,9 @@ var RobloxStudioTools = class {
12861
13011
  throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
12862
13012
  }
12863
13013
  const briefState = async (instanceId) => {
13014
+ const refresh = this.bridge.refreshTopologyForRouting();
13015
+ if (refresh)
13016
+ await refresh;
12864
13017
  const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
12865
13018
  const roles = Array.isArray(state.peers) ? state.peers.flatMap((peer) => peer !== null && typeof peer === "object" && "role" in peer && typeof peer.role === "string" ? [peer.role] : []) : [];
12866
13019
  return {
@@ -12958,6 +13111,9 @@ var RobloxStudioTools = class {
12958
13111
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
12959
13112
  throw new Error("numPlayers must be an integer from 1 to 8");
12960
13113
  }
13114
+ const refresh = this.bridge.refreshTopologyForRouting();
13115
+ if (refresh)
13116
+ await refresh;
12961
13117
  const editTarget = this._resolveSingleTarget("edit", instance_id);
12962
13118
  const existingRuntime = this._runtimeTargetsForScope(editTarget.instanceId);
12963
13119
  if (existingRuntime.length > 0) {
@@ -13014,6 +13170,9 @@ var RobloxStudioTools = class {
13014
13170
  };
13015
13171
  }
13016
13172
  async multiplayerTestState(instance_id) {
13173
+ const refresh = this.bridge.refreshTopologyForRouting();
13174
+ if (refresh)
13175
+ await refresh;
13017
13176
  const instanceId = this._resolveInstanceIdOnly(instance_id);
13018
13177
  const state = await this._buildMultiplayerState(instanceId);
13019
13178
  return { content: [{ type: "text", text: JSON.stringify(state) }] };
@@ -13022,6 +13181,9 @@ var RobloxStudioTools = class {
13022
13181
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
13023
13182
  throw new Error("numPlayers must be an integer from 1 to 8");
13024
13183
  }
13184
+ const refresh = this.bridge.refreshTopologyForRouting();
13185
+ if (refresh)
13186
+ await refresh;
13025
13187
  const serverTarget = this._resolveSingleTarget("server", instance_id);
13026
13188
  const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
13027
13189
  const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
@@ -13050,6 +13212,9 @@ var RobloxStudioTools = class {
13050
13212
  if (!/^client-\d+$/.test(target)) {
13051
13213
  throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
13052
13214
  }
13215
+ const refresh = this.bridge.refreshTopologyForRouting();
13216
+ if (refresh)
13217
+ await refresh;
13053
13218
  const clientTarget = this._resolveSingleTarget(target, instance_id);
13054
13219
  const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
13055
13220
  const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
@@ -13073,6 +13238,9 @@ var RobloxStudioTools = class {
13073
13238
  };
13074
13239
  }
13075
13240
  async multiplayerTestEnd(value, timeout, instance_id) {
13241
+ const refresh = this.bridge.refreshTopologyForRouting();
13242
+ if (refresh)
13243
+ await refresh;
13076
13244
  let serverTarget;
13077
13245
  try {
13078
13246
  serverTarget = this._resolveSingleTarget("server", instance_id);
@@ -13121,6 +13289,9 @@ var RobloxStudioTools = class {
13121
13289
  return result;
13122
13290
  }
13123
13291
  async getConnectedInstances() {
13292
+ const refresh = this.bridge.refreshTopologyForRouting();
13293
+ if (refresh)
13294
+ await refresh;
13124
13295
  return this._textResult({
13125
13296
  instances: this.bridge.getConnectedInstances(),
13126
13297
  multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
@@ -13662,6 +13833,9 @@ var RobloxStudioTools = class {
13662
13833
  if (!action) {
13663
13834
  throw new Error("action is required for simulate_mouse_input");
13664
13835
  }
13836
+ const refresh = this.bridge.refreshTopologyForRouting();
13837
+ if (refresh)
13838
+ await refresh;
13665
13839
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13666
13840
  const response = await this._callSingle("/api/simulate-mouse-input", {
13667
13841
  action,
@@ -13680,6 +13854,9 @@ var RobloxStudioTools = class {
13680
13854
  if (!keyCode && text === void 0) {
13681
13855
  throw new Error("keyCode or text is required for simulate_keyboard_input");
13682
13856
  }
13857
+ const refresh = this.bridge.refreshTopologyForRouting();
13858
+ if (refresh)
13859
+ await refresh;
13683
13860
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13684
13861
  const response = await this._callSingle("/api/simulate-keyboard-input", {
13685
13862
  keyCode,
@@ -13718,6 +13895,9 @@ var RobloxStudioTools = class {
13718
13895
  const data = {};
13719
13896
  if (tags !== void 0)
13720
13897
  data.tags = tags;
13898
+ const refresh = this.bridge.refreshTopologyForRouting();
13899
+ if (refresh)
13900
+ await refresh;
13721
13901
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
13722
13902
  if (!resolved.ok)
13723
13903
  throw new RoutingFailure(resolved.error);
@@ -13751,6 +13931,9 @@ var RobloxStudioTools = class {
13751
13931
  data.topN = topN;
13752
13932
  if (raw !== void 0)
13753
13933
  data.raw = raw;
13934
+ const refresh = this.bridge.refreshTopologyForRouting();
13935
+ if (refresh)
13936
+ await refresh;
13754
13937
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
13755
13938
  if (!resolved.ok)
13756
13939
  throw new RoutingFailure(resolved.error);
@@ -13883,18 +14066,23 @@ var RobloxStudioTools = class {
13883
14066
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
13884
14067
  }
13885
14068
  async _captureViewportImage(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
13886
- let response;
13887
- if (targetRole.startsWith("client-")) {
13888
- const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
13889
- if (begin.error) {
13890
- return { success: false, error: begin.error };
13891
- }
13892
- if (!begin.contentId) {
13893
- return { success: false, error: "Screenshot capture failed: no content id returned from client." };
14069
+ const fmt = format === "png" ? "png" : "jpeg";
14070
+ const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
14071
+ let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
14072
+ const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
14073
+ if (studioFastPathMissing) {
14074
+ if (targetRole.startsWith("client-")) {
14075
+ const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
14076
+ if (begin.error) {
14077
+ return { success: false, error: begin.error };
14078
+ }
14079
+ if (!begin.contentId) {
14080
+ return { success: false, error: "Screenshot capture failed: no content id returned from client." };
14081
+ }
14082
+ response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14083
+ } else {
14084
+ response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
13894
14085
  }
13895
- response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
13896
- } else {
13897
- response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
13898
14086
  }
13899
14087
  if (response.error) {
13900
14088
  let text = response.error;
@@ -13908,11 +14096,19 @@ var RobloxStudioTools = class {
13908
14096
  if (w === void 0 || h === void 0) {
13909
14097
  return { success: false, error: "Screenshot response missing dimensions." };
13910
14098
  }
13911
- const fmt = format === "png" ? "png" : "jpeg";
13912
- const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
13913
- const encoded = encodeImageFromRgbaResponse(response, fmt, q);
13914
- let { buffer } = encoded;
13915
- const { mimeType } = encoded;
14099
+ let buffer;
14100
+ let mimeType;
14101
+ if (response.encoding === "png") {
14102
+ if (!response.data) {
14103
+ return { success: false, error: "Screenshot response missing PNG data." };
14104
+ }
14105
+ buffer = Buffer.from(response.data, "base64");
14106
+ mimeType = "image/png";
14107
+ } else {
14108
+ const encoded = encodeImageFromRgbaResponse(response, fmt, q);
14109
+ buffer = encoded.buffer;
14110
+ mimeType = encoded.mimeType;
14111
+ }
13916
14112
  let usedQ = q;
13917
14113
  let note = "";
13918
14114
  if (buffer.length > maxBytes) {
@@ -13951,6 +14147,9 @@ var RobloxStudioTools = class {
13951
14147
  };
13952
14148
  }
13953
14149
  async captureScreenshot(instance_id, format, quality) {
14150
+ const refresh = this.bridge.refreshTopologyForRouting();
14151
+ if (refresh)
14152
+ await refresh;
13954
14153
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13955
14154
  const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
13956
14155
  if (!capture.success) {
@@ -14032,6 +14231,9 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14032
14231
  initialRefresh;
14033
14232
  refreshTimer;
14034
14233
  static REFRESH_INTERVAL_MS = 1e3;
14234
+ static TOPOLOGY_TIMEOUT_MS = 2e3;
14235
+ topologyGeneration = 0;
14236
+ appliedTopologyGeneration = 0;
14035
14237
  constructor(primaryBaseUrl, authToken) {
14036
14238
  super();
14037
14239
  this.primaryBaseUrl = primaryBaseUrl;
@@ -14049,17 +14251,33 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14049
14251
  headers["X-MCP-Auth"] = this.authToken;
14050
14252
  return headers;
14051
14253
  }
14052
- async refreshTopology() {
14254
+ refreshTopologyForRouting(signal) {
14255
+ return this.refreshTopology(signal, true);
14256
+ }
14257
+ async refreshTopology(signal, requireFresh = false) {
14258
+ const generation = ++this.topologyGeneration;
14259
+ const controller = new AbortController();
14260
+ const abort = () => controller.abort(signal?.reason);
14261
+ signal?.addEventListener("abort", abort, { once: true });
14262
+ if (signal?.aborted)
14263
+ abort();
14264
+ const timeout = setTimeout(() => controller.abort(new Error("Studio topology refresh timed out")), _ProxyBridgeService.TOPOLOGY_TIMEOUT_MS);
14053
14265
  try {
14266
+ controller.signal.throwIfAborted();
14054
14267
  const res = await fetch(`${this.primaryBaseUrl}/topology`, {
14055
- headers: this.authHeaders()
14268
+ headers: this.authHeaders(),
14269
+ signal: controller.signal
14056
14270
  });
14057
14271
  if (!res.ok)
14058
- return;
14272
+ throw new Error(`Studio topology returned HTTP ${res.status}`);
14059
14273
  const body = await res.json();
14060
- if (!Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
14061
- return;
14274
+ controller.signal.throwIfAborted();
14275
+ if (!body || !Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
14276
+ throw new Error("Primary returned invalid Studio topology");
14062
14277
  }
14278
+ if (generation < this.appliedTopologyGeneration)
14279
+ return;
14280
+ this.appliedTopologyGeneration = generation;
14063
14281
  const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
14064
14282
  this.cachedPeers = body.peers;
14065
14283
  this.cachedInstances = body.instances;
@@ -14069,7 +14287,12 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14069
14287
  this.notifyPeerRegistered(toPublicPeer(peer));
14070
14288
  }
14071
14289
  }
14072
- } catch {
14290
+ } catch (error) {
14291
+ if (requireFresh)
14292
+ throw error;
14293
+ } finally {
14294
+ clearTimeout(timeout);
14295
+ signal?.removeEventListener("abort", abort);
14073
14296
  }
14074
14297
  }
14075
14298
  getPeers() {
@@ -14124,6 +14347,13 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14124
14347
  headers: this.authHeaders({ "Content-Type": "application/json" }),
14125
14348
  body: JSON.stringify({ groupId })
14126
14349
  });
14350
+ if (response.status === 409) {
14351
+ const refusal = await response.json().catch(() => void 0);
14352
+ if (refusal && typeof refusal === "object" && !Array.isArray(refusal) && "success" in refusal && refusal.success === false && "error" in refusal && refusal.error === "multiplayer_group_in_use" && "groupId" in refusal && refusal.groupId === groupId) {
14353
+ throw new MultiplayerGroupInUseError(groupId);
14354
+ }
14355
+ throw new Error("Proxy Multiplayer Group removal returned an invalid refusal.");
14356
+ }
14127
14357
  if (!response.ok) {
14128
14358
  const body = await response.text().catch(() => "");
14129
14359
  throw new Error(`Proxy Multiplayer Group removal failed (${response.status}): ${body || response.statusText}`);
@@ -16459,7 +16689,7 @@ var getReadOnlyTools = () => TOOL_DEFINITIONS.filter((t) => t.category === "read
16459
16689
 
16460
16690
  // ../core/dist/install-plugin-helpers.js
16461
16691
  var import_saxes = __toESM(require_saxes(), 1);
16462
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16692
+ import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16463
16693
  import { execSync } from "child_process";
16464
16694
  import { randomUUID as randomUUID5 } from "crypto";
16465
16695
  import { basename as basename3, join as join5 } from "path";
@@ -16610,7 +16840,79 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
16610
16840
  }
16611
16841
  var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
16612
16842
  function errorCode(error) {
16613
- return error.code;
16843
+ return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
16844
+ }
16845
+ function pluginInstallLockError(lockPath, reason) {
16846
+ return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
16847
+ }
16848
+ function readPluginInstallLockOwner(lockPath) {
16849
+ const ownerPath = join5(lockPath, "owner.json");
16850
+ if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
16851
+ throw new Error("Lock and owner metadata must be a directory and regular file");
16852
+ }
16853
+ const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16854
+ if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
16855
+ throw new Error("Invalid lock owner metadata");
16856
+ }
16857
+ return { pid: owner.pid, token: owner.token, createdAt: owner.createdAt };
16858
+ }
16859
+ function assertPluginInstallOwnerExited(lockPath, owner) {
16860
+ if (owner.pid !== process.pid) {
16861
+ try {
16862
+ process.kill(owner.pid, 0);
16863
+ } catch (error) {
16864
+ if (errorCode(error) === "ESRCH")
16865
+ return;
16866
+ throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
16867
+ }
16868
+ }
16869
+ throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
16870
+ }
16871
+ function recoverPluginInstallLock(lockPath, warn) {
16872
+ let owner;
16873
+ try {
16874
+ owner = readPluginInstallLockOwner(lockPath);
16875
+ } catch (error) {
16876
+ throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
16877
+ }
16878
+ assertPluginInstallOwnerExited(lockPath, owner);
16879
+ const claimPath = join5(lockPath, `recovery-${owner.token}`);
16880
+ try {
16881
+ mkdirSync4(claimPath);
16882
+ } catch (error) {
16883
+ throw pluginInstallLockError(lockPath, errorCode(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
16884
+ }
16885
+ const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
16886
+ let moved = false;
16887
+ try {
16888
+ let currentOwner;
16889
+ try {
16890
+ currentOwner = readPluginInstallLockOwner(lockPath);
16891
+ } catch (error) {
16892
+ throw pluginInstallLockError(lockPath, `Cannot safely recheck plugin installation lock owner (${error})`);
16893
+ }
16894
+ if (currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || currentOwner.createdAt !== owner.createdAt) {
16895
+ throw pluginInstallLockError(lockPath, "Plugin installation lock owner changed; retry installation");
16896
+ }
16897
+ assertPluginInstallOwnerExited(lockPath, currentOwner);
16898
+ renameSync(lockPath, abandonedPath);
16899
+ moved = true;
16900
+ } finally {
16901
+ if (!moved) {
16902
+ try {
16903
+ rmdirSync(claimPath);
16904
+ } catch (error) {
16905
+ if (errorCode(error) !== "ENOENT") {
16906
+ warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
16907
+ }
16908
+ }
16909
+ }
16910
+ }
16911
+ try {
16912
+ rmSync2(abandonedPath, { recursive: true, force: true });
16913
+ } catch (error) {
16914
+ warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
16915
+ }
16614
16916
  }
16615
16917
  function acquirePluginInstallLock(pluginsFolder, warn) {
16616
16918
  const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
@@ -16625,7 +16927,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16625
16927
  } catch (error) {
16626
16928
  if (errorCode(error) !== "EEXIST")
16627
16929
  throw error;
16628
- throw new Error(`Another Studio plugin installation is already in progress: ${lockPath}. If no installer is running, remove that lock directory and retry.`);
16930
+ recoverPluginInstallLock(lockPath, warn);
16931
+ try {
16932
+ mkdirSync4(lockPath);
16933
+ } catch (retryError) {
16934
+ if (errorCode(retryError) !== "EEXIST")
16935
+ throw retryError;
16936
+ throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
16937
+ }
16629
16938
  }
16630
16939
  try {
16631
16940
  writeFileSync3(ownerPath, `${JSON.stringify(owner)}
@@ -16643,7 +16952,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16643
16952
  }
16644
16953
  return () => {
16645
16954
  try {
16646
- const currentOwner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16955
+ const currentOwner = readPluginInstallLockOwner(lockPath);
16647
16956
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
16648
16957
  rmSync2(lockPath, { recursive: true, force: true });
16649
16958
  }