@chrrxs/robloxstudio-mcp 3.1.1 → 3.1.3

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
@@ -1939,10 +1939,82 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
1939
1939
  // ../core/dist/http-server.js
1940
1940
  import express from "express";
1941
1941
  import http from "http";
1942
+ import { randomBytes as randomBytes2 } from "crypto";
1943
+ import { WebSocketServer } from "ws";
1942
1944
  import { toNodeHandler } from "@modelcontextprotocol/node";
1943
1945
 
1944
1946
  // ../core/dist/bridge-service.js
1945
- import { randomUUID } from "crypto";
1947
+ import { createHash, randomUUID } from "crypto";
1948
+ function isExecutionOutcome(value) {
1949
+ return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
1950
+ }
1951
+ function isRequestStage(value) {
1952
+ return value === "queued" || value === "dispatched" || value === "executing" || value === "response_delivery";
1953
+ }
1954
+ function handlerOutcome(response) {
1955
+ if (!response || typeof response !== "object")
1956
+ return "success";
1957
+ if ("error" in response && response.error !== void 0 && response.error !== null || "success" in response && response.success === false || "ok" in response && response.ok === false)
1958
+ return "error";
1959
+ if ("summary" in response && response.summary && typeof response.summary === "object" && "failed" in response.summary && typeof response.summary.failed === "number" && response.summary.failed > 0)
1960
+ return "error";
1961
+ return "success";
1962
+ }
1963
+ function observations(status) {
1964
+ return {
1965
+ executionStartedAt: status.executionStartedAt,
1966
+ executionCompletedAt: status.executionCompletedAt,
1967
+ executionOutcome: status.executionOutcome,
1968
+ connectionLostAt: status.connectionLostAt,
1969
+ connectionRestoredAt: status.connectionRestoredAt
1970
+ };
1971
+ }
1972
+ var RequestFailure = class extends Error {
1973
+ code;
1974
+ details;
1975
+ constructor(message, code, details) {
1976
+ super(message);
1977
+ this.code = code;
1978
+ this.details = details;
1979
+ this.name = "RequestFailure";
1980
+ }
1981
+ };
1982
+ function parseObservations(value) {
1983
+ const result = {};
1984
+ if ("executionOutcome" in value) {
1985
+ if (!isExecutionOutcome(value.executionOutcome))
1986
+ throw new Error("Invalid execution outcome");
1987
+ result.executionOutcome = value.executionOutcome;
1988
+ }
1989
+ for (const key of ["executionStartedAt", "executionCompletedAt", "connectionLostAt", "connectionRestoredAt"]) {
1990
+ if (!(key in value))
1991
+ continue;
1992
+ const timestamp = Reflect.get(value, key);
1993
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp))
1994
+ throw new Error("Invalid observation timestamp");
1995
+ result[key] = timestamp;
1996
+ }
1997
+ return result;
1998
+ }
1999
+ function parseFailureDetails(value, identity) {
2000
+ if (!value || typeof value !== "object" || Array.isArray(value))
2001
+ return void 0;
2002
+ const isHttpRejection = "transportStage" in value && value.transportStage === "http_receive";
2003
+ const requestId = "requestId" in value ? value.requestId : isHttpRejection ? identity.requestId : void 0;
2004
+ const targetPeerId = "targetPeerId" in value ? value.targetPeerId : isHttpRejection ? identity.targetPeerId : void 0;
2005
+ if (typeof requestId !== "string" || typeof targetPeerId !== "string" || requestId !== identity.requestId || targetPeerId !== identity.targetPeerId || !("stage" in value) || !isRequestStage(value.stage) || !("outcome" in value) || value.outcome !== "not_executed" && value.outcome !== "unknown")
2006
+ return void 0;
2007
+ return {
2008
+ requestId,
2009
+ targetPeerId,
2010
+ stage: value.stage,
2011
+ outcome: value.outcome,
2012
+ ...parseObservations(value),
2013
+ ..."bytes" in value && typeof value.bytes === "number" ? { bytes: value.bytes } : {},
2014
+ ..."limitBytes" in value && typeof value.limitBytes === "number" ? { limitBytes: value.limitBytes } : {},
2015
+ ..."transportStage" in value && (value.transportStage === "server_send" || value.transportStage === "proxy_send" || value.transportStage === "http_receive") ? { transportStage: value.transportStage } : {}
2016
+ };
2017
+ }
1946
2018
  var RoutingFailure = class extends Error {
1947
2019
  routingError;
1948
2020
  constructor(routingError) {
@@ -1970,11 +2042,16 @@ function toPublicPeer(peer) {
1970
2042
  };
1971
2043
  }
1972
2044
  var STALE_PEER_MS = 3e4;
1973
- var ACCEPTED_REQUEST_TOMBSTONE_TTL_MS = 6e4;
1974
- var MAX_ACCEPTED_REQUEST_TOMBSTONES = 4096;
2045
+ var OPERATION_RETENTION_MS = 5 * 6e4;
2046
+ var MAX_OPERATION_RECORDS = 32768;
2047
+ var MAX_RETAINED_RESULTS = 1024;
2048
+ var MAX_RETAINED_RESULT_BYTES = 64 * 1024 * 1024;
2049
+ var MAX_REQUEST_BYTES = 64 * 1024 * 1024;
2050
+ var MAX_PENDING_REQUESTS = 1024;
2051
+ var MAX_PENDING_REQUEST_BYTES = 64 * 1024 * 1024;
1975
2052
  var CANCELLATION_TOMBSTONE_TTL_MS = 6e4;
1976
2053
  var MAX_CANCELLATION_TOMBSTONES = 4096;
1977
- var MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER = 4;
2054
+ var MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT = 4;
1978
2055
  function roleOrder(role) {
1979
2056
  if (role === "edit")
1980
2057
  return 0;
@@ -2005,7 +2082,10 @@ function copyGroup(group) {
2005
2082
  }
2006
2083
  var BridgeService = class {
2007
2084
  pendingRequests = /* @__PURE__ */ new Map();
2008
- acceptedRequestIds = /* @__PURE__ */ new Map();
2085
+ operations = /* @__PURE__ */ new Map();
2086
+ retainedResults = /* @__PURE__ */ new Map();
2087
+ retainedResultBytes = 0;
2088
+ pendingRequestBytes = 0;
2009
2089
  pendingCancellations = /* @__PURE__ */ new Map();
2010
2090
  peersById = /* @__PURE__ */ new Map();
2011
2091
  multiplayerGroupsById = /* @__PURE__ */ new Map();
@@ -2048,13 +2128,25 @@ var BridgeService = class {
2048
2128
  this.deliveryOwnersByTransportPeer.set(transportPeerId, owners);
2049
2129
  }
2050
2130
  owners.add(owner);
2131
+ for (const operation of this.operations.values()) {
2132
+ if (operation.transportPeerId === transportPeerId && operation.status.state !== "settled" && operation.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0) {
2133
+ operation.status.connectionRestoredAt = Date.now();
2134
+ }
2135
+ }
2051
2136
  return;
2052
2137
  }
2053
2138
  if (!owners)
2054
2139
  return;
2055
2140
  owners.delete(owner);
2056
- if (owners.size === 0)
2141
+ if (owners.size === 0) {
2057
2142
  this.deliveryOwnersByTransportPeer.delete(transportPeerId);
2143
+ for (const operation of this.operations.values()) {
2144
+ if (operation.transportPeerId !== transportPeerId || operation.status.state === "settled")
2145
+ continue;
2146
+ operation.status.connectionLostAt = Date.now();
2147
+ delete operation.status.connectionRestoredAt;
2148
+ }
2149
+ }
2058
2150
  }
2059
2151
  notifyRequestAvailable(transportPeerId) {
2060
2152
  for (const listener of this.requestAvailableListeners) {
@@ -2285,8 +2377,7 @@ var BridgeService = class {
2285
2377
  if (request.targetPeerId !== peerId)
2286
2378
  continue;
2287
2379
  const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2288
- this.removePendingRequest(request);
2289
- request.reject(new Error(`Target Peer "${peerId}" disconnected`));
2380
+ this.endRequestWaiter(request, "disconnected", `Target Peer "${peerId}" disconnected`);
2290
2381
  if (deliveryTransportPeerId)
2291
2382
  this.notifyRequestAvailable(deliveryTransportPeerId);
2292
2383
  }
@@ -2392,6 +2483,12 @@ var BridgeService = class {
2392
2483
  multiplayerGroups: this.getMultiplayerGroups()
2393
2484
  };
2394
2485
  }
2486
+ /** Local topology is authoritative; remote adapters must refresh before fanout. */
2487
+ refreshTopologyForRouting(signal) {
2488
+ if (signal?.aborted)
2489
+ throw new Error("Request aborted before topology resolution");
2490
+ return void 0;
2491
+ }
2395
2492
  resolveConnectedInstanceId(instanceId) {
2396
2493
  const exact = this.getInstances().find((instance) => instance.id === instanceId);
2397
2494
  const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
@@ -2545,44 +2642,126 @@ var BridgeService = class {
2545
2642
  const peers = this.getPeers().filter((peer) => this.peerScopeKey(peer) === onlyScope);
2546
2643
  return this.resolveWithinScope(peers, input.target, errorData);
2547
2644
  }
2548
- sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal) {
2549
- const requestId = randomUUID();
2645
+ sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal, operationId) {
2646
+ const requestId = operationId ?? randomUUID();
2550
2647
  const effectiveTimeoutMs = Math.max(1, timeoutMs);
2551
- if (signal?.aborted)
2552
- return Promise.reject(new Error("Request aborted"));
2648
+ const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
2649
+ if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
2650
+ return Promise.reject(new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details));
2651
+ }
2652
+ if (signal?.aborted) {
2653
+ return Promise.reject(new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details));
2654
+ }
2655
+ let requestBytes;
2656
+ let fingerprint;
2657
+ try {
2658
+ const target2 = this.getPeerById(targetPeerId);
2659
+ fingerprint = createHash("sha256").update(JSON.stringify({ targetPeerId, endpoint, data })).digest("hex");
2660
+ requestBytes = Buffer.byteLength(JSON.stringify({
2661
+ kind: "request",
2662
+ requestId,
2663
+ peerId: targetPeerId,
2664
+ target: target2?.role,
2665
+ endpoint,
2666
+ data: data ?? null,
2667
+ remainingMs: effectiveTimeoutMs
2668
+ }));
2669
+ } catch {
2670
+ return Promise.reject(new RequestFailure(`Request ${requestId} cannot be serialized; queued; not_executed`, "request_serialization_failed", details));
2671
+ }
2672
+ this.pruneOperations(Date.now());
2673
+ const existing = this.operations.get(requestId);
2674
+ if (existing) {
2675
+ if (existing.fingerprint !== fingerprint) {
2676
+ return Promise.reject(new RequestFailure(`Request ${requestId} already identifies a different operation; existing operation ${existing.status.stage}; not replayed`, "operation_id_collision", { requestId, targetPeerId: existing.status.targetPeerId, stage: existing.status.stage, outcome: "unknown", ...observations(existing.status) }));
2677
+ }
2678
+ const pending = this.pendingRequests.get(requestId);
2679
+ if (pending)
2680
+ return pending.promise;
2681
+ const status = this.getRequestStatus(requestId);
2682
+ if (status.state === "settled" && !status.resultUnavailable) {
2683
+ const error = status.error;
2684
+ if (error && typeof error === "object" && "name" in error && error.name === "RequestFailure" && "code" in error && typeof error.code === "string" && "message" in error && typeof error.message === "string" && "details" in error) {
2685
+ const failureDetails = parseFailureDetails(error.details, { requestId, targetPeerId });
2686
+ if (failureDetails)
2687
+ return Promise.reject(new RequestFailure(error.message, error.code, failureDetails));
2688
+ }
2689
+ return Object.hasOwn(status, "error") ? Promise.reject(status.error) : Promise.resolve(status.response);
2690
+ }
2691
+ return Promise.reject(new RequestFailure(`Request ${requestId} already exists: ${status.state}; ${status.stage}; ${status.outcome}; use get_request_status; not replayed`, "operation_not_replayed", { requestId, targetPeerId, stage: status.stage, outcome: status.executionOutcome === "not_executed" ? "not_executed" : "unknown", ...observations(status) }));
2692
+ }
2693
+ if (requestBytes > MAX_REQUEST_BYTES) {
2694
+ return Promise.reject(new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at server_send; limit ${MAX_REQUEST_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: MAX_REQUEST_BYTES, transportStage: "server_send" }));
2695
+ }
2696
+ if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
2697
+ return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
2698
+ }
2553
2699
  const { promise, resolve: resolve5, reject } = Promise.withResolvers();
2554
- const cancelPending = (reason, error) => {
2700
+ const abortListener = () => {
2555
2701
  const pending = this.pendingRequests.get(requestId);
2556
- if (!pending || !this.removePendingRequest(pending))
2557
- return;
2558
- this.notifyRequestCancelled(pending, reason);
2559
- pending.reject(error);
2702
+ if (pending)
2703
+ this.endRequestWaiter(pending, "aborted", "Request aborted");
2560
2704
  };
2561
- const timeoutId = setTimeout(() => cancelPending("timeout", new Error("Request timeout")), effectiveTimeoutMs);
2562
- const abortListener = () => cancelPending("aborted", new Error("Request aborted"));
2705
+ const timeoutId = setTimeout(() => {
2706
+ const pending = this.pendingRequests.get(requestId);
2707
+ if (pending)
2708
+ this.endRequestWaiter(pending, "timed_out", "Request timeout");
2709
+ }, effectiveTimeoutMs);
2710
+ const now = Date.now();
2563
2711
  const request = {
2564
2712
  id: requestId,
2565
2713
  endpoint,
2566
2714
  data,
2567
2715
  targetPeerId,
2568
- timestamp: Date.now(),
2716
+ timestamp: now,
2569
2717
  resolve: resolve5,
2570
2718
  reject,
2719
+ promise,
2571
2720
  timeoutId,
2572
2721
  timeoutMs: effectiveTimeoutMs,
2722
+ requestBytes,
2573
2723
  abortSignal: signal,
2574
2724
  abortListener
2575
2725
  };
2576
2726
  this.pendingRequests.set(requestId, request);
2727
+ this.pendingRequestBytes += requestBytes;
2728
+ this.operations.set(requestId, {
2729
+ status: { requestId, targetPeerId, queuedAt: now, stage: "queued", state: "pending", outcome: "pending", executionOutcome: "unknown" },
2730
+ fingerprint,
2731
+ updatedAt: now,
2732
+ resultBytes: 0
2733
+ });
2734
+ this.pruneOperations(now);
2577
2735
  signal?.addEventListener("abort", abortListener, { once: true });
2578
2736
  if (signal?.aborted)
2579
2737
  abortListener();
2580
2738
  const target = this.getPeerById(targetPeerId);
2581
- if (this.pendingRequests.has(requestId) && target) {
2739
+ if (this.pendingRequests.has(requestId) && target)
2582
2740
  this.notifyRequestAvailable(target.transportPeerId);
2583
- }
2584
2741
  return promise;
2585
2742
  }
2743
+ endRequestWaiter(request, state, message) {
2744
+ if (!this.removePendingRequest(request))
2745
+ return;
2746
+ const operation = this.operations.get(request.id);
2747
+ const stage = operation?.status.stage ?? (request.lastDeliveryTransportPeerId ? "dispatched" : "queued");
2748
+ const outcome = stage === "queued" || operation?.status.executionOutcome === "not_executed" ? "not_executed" : "unknown";
2749
+ if (operation) {
2750
+ operation.status.state = state;
2751
+ operation.status.outcome = outcome;
2752
+ if (stage === "queued")
2753
+ operation.status.executionOutcome = "not_executed";
2754
+ operation.status.waiterEndedAt = Date.now();
2755
+ operation.updatedAt = Date.now();
2756
+ this.operations.delete(request.id);
2757
+ this.operations.set(request.id, operation);
2758
+ }
2759
+ if (state !== "disconnected") {
2760
+ this.notifyRequestCancelled(request, state === "timed_out" ? "timeout" : "aborted");
2761
+ }
2762
+ const connectionLost = operation?.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0;
2763
+ request.reject(new RequestFailure(`${message}: ${request.id}; ${stage}; ${outcome}${connectionLost ? "; connection lost" : ""}; waiter ended, execution is not cancelled or rolled back`, state === "timed_out" ? connectionLost ? "request_connection_lost" : "request_timeout" : `request_${state}`, { requestId: request.id, targetPeerId: request.targetPeerId, stage, outcome, ...operation ? observations(operation.status) : {} }));
2764
+ }
2586
2765
  removePendingRequest(request) {
2587
2766
  if (this.pendingRequests.get(request.id) !== request)
2588
2767
  return false;
@@ -2591,15 +2770,16 @@ var BridgeService = class {
2591
2770
  request.abortSignal.removeEventListener("abort", request.abortListener);
2592
2771
  }
2593
2772
  this.pendingRequests.delete(request.id);
2773
+ this.pendingRequestBytes -= request.requestBytes;
2594
2774
  return true;
2595
2775
  }
2596
2776
  claimNextRequestForTransport(transportPeerId, claimOwner) {
2597
2777
  let outstandingCount = 0;
2598
2778
  for (const request of this.pendingRequests.values()) {
2599
- if (request.claimOwner === claimOwner)
2779
+ if (request.lastDeliveryTransportPeerId === transportPeerId)
2600
2780
  outstandingCount++;
2601
2781
  }
2602
- if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER)
2782
+ if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT)
2603
2783
  return null;
2604
2784
  let oldestRequest;
2605
2785
  for (const request of this.pendingRequests.values()) {
@@ -2618,6 +2798,13 @@ var BridgeService = class {
2618
2798
  return null;
2619
2799
  oldestRequest.claimOwner = claimOwner;
2620
2800
  oldestRequest.lastDeliveryTransportPeerId = transportPeerId;
2801
+ const operation = this.operations.get(oldestRequest.id);
2802
+ if (operation) {
2803
+ operation.transportPeerId = transportPeerId;
2804
+ operation.status.stage = "dispatched";
2805
+ operation.status.dispatchedAt = Date.now();
2806
+ operation.updatedAt = Date.now();
2807
+ }
2621
2808
  return {
2622
2809
  requestId: oldestRequest.id,
2623
2810
  peerId: oldestRequest.targetPeerId,
@@ -2640,14 +2827,6 @@ var BridgeService = class {
2640
2827
  }
2641
2828
  releaseDeliveryClaims(claimOwner) {
2642
2829
  const transportPeerIds = /* @__PURE__ */ new Set();
2643
- for (const request of this.pendingRequests.values()) {
2644
- if (request.claimOwner !== claimOwner)
2645
- continue;
2646
- request.claimOwner = void 0;
2647
- const peer = this.getPeerById(request.targetPeerId);
2648
- if (peer)
2649
- transportPeerIds.add(peer.transportPeerId);
2650
- }
2651
2830
  for (const cancellation of this.pendingCancellations.values()) {
2652
2831
  if (cancellation.claimOwner !== claimOwner)
2653
2832
  continue;
@@ -2657,38 +2836,169 @@ var BridgeService = class {
2657
2836
  for (const transportPeerId of transportPeerIds)
2658
2837
  this.notifyRequestAvailable(transportPeerId);
2659
2838
  }
2839
+ ownedOperation(transportPeerId, requestId) {
2840
+ this.pruneOperations(Date.now());
2841
+ const operation = this.operations.get(requestId);
2842
+ if (!operation || operation.transportPeerId !== transportPeerId)
2843
+ return void 0;
2844
+ const peer = this.getPeerById(operation.status.targetPeerId);
2845
+ const transport = this.getPeerById(transportPeerId);
2846
+ return peer?.transportPeerId === transportPeerId && transport?.transportPeerId === transportPeerId ? operation : void 0;
2847
+ }
2848
+ observeTransportProgress(transportPeerId, requestId, phase, outcome) {
2849
+ const operation = this.ownedOperation(transportPeerId, requestId);
2850
+ if (!operation || operation.status.state === "settled" || operation.status.stage === "response_delivery" || phase === "executing" && operation.status.stage !== "dispatched")
2851
+ return;
2852
+ const now = Date.now();
2853
+ operation.status.stage = phase;
2854
+ if (phase === "executing")
2855
+ operation.status.executionStartedAt = now;
2856
+ else {
2857
+ operation.status.executionOutcome = outcome ?? "unknown";
2858
+ if (outcome !== "not_executed")
2859
+ operation.status.executionCompletedAt = now;
2860
+ }
2861
+ operation.updatedAt = now;
2862
+ this.operations.delete(requestId);
2863
+ this.operations.set(requestId, operation);
2864
+ }
2865
+ settleTransportResponse(transportPeerId, requestId, response, error, executionOutcome) {
2866
+ const operation = this.ownedOperation(transportPeerId, requestId);
2867
+ if (!operation)
2868
+ return "unknown";
2869
+ if (error !== void 0 && executionOutcome !== void 0) {
2870
+ error = new RequestFailure(typeof error === "string" ? error : "Studio response failed", "studio_response_error", {
2871
+ requestId,
2872
+ targetPeerId: operation.status.targetPeerId,
2873
+ ...observations(operation.status),
2874
+ executionCompletedAt: executionOutcome === "not_executed" ? void 0 : operation.status.executionCompletedAt ?? Date.now(),
2875
+ stage: "response_delivery",
2876
+ outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
2877
+ executionOutcome
2878
+ });
2879
+ }
2880
+ return this.recordResponse(requestId, response, error, executionOutcome);
2881
+ }
2882
+ /** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
2660
2883
  resolveRequest(requestId, response) {
2661
- return this.settleRequest(requestId, (request) => request.resolve(response));
2884
+ return this.recordResponse(requestId, response);
2662
2885
  }
2886
+ /** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
2663
2887
  rejectRequest(requestId, error) {
2664
- return this.settleRequest(requestId, (request) => request.reject(error));
2888
+ return this.recordResponse(requestId, void 0, error);
2665
2889
  }
2666
- settleRequest(requestId, settle) {
2890
+ recordResponse(requestId, response, error, executionOutcome) {
2667
2891
  const now = Date.now();
2668
- this.pruneAcceptedRequestIds(now);
2892
+ this.pruneOperations(now);
2893
+ const operation = this.operations.get(requestId);
2894
+ if (!operation)
2895
+ return "unknown";
2896
+ if (operation.status.state === "settled")
2897
+ return "already_settled";
2898
+ const hasError = error !== void 0;
2899
+ operation.status.state = "settled";
2900
+ const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
2901
+ const responseOutcome = handlerOutcome(response);
2902
+ const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2903
+ operation.status.executionOutcome = completedOutcome;
2904
+ operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2905
+ if (!localRejection) {
2906
+ operation.status.stage = "response_delivery";
2907
+ if (completedOutcome !== "not_executed")
2908
+ operation.status.executionCompletedAt ??= now;
2909
+ }
2910
+ operation.status.settledAt = now;
2911
+ operation.updatedAt = now;
2912
+ try {
2913
+ const recordedError = error instanceof Error ? { ...error, name: error.name, message: error.message } : error;
2914
+ const serialized = JSON.stringify(hasError ? { error: recordedError } : { response });
2915
+ const bytes = Buffer.byteLength(serialized);
2916
+ if (bytes > MAX_RETAINED_RESULT_BYTES) {
2917
+ operation.status.resultUnavailable = { reason: "size_limit", bytes, limitBytes: MAX_RETAINED_RESULT_BYTES };
2918
+ } else {
2919
+ operation.serializedResult = serialized;
2920
+ operation.resultBytes = bytes;
2921
+ this.retainedResultBytes += bytes;
2922
+ this.retainedResults.set(requestId, operation);
2923
+ }
2924
+ } catch {
2925
+ operation.status.resultUnavailable = { reason: "serialization_failed", limitBytes: MAX_RETAINED_RESULT_BYTES };
2926
+ }
2927
+ this.operations.delete(requestId);
2928
+ this.operations.set(requestId, operation);
2669
2929
  const request = this.pendingRequests.get(requestId);
2670
- if (!request)
2671
- return this.acceptedRequestIds.has(requestId) ? "already_settled" : "unknown";
2672
- const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2673
- this.removePendingRequest(request);
2674
- this.acceptedRequestIds.set(requestId, now);
2675
- this.pruneAcceptedRequestIds(now);
2676
- settle(request);
2677
- if (deliveryTransportPeerId)
2678
- this.notifyRequestAvailable(deliveryTransportPeerId);
2930
+ if (request && this.removePendingRequest(request)) {
2931
+ if (hasError)
2932
+ request.reject(error);
2933
+ else
2934
+ request.resolve(response);
2935
+ }
2936
+ this.pendingCancellations.delete(requestId);
2937
+ this.pruneOperations(now);
2938
+ if (operation.transportPeerId)
2939
+ this.notifyRequestAvailable(operation.transportPeerId);
2679
2940
  return "accepted";
2680
2941
  }
2681
- pruneAcceptedRequestIds(now) {
2682
- for (const [requestId, acceptedAt] of this.acceptedRequestIds) {
2683
- if (now - acceptedAt < ACCEPTED_REQUEST_TOMBSTONE_TTL_MS)
2942
+ getRequestStatus(requestId) {
2943
+ this.pruneOperations(Date.now());
2944
+ const operation = this.operations.get(requestId);
2945
+ if (!operation)
2946
+ return void 0;
2947
+ const status = { ...operation.status };
2948
+ if (status.resultUnavailable)
2949
+ status.resultUnavailable = { ...status.resultUnavailable };
2950
+ if (operation.serializedResult !== void 0) {
2951
+ const result = JSON.parse(operation.serializedResult);
2952
+ if (result && typeof result === "object") {
2953
+ if ("response" in result)
2954
+ status.response = result.response;
2955
+ if ("error" in result)
2956
+ status.error = result.error;
2957
+ }
2958
+ }
2959
+ return status;
2960
+ }
2961
+ async getRequestStatusEverywhere(requestId) {
2962
+ return this.getRequestStatus(requestId);
2963
+ }
2964
+ pruneOperations(now) {
2965
+ for (const [requestId, operation] of this.operations) {
2966
+ if (this.pendingRequests.has(requestId))
2967
+ continue;
2968
+ if (now - operation.updatedAt < OPERATION_RETENTION_MS)
2969
+ break;
2970
+ this.operations.delete(requestId);
2971
+ this.retainedResults.delete(requestId);
2972
+ this.retainedResultBytes -= operation.resultBytes;
2973
+ }
2974
+ while (this.operations.size > MAX_OPERATION_RECORDS) {
2975
+ let removed = false;
2976
+ for (const [requestId, operation] of this.operations) {
2977
+ if (this.pendingRequests.has(requestId))
2978
+ continue;
2979
+ this.operations.delete(requestId);
2980
+ this.retainedResults.delete(requestId);
2981
+ this.retainedResultBytes -= operation.resultBytes;
2982
+ removed = true;
2983
+ break;
2984
+ }
2985
+ if (!removed)
2684
2986
  break;
2685
- this.acceptedRequestIds.delete(requestId);
2686
2987
  }
2687
- while (this.acceptedRequestIds.size > MAX_ACCEPTED_REQUEST_TOMBSTONES) {
2688
- const oldestRequestId = this.acceptedRequestIds.keys().next().value;
2689
- if (oldestRequestId === void 0)
2988
+ if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
2989
+ return;
2990
+ for (const [requestId, operation] of this.retainedResults) {
2991
+ operation.status.resultUnavailable = {
2992
+ reason: "retention_capacity",
2993
+ bytes: operation.resultBytes,
2994
+ limitBytes: MAX_RETAINED_RESULT_BYTES
2995
+ };
2996
+ this.retainedResultBytes -= operation.resultBytes;
2997
+ operation.resultBytes = 0;
2998
+ operation.serializedResult = void 0;
2999
+ this.retainedResults.delete(requestId);
3000
+ if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
2690
3001
  break;
2691
- this.acceptedRequestIds.delete(oldestRequestId);
2692
3002
  }
2693
3003
  }
2694
3004
  prunePendingCancellations(now) {
@@ -2707,16 +3017,15 @@ var BridgeService = class {
2707
3017
  cleanupOldRequests() {
2708
3018
  const now = Date.now();
2709
3019
  for (const request of this.pendingRequests.values()) {
2710
- if (now - request.timestamp > request.timeoutMs && this.removePendingRequest(request)) {
2711
- this.notifyRequestCancelled(request, "timeout");
2712
- request.reject(new Error("Request timeout"));
3020
+ if (now - request.timestamp >= request.timeoutMs) {
3021
+ this.endRequestWaiter(request, "timed_out", "Request timeout");
2713
3022
  }
2714
3023
  }
3024
+ this.pruneOperations(now);
2715
3025
  }
2716
3026
  clearAllPendingRequests() {
2717
3027
  for (const request of Array.from(this.pendingRequests.values())) {
2718
- this.removePendingRequest(request);
2719
- request.reject(new Error("Connection closed"));
3028
+ this.endRequestWaiter(request, "disconnected", "Connection closed");
2720
3029
  }
2721
3030
  this.pendingCancellations.clear();
2722
3031
  }
@@ -2981,7 +3290,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
2981
3290
 
2982
3291
  - Use selection with action=get when the user's Studio selection should define the scope.
2983
3292
  - Use action=set with instance paths to replace, add to, or remove from the selection. An empty paths array in set mode clears it.
2984
- - Use action=view with a BasePart or Model path to frame it. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
3293
+ - Use action=view with a BasePart or Model path to frame it, or omit path to frame exactly one selected BasePart or Model. Empty, multiple, or unsupported selections require an explicit target or a changed selection. The original camera type is restored after framing, including on failure. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
2985
3294
  - For visual proof, change the instance, frame it with selection, then call capture_screenshot.
2986
3295
 
2987
3296
  ## Script changes
@@ -4471,6 +4780,21 @@ async function resolveStudioExeAsync() {
4471
4780
  }
4472
4781
  return candidates[0];
4473
4782
  }
4783
+ var WINDOWS_STUDIO_PROCESS_QUERY = [
4784
+ "$ErrorActionPreference = 'Stop'",
4785
+ // Get-Process reports a missing name as an error, not a successful empty set.
4786
+ '$studio = @(); try { $studio = @(Get-Process RobloxStudioBeta -ErrorAction Stop) } catch { if ($_.FullyQualifiedErrorId -notlike "NoProcessFoundForGivenName,*") { throw } }',
4787
+ "$processes = @($studio | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } })",
4788
+ "ConvertTo-Json -InputObject $processes -Compress"
4789
+ ].join("; ");
4790
+ function parseWindowsStudioProcesses(output) {
4791
+ const parsed = JSON.parse(output);
4792
+ const processes = Array.isArray(parsed) ? parsed : [parsed];
4793
+ if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && typeof value.Path === "string" && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
4794
+ throw new Error("Malformed Roblox Studio process enumeration result.");
4795
+ }
4796
+ return processes;
4797
+ }
4474
4798
  async function observeStudioProcesses() {
4475
4799
  const observedAt = Date.now();
4476
4800
  try {
@@ -4493,11 +4817,8 @@ async function observeStudioProcesses() {
4493
4817
  if (process.platform !== "win32" && !isWsl()) {
4494
4818
  return { status: "ok", observedAt, processes: [] };
4495
4819
  }
4496
- const out = await powershellAsync("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } } | ConvertTo-Json -Compress");
4497
- if (!out)
4498
- return { status: "ok", observedAt, processes: [] };
4499
- const parsed = JSON.parse(out);
4500
- return { status: "ok", observedAt, processes: Array.isArray(parsed) ? parsed : [parsed] };
4820
+ const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
4821
+ return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
4501
4822
  } catch (error) {
4502
4823
  return {
4503
4824
  status: "error",
@@ -5529,7 +5850,6 @@ var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
5529
5850
  "transportPeerId",
5530
5851
  "pluginVariant",
5531
5852
  "pluginVersion",
5532
- "requestId",
5533
5853
  "serverVersion"
5534
5854
  ]);
5535
5855
  var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
@@ -5651,8 +5971,11 @@ function publicToolErrorBody(name, error) {
5651
5971
  }
5652
5972
  if (error instanceof RoutingFailure)
5653
5973
  return publicRoutingError(error);
5974
+ if (error instanceof RequestFailure) {
5975
+ return { error: error.code, message: error.message.slice(0, 500), ...error.details };
5976
+ }
5654
5977
  console.error(`[tool:${name}]`, error);
5655
- const message = error instanceof Error ? error.message : "Tool execution failed.";
5978
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Tool execution failed.";
5656
5979
  return { error: "tool_failed", message: message.slice(0, 500) };
5657
5980
  }
5658
5981
  function concise(text, maxLength, firstSentence = false) {
@@ -5709,6 +6032,9 @@ function serverInstructions(definitions) {
5709
6032
  if (has("get_connected_instances")) {
5710
6033
  instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
5711
6034
  }
6035
+ if (has("get_request_status", "execute_luau", "set_properties")) {
6036
+ instructions.push("Supply a unique operation_id to execute_luau or set_properties when retry safety matters. After a timeout, query get_request_status with that ID before retrying. Identical arguments reuse a retained outcome; changed arguments are rejected. Recovery and deduplication are bounded to the current server session and five-minute retention window; result payloads may be evicted earlier. Unknown status does not mean unexecuted, and cancellation cannot roll back mutations.", "Request stages are queued, dispatched, executing (plugin handler entered), and response_delivery (handler returned or admission rejected). executionOutcome is separate from waiter state and delivery outcome; handler observations do not prove user Luau instructions ran. A waiter timeout is not an execution deadline or rollback. Neither missing progress nor connection loss proves completion.");
6037
+ }
5712
6038
  if (has("search_objects", "get_project_structure", "grep_scripts", "execute_luau")) {
5713
6039
  instructions.push("Use search_objects, get_project_structure, or grep_scripts for standard discovery. Use execute_luau for custom traversal or bulk edits.");
5714
6040
  }
@@ -5771,7 +6097,7 @@ function createToolHttpHandler(options) {
5771
6097
  }
5772
6098
 
5773
6099
  // ../core/dist/auth.js
5774
- import { randomBytes, createHash, timingSafeEqual } from "crypto";
6100
+ import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
5775
6101
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, chmodSync } from "fs";
5776
6102
  import { join as join3, dirname as dirname2 } from "path";
5777
6103
  import { homedir as homedir3 } from "os";
@@ -5809,192 +6135,308 @@ function resolveAuthToken() {
5809
6135
  }
5810
6136
  }
5811
6137
  function tokensMatch(provided, expected) {
5812
- const a = createHash("sha256").update(provided).digest();
5813
- const b = createHash("sha256").update(expected).digest();
6138
+ const a = createHash2("sha256").update(provided).digest();
6139
+ const b = createHash2("sha256").update(expected).digest();
5814
6140
  return timingSafeEqual(a, b);
5815
6141
  }
5816
6142
 
6143
+ // ../core/dist/http-body-limits.js
6144
+ var HTTP_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
6145
+
5817
6146
  // ../core/dist/studio-transport.js
5818
6147
  var HEARTBEAT_INTERVAL_MS = 1e4;
5819
- var MAX_ACTIVE_EVENT_STREAMS = 64;
5820
- var SseStudioTransport = class {
6148
+ var MAX_PENDING_ACKS = 128;
6149
+ var STUDIO_PROTOCOL_VERSION = 1;
6150
+ var MAX_ACTIVE_STUDIO_SOCKETS = 64;
6151
+ var MAX_STUDIO_FRAME_BYTES = 64 * 1024 * 1024;
6152
+ var MAX_STUDIO_BUFFERED_BYTES = MAX_STUDIO_FRAME_BYTES + 14;
6153
+ var WebSocketStudioTransport = class {
5821
6154
  queue;
5822
- streams = /* @__PURE__ */ new Map();
6155
+ sockets = /* @__PURE__ */ new Map();
6156
+ closing = /* @__PURE__ */ new Set();
5823
6157
  unsubscribeRequestAvailable;
5824
6158
  unsubscribePeerClosed;
5825
6159
  nextGeneration = 0;
6160
+ closed = false;
5826
6161
  constructor(queue) {
5827
6162
  this.queue = queue;
5828
6163
  this.unsubscribeRequestAvailable = queue.onRequestAvailable((transportPeerId) => {
5829
- const stream = this.streams.get(transportPeerId);
5830
- if (stream)
5831
- this.pump(stream);
6164
+ const connection = this.sockets.get(transportPeerId);
6165
+ if (connection)
6166
+ this.pump(connection);
5832
6167
  });
5833
- this.unsubscribePeerClosed = queue.onPeerClosed((route) => {
5834
- if (route.peerId === route.transportPeerId) {
5835
- this.closeTransport(route.transportPeerId);
5836
- }
6168
+ this.unsubscribePeerClosed = queue.onPeerClosed((peer) => {
6169
+ if (peer.peerId === peer.transportPeerId)
6170
+ this.closeTransport(peer.transportPeerId);
5837
6171
  });
5838
6172
  }
5839
- get activeStreamCount() {
5840
- return this.streams.size;
6173
+ get activeSocketCount() {
6174
+ return this.sockets.size;
5841
6175
  }
5842
6176
  canOpen(transportPeerId) {
5843
- return this.streams.has(transportPeerId) || this.streams.size < MAX_ACTIVE_EVENT_STREAMS;
6177
+ return !this.closed && (this.sockets.has(transportPeerId) || this.sockets.size < MAX_ACTIVE_STUDIO_SOCKETS);
5844
6178
  }
5845
- open(transportPeerId, sink, status) {
5846
- if (!this.canOpen(transportPeerId))
6179
+ open(transportPeerId, socket, status) {
6180
+ if (!this.canOpen(transportPeerId) || socket.readyState !== 1)
5847
6181
  return void 0;
5848
6182
  this.nextGeneration += 1;
5849
- const claimOwner = `sse:${transportPeerId}:${this.nextGeneration}`;
5850
- const stream = {
6183
+ const claimOwner = `ws:${transportPeerId}:${this.nextGeneration}`;
6184
+ const connection = {
5851
6185
  transportPeerId,
5852
6186
  claimOwner,
5853
- sink,
6187
+ socket,
5854
6188
  status,
5855
6189
  closed: false,
5856
- blocked: false,
6190
+ sending: false,
6191
+ pumping: false,
6192
+ settling: false,
5857
6193
  statusPending: true,
5858
- onClose: () => this.closeStream(stream),
5859
- onDrain: () => {
5860
- if (stream.closed)
5861
- return;
5862
- stream.blocked = false;
5863
- this.pump(stream);
5864
- }
6194
+ heartbeatPending: false,
6195
+ acknowledgements: /* @__PURE__ */ new Map(),
6196
+ onClose: () => {
6197
+ this.closeSocket(connection);
6198
+ this.finishClose(connection);
6199
+ },
6200
+ onError: () => this.closeSocket(connection, 1011, "socket_error"),
6201
+ onMessage: (data, isBinary) => this.receive(connection, data, isBinary)
5865
6202
  };
5866
6203
  this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
5867
- this.queue.updatePeerActivity(transportPeerId);
5868
- const replaced = this.streams.get(transportPeerId);
6204
+ const replaced = this.sockets.get(transportPeerId);
5869
6205
  if (replaced)
5870
- this.closeStream(replaced, true);
5871
- this.streams.set(transportPeerId, stream);
5872
- sink.on("close", stream.onClose);
5873
- sink.on("error", stream.onClose);
5874
- sink.on("drain", stream.onDrain);
5875
- stream.heartbeatTimer = setInterval(() => {
5876
- if (!stream.closed && !stream.blocked) {
5877
- this.queue.updatePeerActivity(transportPeerId);
5878
- stream.statusPending = true;
5879
- this.pump(stream);
5880
- if (!stream.blocked) {
5881
- this.write(stream, { kind: "heartbeat", timestamp: Date.now() });
5882
- }
5883
- }
6206
+ this.closeSocket(replaced, 1012, "transport_replaced");
6207
+ this.sockets.set(transportPeerId, connection);
6208
+ this.queue.updatePeerActivity(transportPeerId);
6209
+ socket.on("close", connection.onClose);
6210
+ socket.on("error", connection.onError);
6211
+ socket.on("message", connection.onMessage);
6212
+ connection.heartbeatTimer = setInterval(() => {
6213
+ if (!this.isCurrent(connection))
6214
+ return;
6215
+ this.queue.updatePeerActivity(transportPeerId);
6216
+ connection.statusPending = true;
6217
+ connection.heartbeatPending = true;
6218
+ this.pump(connection);
5884
6219
  }, HEARTBEAT_INTERVAL_MS);
5885
- stream.heartbeatTimer.unref();
5886
- this.pump(stream);
5887
- return {
5888
- transportPeerId,
5889
- close: () => this.closeStream(stream, true)
5890
- };
6220
+ connection.heartbeatTimer.unref();
6221
+ this.pump(connection);
6222
+ return { transportPeerId, close: () => this.closeSocket(connection, 1e3, "transport_closed") };
5891
6223
  }
5892
6224
  refreshStatus(transportPeerId) {
5893
6225
  if (transportPeerId !== void 0) {
5894
- const stream = this.streams.get(transportPeerId);
5895
- if (stream) {
5896
- stream.lastStatusJson = void 0;
5897
- stream.statusPending = true;
5898
- this.pump(stream);
6226
+ const connection = this.sockets.get(transportPeerId);
6227
+ if (connection) {
6228
+ connection.statusPending = true;
6229
+ this.pump(connection);
5899
6230
  }
5900
6231
  return;
5901
6232
  }
5902
- for (const stream of this.streams.values()) {
5903
- stream.lastStatusJson = void 0;
5904
- stream.statusPending = true;
5905
- this.pump(stream);
6233
+ for (const connection of this.sockets.values()) {
6234
+ connection.statusPending = true;
6235
+ this.pump(connection);
5906
6236
  }
5907
6237
  }
5908
6238
  closeTransport(transportPeerId) {
5909
- const stream = this.streams.get(transportPeerId);
5910
- if (stream)
5911
- this.closeStream(stream, true);
6239
+ const connection = this.sockets.get(transportPeerId);
6240
+ if (connection)
6241
+ this.closeSocket(connection, 1e3, "peer_unregistered");
5912
6242
  }
5913
6243
  close() {
5914
- for (const stream of Array.from(this.streams.values())) {
5915
- this.closeStream(stream, true);
5916
- }
6244
+ this.closed = true;
6245
+ for (const connection of this.sockets.values())
6246
+ this.closeSocket(connection, 1001, "server_shutdown");
5917
6247
  this.unsubscribeRequestAvailable();
5918
6248
  this.unsubscribePeerClosed();
5919
6249
  }
5920
- pump(stream) {
5921
- if (stream.closed || stream.blocked || this.streams.get(stream.transportPeerId) !== stream)
6250
+ isCurrent(connection) {
6251
+ return !connection.closed && this.sockets.get(connection.transportPeerId) === connection;
6252
+ }
6253
+ receive(connection, data, isBinary) {
6254
+ if (!this.isCurrent(connection))
6255
+ return;
6256
+ if (isBinary) {
6257
+ this.closeSocket(connection, 1003, "text_frames_required");
5922
6258
  return;
5923
- if (stream.statusPending) {
5924
- stream.statusPending = false;
5925
- let status;
5926
- try {
5927
- status = stream.status();
5928
- } catch {
5929
- this.closeStream(stream);
5930
- return;
5931
- }
5932
- const statusJson = JSON.stringify(status);
5933
- if (statusJson !== stream.lastStatusJson) {
5934
- stream.lastStatusJson = statusJson;
5935
- if (!this.write(stream, status))
5936
- return;
5937
- }
5938
6259
  }
5939
- while (!stream.closed && !stream.blocked) {
5940
- const cancellation = this.queue.claimNextCancellationForTransport(stream.transportPeerId, stream.claimOwner);
5941
- if (!cancellation)
5942
- break;
5943
- if (!this.write(stream, { kind: "cancel", ...cancellation }))
6260
+ const bytes = Array.isArray(data) ? data.reduce((total, chunk) => total + chunk.byteLength, 0) : data.byteLength;
6261
+ if (bytes > MAX_STUDIO_FRAME_BYTES) {
6262
+ this.closeSocket(connection, 1009, `server_receive bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
6263
+ return;
6264
+ }
6265
+ let message;
6266
+ try {
6267
+ const buffer = Array.isArray(data) ? Buffer.concat(data, bytes) : data instanceof ArrayBuffer ? Buffer.from(data) : data;
6268
+ message = JSON.parse(buffer.toString("utf8"));
6269
+ } catch {
6270
+ this.closeSocket(connection, 1007, "invalid_json");
6271
+ return;
6272
+ }
6273
+ if (message === null || typeof message !== "object" || Array.isArray(message) || !("kind" in message) || message.kind !== "response" && message.kind !== "progress" || !("requestId" in message) || typeof message.requestId !== "string" || message.requestId.length === 0 || message.requestId.length > 1024) {
6274
+ this.closeSocket(connection, 1008, "invalid_response");
6275
+ return;
6276
+ }
6277
+ if (message.kind === "progress") {
6278
+ const outcome = "outcome" in message ? message.outcome : void 0;
6279
+ if (!("phase" in message) || message.phase !== "executing" && message.phase !== "response_delivery" || outcome !== void 0 && !isExecutionOutcome(outcome) || message.phase === "executing" && "outcome" in message)
5944
6280
  return;
6281
+ this.queue.observeTransportProgress(connection.transportPeerId, message.requestId, message.phase, outcome);
6282
+ this.queue.updatePeerActivity(connection.transportPeerId);
6283
+ return;
5945
6284
  }
5946
- while (!stream.closed && !stream.blocked) {
5947
- const request = this.queue.claimNextRequestForTransport(stream.transportPeerId, stream.claimOwner);
5948
- if (!request)
6285
+ const executionOutcome = "executionOutcome" in message ? message.executionOutcome : void 0;
6286
+ if (executionOutcome !== void 0 && !isExecutionOutcome(executionOutcome))
6287
+ return;
6288
+ const response = "response" in message ? message.response : void 0;
6289
+ const error = "error" in message ? message.error : void 0;
6290
+ connection.settling = true;
6291
+ try {
6292
+ const disposition = this.queue.settleTransportResponse(connection.transportPeerId, message.requestId, response, error, executionOutcome);
6293
+ if (!this.isCurrent(connection))
5949
6294
  return;
5950
- const event = {
5951
- kind: "request",
5952
- requestId: request.requestId,
5953
- peerId: request.peerId,
5954
- target: request.target,
5955
- endpoint: request.endpoint,
5956
- data: request.data === void 0 ? null : request.data,
5957
- remainingMs: request.remainingMs
5958
- };
5959
- if (!this.write(stream, event))
6295
+ if (!connection.acknowledgements.has(message.requestId) && connection.acknowledgements.size >= MAX_PENDING_ACKS) {
6296
+ this.closeSocket(connection, 1013, "ack_backpressure");
5960
6297
  return;
6298
+ }
6299
+ connection.acknowledgements.set(message.requestId, {
6300
+ kind: "ack",
6301
+ requestId: message.requestId,
6302
+ disposition
6303
+ });
6304
+ this.queue.updatePeerActivity(connection.transportPeerId);
6305
+ } catch {
6306
+ this.closeSocket(connection, 1011, "response_recording_failed");
6307
+ } finally {
6308
+ connection.settling = false;
5961
6309
  }
6310
+ this.pump(connection);
5962
6311
  }
5963
- write(stream, event) {
5964
- if (stream.closed)
5965
- return false;
6312
+ pump(connection) {
6313
+ if (!this.isCurrent(connection) || connection.sending || connection.pumping || connection.settling)
6314
+ return;
6315
+ connection.pumping = true;
5966
6316
  try {
5967
- const writable = stream.sink.write(`data: ${JSON.stringify(event)}
5968
-
5969
- `);
5970
- if (!writable)
5971
- stream.blocked = true;
5972
- return writable;
6317
+ while (this.isCurrent(connection) && !connection.sending) {
6318
+ const ack = connection.acknowledgements.values().next().value;
6319
+ if (ack) {
6320
+ connection.acknowledgements.delete(ack.requestId);
6321
+ this.send(connection, ack);
6322
+ continue;
6323
+ }
6324
+ if (connection.statusPending) {
6325
+ connection.statusPending = false;
6326
+ const status = connection.status();
6327
+ const statusJson = JSON.stringify(status);
6328
+ if (statusJson !== connection.lastStatusJson) {
6329
+ connection.lastStatusJson = statusJson;
6330
+ this.send(connection, status, statusJson);
6331
+ continue;
6332
+ }
6333
+ }
6334
+ const cancellation = this.queue.claimNextCancellationForTransport(connection.transportPeerId, connection.claimOwner);
6335
+ if (cancellation) {
6336
+ this.send(connection, { kind: "cancel", ...cancellation });
6337
+ continue;
6338
+ }
6339
+ const request = this.queue.claimNextRequestForTransport(connection.transportPeerId, connection.claimOwner);
6340
+ if (request) {
6341
+ this.send(connection, { kind: "request", ...request, data: request.data ?? null });
6342
+ continue;
6343
+ }
6344
+ if (connection.heartbeatPending) {
6345
+ connection.heartbeatPending = false;
6346
+ this.send(connection, { kind: "heartbeat", timestamp: Date.now() });
6347
+ continue;
6348
+ }
6349
+ return;
6350
+ }
5973
6351
  } catch {
5974
- this.closeStream(stream);
5975
- return false;
6352
+ this.closeSocket(connection, 1011, "transport_pump_failed");
6353
+ } finally {
6354
+ connection.pumping = false;
5976
6355
  }
5977
6356
  }
5978
- closeStream(stream, endSink = false) {
5979
- if (stream.closed)
6357
+ send(connection, event, serialized) {
6358
+ let json;
6359
+ try {
6360
+ json = serialized ?? JSON.stringify(event);
6361
+ } catch {
6362
+ if (event.kind !== "request")
6363
+ throw new Error("Unserializable Studio event");
6364
+ this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure("Studio request could not be serialized before WebSocket delivery at server_send", "studio_frame_serialization_failed", {
6365
+ requestId: event.requestId,
6366
+ targetPeerId: event.peerId,
6367
+ stage: "dispatched",
6368
+ outcome: "not_executed",
6369
+ executionOutcome: "not_executed",
6370
+ transportStage: "server_send"
6371
+ }));
5980
6372
  return;
5981
- stream.closed = true;
5982
- clearInterval(stream.heartbeatTimer);
5983
- stream.sink.removeListener("close", stream.onClose);
5984
- stream.sink.removeListener("error", stream.onClose);
5985
- stream.sink.removeListener("drain", stream.onDrain);
5986
- if (this.streams.get(stream.transportPeerId) === stream) {
5987
- this.streams.delete(stream.transportPeerId);
5988
- }
5989
- this.queue.updatePeerActivity(stream.transportPeerId);
5990
- this.queue.setDeliveryActive(stream.transportPeerId, stream.claimOwner, false);
5991
- this.queue.releaseDeliveryClaims(stream.claimOwner);
5992
- if (endSink) {
5993
- try {
5994
- stream.sink.end();
5995
- } catch {
6373
+ }
6374
+ const bytes = Buffer.byteLength(json);
6375
+ if (bytes > MAX_STUDIO_FRAME_BYTES) {
6376
+ if (event.kind === "request") {
6377
+ this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure(`Studio request frame is ${bytes} bytes; limit is ${MAX_STUDIO_FRAME_BYTES} at server_send`, "studio_frame_too_large", {
6378
+ requestId: event.requestId,
6379
+ targetPeerId: event.peerId,
6380
+ stage: "dispatched",
6381
+ outcome: "not_executed",
6382
+ executionOutcome: "not_executed",
6383
+ transportStage: "server_send",
6384
+ bytes,
6385
+ limitBytes: MAX_STUDIO_FRAME_BYTES
6386
+ }));
6387
+ } else {
6388
+ this.closeSocket(connection, 1009, `server_send bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
5996
6389
  }
6390
+ return;
6391
+ }
6392
+ if (connection.socket.readyState !== 1 || connection.socket.bufferedAmount + bytes > MAX_STUDIO_BUFFERED_BYTES) {
6393
+ this.closeSocket(connection, 1013, "server_send_backpressure");
6394
+ return;
5997
6395
  }
6396
+ connection.sending = true;
6397
+ connection.socket.send(json, (error) => {
6398
+ if (!this.isCurrent(connection))
6399
+ return;
6400
+ connection.sending = false;
6401
+ if (error)
6402
+ this.closeSocket(connection, 1011, "server_send_failed");
6403
+ else
6404
+ this.pump(connection);
6405
+ });
6406
+ }
6407
+ closeSocket(connection, code, reason) {
6408
+ if (connection.closed)
6409
+ return;
6410
+ connection.closed = true;
6411
+ clearInterval(connection.heartbeatTimer);
6412
+ connection.socket.removeListener("message", connection.onMessage);
6413
+ connection.acknowledgements.clear();
6414
+ if (this.sockets.get(connection.transportPeerId) === connection)
6415
+ this.sockets.delete(connection.transportPeerId);
6416
+ this.queue.setDeliveryActive(connection.transportPeerId, connection.claimOwner, false);
6417
+ this.queue.releaseDeliveryClaims(connection.claimOwner);
6418
+ if (code !== void 0) {
6419
+ this.closing.add(connection);
6420
+ connection.closeTimer = setTimeout(() => {
6421
+ connection.socket.terminate();
6422
+ this.finishClose(connection);
6423
+ }, 1e3);
6424
+ connection.closeTimer.unref();
6425
+ connection.socket.close(code, reason);
6426
+ if (this.closing.size > MAX_ACTIVE_STUDIO_SOCKETS) {
6427
+ const oldest = this.closing.values().next().value;
6428
+ if (oldest) {
6429
+ oldest.socket.terminate();
6430
+ this.finishClose(oldest);
6431
+ }
6432
+ }
6433
+ }
6434
+ }
6435
+ finishClose(connection) {
6436
+ clearTimeout(connection.closeTimer);
6437
+ this.closing.delete(connection);
6438
+ connection.socket.removeListener("close", connection.onClose);
6439
+ connection.socket.removeListener("error", connection.onError);
5998
6440
  }
5999
6441
  };
6000
6442
 
@@ -6074,13 +6516,14 @@ function requiredClosedLineRange(body, toolName) {
6074
6516
  return { startLine: parsed.startLine, endLine: parsed.endLine };
6075
6517
  }
6076
6518
  var TOOL_HANDLERS = {
6519
+ get_request_status: (tools, body) => tools.getRequestStatus(body.request_id),
6077
6520
  get_roblox_skills: (tools, body) => tools.getRobloxSkills(body.action, body.name),
6078
6521
  get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
6079
6522
  get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
6080
6523
  search_objects: (tools, body) => tools.searchObjects(body.query, body.searchType, body.propertyName, body.instance_id),
6081
6524
  get_instance_properties: (tools, body) => tools.getInstanceProperties(body.instancePath, body.excludeSource, body.instance_id),
6082
6525
  get_project_structure: (tools, body) => tools.getProjectStructure(body.path, body.maxDepth, body.scriptsOnly, body.instance_id),
6083
- set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id),
6526
+ set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id, body.operation_id),
6084
6527
  grep_scripts: (tools, body, context) => tools.grepScripts(body.pattern, {
6085
6528
  caseSensitive: body.caseSensitive,
6086
6529
  usePattern: body.usePattern,
@@ -6104,7 +6547,7 @@ var TOOL_HANDLERS = {
6104
6547
  },
6105
6548
  get_attributes: (tools, body) => tools.getAttributes(body.instancePath, body.instance_id),
6106
6549
  selection: (tools, body) => tools.selection(body.action, body, body.instance_id),
6107
- execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
6550
+ execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id, body.operation_id),
6108
6551
  eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
6109
6552
  eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
6110
6553
  set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
@@ -6172,6 +6615,16 @@ var TOOL_HANDLERS = {
6172
6615
  maxReplacements: body.maxReplacements
6173
6616
  }, body.instance_id)
6174
6617
  };
6618
+ var MAX_STUDIO_SESSIONS = 256;
6619
+ function rejectStudioUpgrade(socket, status, error) {
6620
+ const body = JSON.stringify({ error });
6621
+ socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r
6622
+ Connection: close\r
6623
+ Content-Type: application/json\r
6624
+ Content-Length: ${Buffer.byteLength(body)}\r
6625
+ \r
6626
+ ${body}`);
6627
+ }
6175
6628
  function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6176
6629
  const app = express();
6177
6630
  const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
@@ -6181,8 +6634,19 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6181
6634
  let mcpServerStartTime = 0;
6182
6635
  const proxyInstances = /* @__PURE__ */ new Set();
6183
6636
  const rejectedVersionPeers = /* @__PURE__ */ new Set();
6184
- const eventTransport = new SseStudioTransport(bridge);
6185
- const eventStreamHandles = /* @__PURE__ */ new Set();
6637
+ const studioTransport = new WebSocketStudioTransport(bridge);
6638
+ const transportTokens = /* @__PURE__ */ new Map();
6639
+ const boundServers = /* @__PURE__ */ new Set();
6640
+ const webSocketServer = new WebSocketServer({
6641
+ noServer: true,
6642
+ clientTracking: false,
6643
+ perMessageDeflate: false,
6644
+ maxPayload: MAX_STUDIO_FRAME_BYTES
6645
+ });
6646
+ let closed = false;
6647
+ const unsubscribePeerClosed = bridge.onPeerClosed((peer) => {
6648
+ transportTokens.delete(peer.peerId);
6649
+ });
6186
6650
  const setMCPServerActive = (active) => {
6187
6651
  mcpServerActive = active;
6188
6652
  if (active) {
@@ -6192,14 +6656,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6192
6656
  mcpServerStartTime = 0;
6193
6657
  lastMCPActivity = 0;
6194
6658
  }
6195
- eventTransport.refreshStatus();
6659
+ studioTransport.refreshStatus();
6196
6660
  };
6197
6661
  const trackMCPActivity = () => {
6198
6662
  if (mcpServerActive) {
6199
6663
  const wasConnected = Date.now() - lastMCPActivity < 3e4;
6200
6664
  lastMCPActivity = Date.now();
6201
6665
  if (!wasConnected)
6202
- eventTransport.refreshStatus();
6666
+ studioTransport.refreshStatus();
6203
6667
  }
6204
6668
  };
6205
6669
  const isMCPServerActive = () => {
@@ -6223,6 +6687,67 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6223
6687
  return bridge.getPeers().length > 0;
6224
6688
  };
6225
6689
  const allowedOrigins = new Set(security?.allowedOrigins ?? []);
6690
+ const upgradeStudio = (req, socket, head) => {
6691
+ socket.on("error", () => socket.destroy());
6692
+ if (closed) {
6693
+ rejectStudioUpgrade(socket, 503, "server_shutdown");
6694
+ return;
6695
+ }
6696
+ let url;
6697
+ try {
6698
+ url = new URL(req.url ?? "/", "http://localhost");
6699
+ } catch {
6700
+ rejectStudioUpgrade(socket, 400, "invalid_websocket_url");
6701
+ return;
6702
+ }
6703
+ if (url.pathname !== "/studio") {
6704
+ rejectStudioUpgrade(socket, 404, "unknown_websocket_endpoint");
6705
+ return;
6706
+ }
6707
+ const origin = req.headers.origin;
6708
+ if (origin && !allowedOrigins.has(origin)) {
6709
+ rejectStudioUpgrade(socket, 403, "forbidden_origin");
6710
+ return;
6711
+ }
6712
+ if (req.method !== "GET" || url.searchParams.getAll("protocolVersion").length !== 1 || url.searchParams.get("protocolVersion") !== String(STUDIO_PROTOCOL_VERSION)) {
6713
+ rejectStudioUpgrade(socket, 426, "studio_protocol_mismatch");
6714
+ return;
6715
+ }
6716
+ const peerId = url.searchParams.get("peerId");
6717
+ if (!peerId || url.searchParams.getAll("peerId").length !== 1) {
6718
+ rejectStudioUpgrade(socket, 400, "missing_peer_id");
6719
+ return;
6720
+ }
6721
+ const peer = bridge.getPeerById(peerId);
6722
+ if (!peer) {
6723
+ rejectStudioUpgrade(socket, 404, "unknown_peer");
6724
+ return;
6725
+ }
6726
+ if (peer.transportPeerId !== peerId) {
6727
+ rejectStudioUpgrade(socket, 403, "peer_has_no_socket");
6728
+ return;
6729
+ }
6730
+ const token = transportTokens.get(peerId);
6731
+ const provided = req.headers["x-studio-token"];
6732
+ if (!token || typeof provided !== "string" || !tokensMatch(provided, token)) {
6733
+ rejectStudioUpgrade(socket, 401, "invalid_studio_token");
6734
+ return;
6735
+ }
6736
+ if (!studioTransport.canOpen(peerId)) {
6737
+ rejectStudioUpgrade(socket, 503, "studio_socket_capacity_reached");
6738
+ return;
6739
+ }
6740
+ webSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
6741
+ webSocket.on("error", (error) => {
6742
+ if ("code" in error && error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
6743
+ console.error(`[studio-websocket] server_receive frame exceeds limitBytes=${MAX_STUDIO_FRAME_BYTES}`);
6744
+ }
6745
+ });
6746
+ const handle = studioTransport.open(peerId, webSocket, () => eventStatus(peerId));
6747
+ if (!handle)
6748
+ webSocket.close(1013, "studio_socket_capacity_reached");
6749
+ });
6750
+ };
6226
6751
  app.use((req, res, next) => {
6227
6752
  const origin = req.headers.origin;
6228
6753
  if (typeof origin !== "string" || origin === "") {
@@ -6247,7 +6772,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6247
6772
  next();
6248
6773
  });
6249
6774
  const authToken = security?.authToken;
6250
- const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6775
+ const authRequired = (requestPath) => {
6776
+ const path6 = requestPath.toLowerCase().replace(/\/+$/, "");
6777
+ return path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/request-status" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6778
+ };
6251
6779
  app.use((req, res, next) => {
6252
6780
  if (!authToken || !authRequired(req.path)) {
6253
6781
  next();
@@ -6265,8 +6793,31 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6265
6793
  message: 'Missing or invalid auth token. Send it as "X-MCP-Auth: <token>" or "Authorization: Bearer <token>". ' + (security?.authTokenHint ?? "The token is in ~/.robloxstudio-mcp/auth-token (or ROBLOX_STUDIO_AUTH_TOKEN).")
6266
6794
  });
6267
6795
  });
6268
- app.use(express.json({ limit: "50mb" }));
6269
- app.use(express.urlencoded({ limit: "50mb", extended: true }));
6796
+ app.use(express.json({ limit: HTTP_BODY_LIMIT_BYTES }));
6797
+ app.use(express.urlencoded({ limit: HTTP_BODY_LIMIT_BYTES, extended: true }));
6798
+ const handleBodySizeError = (error, _req, res, next) => {
6799
+ if (!error || typeof error !== "object" || !("type" in error) || error.type !== "entity.too.large") {
6800
+ next(error);
6801
+ return;
6802
+ }
6803
+ const bytes = "received" in error && typeof error.received === "number" ? error.received : "length" in error && typeof error.length === "number" ? error.length : void 0;
6804
+ if (bytes === void 0) {
6805
+ next(error);
6806
+ return;
6807
+ }
6808
+ res.status(413).json({
6809
+ error: `HTTP request body is ${bytes} bytes at http_receive; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`,
6810
+ code: "request_too_large",
6811
+ details: {
6812
+ bytes,
6813
+ limitBytes: HTTP_BODY_LIMIT_BYTES,
6814
+ stage: "queued",
6815
+ outcome: "not_executed",
6816
+ transportStage: "http_receive"
6817
+ }
6818
+ });
6819
+ };
6820
+ app.use(handleBodySizeError);
6270
6821
  app.get("/health", (req, res) => {
6271
6822
  const peers = bridge.getPublicPeers().map(toPassivePeer);
6272
6823
  const instances = bridge.getPublicInstances().map(toPassiveInstance);
@@ -6296,7 +6847,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6296
6847
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
6297
6848
  pendingRequests: bridge.getPendingRequestCount(),
6298
6849
  proxyInstanceCount: proxyInstances.size,
6299
- activeEventStreams: eventTransport.activeStreamCount,
6850
+ activeWebSockets: studioTransport.activeSocketCount,
6851
+ studioSocketCapacity: MAX_ACTIVE_STUDIO_SOCKETS,
6300
6852
  streamableHttp: !!serverConfig
6301
6853
  });
6302
6854
  });
@@ -6401,6 +6953,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6401
6953
  return;
6402
6954
  }
6403
6955
  }
6956
+ if (closed || !isProxiedPeer && !transportTokens.has(peerId) && transportTokens.size >= MAX_STUDIO_SESSIONS) {
6957
+ res.status(503).json({ success: false, error: closed ? "server_shutdown" : "studio_session_capacity_reached" });
6958
+ return;
6959
+ }
6404
6960
  let result;
6405
6961
  try {
6406
6962
  result = bridge.registerPeer({
@@ -6437,14 +6993,20 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6437
6993
  });
6438
6994
  return;
6439
6995
  }
6440
- eventTransport.refreshStatus(transportPeerId);
6996
+ let transportToken;
6997
+ if (!isProxiedPeer) {
6998
+ transportToken = transportTokens.get(peerId) ?? randomBytes2(32).toString("hex");
6999
+ transportTokens.set(peerId, transportToken);
7000
+ }
7001
+ studioTransport.refreshStatus(transportPeerId);
6441
7002
  res.json({
6442
7003
  success: true,
6443
7004
  assignedRole: result.assignedRole,
6444
7005
  peerId: result.peerId,
6445
7006
  instanceId: result.instanceId,
6446
7007
  multiplayerGroupId: result.multiplayerGroupId,
6447
- serverVersion
7008
+ serverVersion,
7009
+ ...!isProxiedPeer ? { protocolVersion: STUDIO_PROTOCOL_VERSION, transportToken } : {}
6448
7010
  });
6449
7011
  });
6450
7012
  app.post("/disconnect", (req, res) => {
@@ -6504,71 +7066,24 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6504
7066
  serverVersion: serverConfig?.version
6505
7067
  });
6506
7068
  });
6507
- app.get("/events", (req, res) => {
6508
- const peerId = typeof req.query.peerId === "string" ? req.query.peerId : void 0;
6509
- if (!peerId) {
6510
- res.status(400).json({
6511
- error: "missing_peer_id",
6512
- message: "peerId is required"
6513
- });
6514
- return;
6515
- }
6516
- const peer = bridge.getPeerById(peerId);
6517
- if (!peer) {
6518
- res.status(404).json({
6519
- error: "unknown_peer",
6520
- knownPeer: false
6521
- });
6522
- return;
6523
- }
6524
- if (peer.transportPeerId !== peerId) {
6525
- res.status(409).json({
6526
- error: "peer_has_no_event_stream",
6527
- transportPeerId: peer.transportPeerId
6528
- });
6529
- return;
6530
- }
6531
- if (!eventTransport.canOpen(peerId)) {
6532
- res.setHeader("Retry-After", "1");
6533
- res.status(503).json({
6534
- error: "event_stream_capacity_reached",
6535
- capacity: MAX_ACTIVE_EVENT_STREAMS
6536
- });
6537
- return;
6538
- }
6539
- bridge.updatePeerActivity(peerId);
6540
- res.status(200);
6541
- res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
6542
- res.setHeader("Cache-Control", "no-cache, no-transform");
6543
- res.setHeader("Connection", "keep-alive");
6544
- res.setHeader("X-Accel-Buffering", "no");
6545
- res.flushHeaders();
6546
- const handle = eventTransport.open(peerId, res, () => eventStatus(peerId));
6547
- if (!handle) {
6548
- res.end();
6549
- return;
6550
- }
6551
- eventStreamHandles.add(handle);
6552
- res.once("close", () => eventStreamHandles.delete(handle));
7069
+ app.get(["/studio", "/events"], (_req, res) => {
7070
+ res.setHeader("Upgrade", "websocket");
7071
+ res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
6553
7072
  });
6554
- app.post("/response", (req, res) => {
6555
- const { requestId, response, error } = req.body;
6556
- if (typeof requestId !== "string" || requestId.length === 0) {
6557
- res.status(400).json({
6558
- success: false,
6559
- error: "invalid_request_id"
6560
- });
6561
- return;
6562
- }
6563
- const disposition = error !== void 0 ? bridge.rejectRequest(requestId, error) : bridge.resolveRequest(requestId, response);
6564
- if (disposition === "unknown") {
6565
- res.status(404).json({ success: false, disposition });
7073
+ app.post("/response", (_req, res) => {
7074
+ res.setHeader("Upgrade", "websocket");
7075
+ res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
7076
+ });
7077
+ app.get("/request-status", (req, res) => {
7078
+ const requestId = req.query.requestId;
7079
+ if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 1024) {
7080
+ res.status(400).json({ error: "invalid_request_id" });
6566
7081
  return;
6567
7082
  }
6568
- res.json({ success: true, disposition });
7083
+ res.json({ status: bridge.getRequestStatus(requestId) ?? null });
6569
7084
  });
6570
7085
  app.post("/proxy", async (req, res) => {
6571
- const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs } = req.body;
7086
+ const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs, operationId } = req.body;
6572
7087
  if (!endpoint || !targetPeerId) {
6573
7088
  res.status(400).json({ error: "endpoint and targetPeerId are required" });
6574
7089
  return;
@@ -6580,17 +7095,22 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6580
7095
  if (proxyInstanceId) {
6581
7096
  proxyInstances.add(proxyInstanceId);
6582
7097
  }
7098
+ if (operationId !== void 0 && (typeof operationId !== "string" || operationId.trim().length === 0 || operationId.length > 128)) {
7099
+ res.status(400).json({ error: "operationId must be a non-empty string of at most 128 characters" });
7100
+ return;
7101
+ }
6583
7102
  const controller = new AbortController();
6584
7103
  const abort = () => controller.abort();
6585
7104
  req.once("aborted", abort);
6586
7105
  res.once("close", abort);
6587
7106
  try {
6588
- const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal);
7107
+ const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal, operationId);
6589
7108
  res.json({ response });
6590
7109
  } catch (error) {
6591
7110
  if (!res.headersSent && !res.destroyed) {
6592
7111
  res.status(500).json({
6593
- error: error instanceof Error ? error.message : "Proxy request failed"
7112
+ error: error instanceof Error ? error.message : error === void 0 ? "Proxy request failed" : error,
7113
+ ...error instanceof RequestFailure ? { code: error.code, details: error.details } : {}
6594
7114
  });
6595
7115
  }
6596
7116
  } finally {
@@ -6642,11 +7162,29 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6642
7162
  app.isMCPServerActive = isMCPServerActive;
6643
7163
  app.trackMCPActivity = trackMCPActivity;
6644
7164
  app.closeMcpHandler = () => mcpHandler?.close();
7165
+ app.attachStudioTransport = (server) => {
7166
+ if (closed)
7167
+ throw new Error("Cannot attach a closed Studio transport");
7168
+ if (boundServers.has(server))
7169
+ return;
7170
+ boundServers.add(server);
7171
+ server.on("upgrade", upgradeStudio);
7172
+ server.once("close", () => {
7173
+ boundServers.delete(server);
7174
+ server.removeListener("upgrade", upgradeStudio);
7175
+ });
7176
+ };
6645
7177
  app.cleanup = async () => {
6646
- for (const handle of eventStreamHandles)
6647
- handle.close();
6648
- eventStreamHandles.clear();
6649
- eventTransport.close();
7178
+ if (closed)
7179
+ return;
7180
+ closed = true;
7181
+ for (const server of boundServers)
7182
+ server.removeListener("upgrade", upgradeStudio);
7183
+ boundServers.clear();
7184
+ unsubscribePeerClosed();
7185
+ transportTokens.clear();
7186
+ studioTransport.close();
7187
+ webSocketServer.close();
6650
7188
  await mcpHandler?.close();
6651
7189
  };
6652
7190
  return app;
@@ -6668,18 +7206,26 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
6668
7206
  throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
6669
7207
  }
6670
7208
  function bindPort(app, host, port) {
6671
- return new Promise((resolve5, reject) => {
6672
- const server = http.createServer(app);
6673
- const onError = (err2) => {
6674
- server.removeListener("error", onError);
6675
- reject(err2);
6676
- };
6677
- server.once("error", onError);
6678
- server.listen(port, host, () => {
6679
- server.removeListener("error", onError);
7209
+ const { promise, resolve: resolve5, reject } = Promise.withResolvers();
7210
+ const server = http.createServer(app);
7211
+ const onError = (err2) => {
7212
+ server.removeListener("error", onError);
7213
+ reject(err2);
7214
+ };
7215
+ server.once("error", onError);
7216
+ server.listen(port, host, () => {
7217
+ server.removeListener("error", onError);
7218
+ try {
7219
+ if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
7220
+ app.attachStudioTransport(server);
7221
+ }
6680
7222
  resolve5(server);
6681
- });
7223
+ } catch (error) {
7224
+ server.close();
7225
+ reject(error);
7226
+ }
6682
7227
  });
7228
+ return promise;
6683
7229
  }
6684
7230
 
6685
7231
  // ../core/dist/tools/studio-client.js
@@ -6688,15 +7234,8 @@ var StudioHttpClient = class {
6688
7234
  constructor(bridge) {
6689
7235
  this.bridge = bridge;
6690
7236
  }
6691
- async request(endpoint, data, targetPeerId, timeoutMs, signal) {
6692
- try {
6693
- return await this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal);
6694
- } catch (error) {
6695
- if (error instanceof Error && error.message === "Request timeout") {
6696
- throw new Error("Studio plugin connection timeout. Make sure the Roblox Studio plugin is running and activated.");
6697
- }
6698
- throw error;
6699
- }
7237
+ async request(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
7238
+ return this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
6700
7239
  }
6701
7240
  };
6702
7241
 
@@ -7521,7 +8060,7 @@ function decodeImagePathToRgba(imagePath) {
7521
8060
  }
7522
8061
 
7523
8062
  // ../core/dist/studio-skills.js
7524
- import { createHash as createHash2 } from "crypto";
8063
+ import { createHash as createHash3 } from "crypto";
7525
8064
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7526
8065
  import * as path4 from "path";
7527
8066
 
@@ -8406,7 +8945,7 @@ function parseBuiltInStudioSkills(buffer) {
8406
8945
  hasCombinedDocument: group.combined !== void 0,
8407
8946
  content: selected.content,
8408
8947
  contentLength: Buffer.byteLength(selected.content, "utf8"),
8409
- contentSha256: createHash2("sha256").update(selected.content).digest("hex")
8948
+ contentSha256: createHash3("sha256").update(selected.content).digest("hex")
8410
8949
  };
8411
8950
  }).sort((left, right) => left.name.localeCompare(right.name));
8412
8951
  }
@@ -8473,7 +9012,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
8473
9012
  const value = {
8474
9013
  bundlePath,
8475
9014
  bundleModifiedAt: stats.mtime.toISOString(),
8476
- bundleSha256: createHash2("sha256").update(buffer).digest("hex"),
9015
+ bundleSha256: createHash3("sha256").update(buffer).digest("hex"),
8477
9016
  studioVersion,
8478
9017
  skills
8479
9018
  };
@@ -10344,8 +10883,8 @@ var RobloxStudioTools = class {
10344
10883
  _peerForRoleInScope(instanceId, role) {
10345
10884
  return this.bridge.getPeersInScope(instanceId).find((peer) => peer.role === role);
10346
10885
  }
10347
- _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal) {
10348
- return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal);
10886
+ _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
10887
+ return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
10349
10888
  }
10350
10889
  _request(endpoint, data, instanceId, role, timeoutMs, signal) {
10351
10890
  const peer = this._peerForRoleInScope(instanceId, role);
@@ -10359,7 +10898,7 @@ var RobloxStudioTools = class {
10359
10898
  return this._requestPeer(endpoint, data, peer.peerId, timeoutMs, signal);
10360
10899
  }
10361
10900
  // Resolve an optional Studio process plus role to one exact Peer and dispatch.
10362
- async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal) {
10901
+ async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
10363
10902
  const resolved = this.bridge.resolveTarget({ instance_id, target });
10364
10903
  if (!resolved.ok)
10365
10904
  throw new RoutingFailure(resolved.error);
@@ -10370,7 +10909,7 @@ var RobloxStudioTools = class {
10370
10909
  data: this._routingErrorData()
10371
10910
  });
10372
10911
  }
10373
- return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal);
10912
+ return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
10374
10913
  }
10375
10914
  // Prefer the first client role in the selected process/group scope for live
10376
10915
  // viewport and input operations; otherwise retain the default Peer's Instance.
@@ -10769,11 +11308,11 @@ var RobloxStudioTools = class {
10769
11308
  ]
10770
11309
  };
10771
11310
  }
10772
- async setProperties(instancePath, properties, instance_id) {
11311
+ async setProperties(instancePath, properties, instance_id, operation_id) {
10773
11312
  if (!instancePath || !properties) {
10774
11313
  throw new Error("instancePath and properties are required for set_properties");
10775
11314
  }
10776
- const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id);
11315
+ const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id, void 0, void 0, operation_id);
10777
11316
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
10778
11317
  }
10779
11318
  async getScriptSource(instancePath, startLine, endLine, instance_id) {
@@ -10903,9 +11442,6 @@ var RobloxStudioTools = class {
10903
11442
  }
10904
11443
  return this.setSelection(opts.paths, opts.mode, instance_id);
10905
11444
  }
10906
- if (!opts.path) {
10907
- throw new Error("selection action=view requires path");
10908
- }
10909
11445
  return this.focusViewport(opts.path, opts.from, opts.padding, opts.angleY, instance_id);
10910
11446
  }
10911
11447
  async getSelection(instance_id) {
@@ -10938,8 +11474,8 @@ var RobloxStudioTools = class {
10938
11474
  };
10939
11475
  }
10940
11476
  async focusViewport(instancePath, from, padding, angleY, instance_id) {
10941
- if (!instancePath) {
10942
- throw new Error("selection action=view requires path");
11477
+ if (instancePath !== void 0 && (typeof instancePath !== "string" || instancePath.length === 0)) {
11478
+ throw new Error("selection path must be a non-empty instance path when provided");
10943
11479
  }
10944
11480
  if (padding !== void 0 && (padding <= 0 || padding > 10)) {
10945
11481
  throw new Error("selection padding must be greater than 0 and at most 10");
@@ -10963,11 +11499,11 @@ var RobloxStudioTools = class {
10963
11499
  ]
10964
11500
  };
10965
11501
  }
10966
- async executeLuau(code, target, instance_id) {
11502
+ async executeLuau(code, target, instance_id, operation_id) {
10967
11503
  if (!code) {
10968
11504
  throw new Error("Code is required for execute_luau");
10969
11505
  }
10970
- const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id);
11506
+ const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id, void 0, void 0, operation_id);
10971
11507
  return {
10972
11508
  content: [
10973
11509
  {
@@ -11378,6 +11914,9 @@ var RobloxStudioTools = class {
11378
11914
  if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
11379
11915
  throw new Error("get_runtime_logs tail must be a non-negative integer.");
11380
11916
  }
11917
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
11918
+ if (refresh)
11919
+ await refresh;
11381
11920
  const instances = this.bridge.getInstances();
11382
11921
  const groups = this.bridge.getMultiplayerGroups();
11383
11922
  let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
@@ -12098,6 +12637,8 @@ var RobloxStudioTools = class {
12098
12637
  });
12099
12638
  }
12100
12639
  return this._textResult({
12640
+ // Keep lifecycle diagnostics on failures; only successful responses are brief.
12641
+ ...body2,
12101
12642
  success: false,
12102
12643
  action,
12103
12644
  error: body2.error ?? "start_failed",
@@ -12114,6 +12655,7 @@ var RobloxStudioTools = class {
12114
12655
  });
12115
12656
  }
12116
12657
  return this._textResult({
12658
+ ...body,
12117
12659
  success: false,
12118
12660
  action,
12119
12661
  error: body.error ?? "stop_failed",
@@ -12202,7 +12744,7 @@ var RobloxStudioTools = class {
12202
12744
  wait = {
12203
12745
  ok: false,
12204
12746
  roles: this._rolesForScope(instanceId),
12205
- timedOut: false
12747
+ timedOut: response.timedOut === true
12206
12748
  };
12207
12749
  }
12208
12750
  const body = wait ? {
@@ -12217,8 +12759,8 @@ var RobloxStudioTools = class {
12217
12759
  ...body,
12218
12760
  success: false,
12219
12761
  error: "Playtest teardown did not complete.",
12220
- 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.",
12221
- stopSignalAccepted: response?.success === true,
12762
+ 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.",
12763
+ stopSignalAccepted: response?.success === true || response.stopSignalAccepted === true,
12222
12764
  stopRequestError,
12223
12765
  runtimeRoles,
12224
12766
  possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
@@ -12596,6 +13138,20 @@ var RobloxStudioTools = class {
12596
13138
  multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
12597
13139
  });
12598
13140
  }
13141
+ async getRequestStatus(request_id) {
13142
+ if (typeof request_id !== "string" || request_id.length === 0 || request_id.length > 128) {
13143
+ throw new Error("request_id must contain between 1 and 128 characters");
13144
+ }
13145
+ const status = await this.bridge.getRequestStatusEverywhere(request_id);
13146
+ if (status)
13147
+ return this._textResult({ ...status });
13148
+ return this._textResult({
13149
+ requestId: request_id,
13150
+ state: "unknown",
13151
+ outcome: "unknown",
13152
+ message: "No retained operation record. It may have expired, been evicted, or belonged to an earlier server session. Do not infer that the mutation was not executed."
13153
+ });
13154
+ }
12599
13155
  // === Asset Tools ===
12600
13156
  async searchAssets(assetType, query, maxResults, sortBy, robloxCreatedOnly) {
12601
13157
  const normalized = normalizeCreatorStoreSearch(assetType, query);
@@ -13438,6 +13994,42 @@ var RobloxStudioTools = class {
13438
13994
  // ../core/dist/proxy-bridge-service.js
13439
13995
  import { randomUUID as randomUUID4 } from "crypto";
13440
13996
  var PROXY_RESPONSE_GRACE_MS = 5e3;
13997
+ function parseRequestStatus(value) {
13998
+ if (!value || typeof value !== "object" || Array.isArray(value))
13999
+ throw new Error("Proxy returned an invalid request status");
14000
+ if (!("requestId" in value) || typeof value.requestId !== "string" || !("targetPeerId" in value) || typeof value.targetPeerId !== "string" || !("queuedAt" in value) || typeof value.queuedAt !== "number" || !("stage" in value) || !isRequestStage(value.stage) || !("state" in value) || value.state !== "pending" && value.state !== "timed_out" && value.state !== "aborted" && value.state !== "disconnected" && value.state !== "settled" || !("outcome" in value) || value.outcome !== "pending" && value.outcome !== "not_executed" && value.outcome !== "unknown" && value.outcome !== "success" && value.outcome !== "error")
14001
+ throw new Error("Proxy returned an invalid request status");
14002
+ const status = {
14003
+ requestId: value.requestId,
14004
+ targetPeerId: value.targetPeerId,
14005
+ queuedAt: value.queuedAt,
14006
+ stage: value.stage,
14007
+ state: value.state,
14008
+ outcome: value.outcome,
14009
+ ...parseObservations(value)
14010
+ };
14011
+ if ("dispatchedAt" in value && typeof value.dispatchedAt === "number")
14012
+ status.dispatchedAt = value.dispatchedAt;
14013
+ if ("settledAt" in value && typeof value.settledAt === "number")
14014
+ status.settledAt = value.settledAt;
14015
+ if ("waiterEndedAt" in value && typeof value.waiterEndedAt === "number")
14016
+ status.waiterEndedAt = value.waiterEndedAt;
14017
+ if ("response" in value)
14018
+ status.response = value.response;
14019
+ if ("error" in value)
14020
+ status.error = value.error;
14021
+ if ("resultUnavailable" in value) {
14022
+ const unavailable = value.resultUnavailable;
14023
+ if (!unavailable || typeof unavailable !== "object" || !("reason" in unavailable) || unavailable.reason !== "size_limit" && unavailable.reason !== "serialization_failed" && unavailable.reason !== "retention_capacity" || !("limitBytes" in unavailable) || typeof unavailable.limitBytes !== "number")
14024
+ throw new Error("Proxy returned invalid result availability");
14025
+ status.resultUnavailable = {
14026
+ reason: unavailable.reason,
14027
+ limitBytes: unavailable.limitBytes,
14028
+ ..."bytes" in unavailable && typeof unavailable.bytes === "number" ? { bytes: unavailable.bytes } : {}
14029
+ };
14030
+ }
14031
+ return status;
14032
+ }
13441
14033
  function peerPublicationChanged(previous, current) {
13442
14034
  return previous === void 0 || previous.transportPeerId !== current.transportPeerId || previous.instanceId !== current.instanceId || previous.multiplayerGroupId !== current.multiplayerGroupId || previous.role !== current.role || previous.placeId !== current.placeId || previous.placeName !== current.placeName || previous.placeKey !== current.placeKey || previous.dataModelName !== current.dataModelName || previous.isRunning !== current.isRunning || previous.pluginVersion !== current.pluginVersion || previous.pluginVariant !== current.pluginVariant || previous.serverVersion !== current.serverVersion;
13443
14035
  }
@@ -13452,6 +14044,9 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13452
14044
  initialRefresh;
13453
14045
  refreshTimer;
13454
14046
  static REFRESH_INTERVAL_MS = 1e3;
14047
+ static TOPOLOGY_TIMEOUT_MS = 2e3;
14048
+ topologyGeneration = 0;
14049
+ appliedTopologyGeneration = 0;
13455
14050
  constructor(primaryBaseUrl, authToken) {
13456
14051
  super();
13457
14052
  this.primaryBaseUrl = primaryBaseUrl;
@@ -13469,17 +14064,33 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13469
14064
  headers["X-MCP-Auth"] = this.authToken;
13470
14065
  return headers;
13471
14066
  }
13472
- async refreshTopology() {
14067
+ refreshTopologyForRouting(signal) {
14068
+ return this.refreshTopology(signal, true);
14069
+ }
14070
+ async refreshTopology(signal, requireFresh = false) {
14071
+ const generation = ++this.topologyGeneration;
14072
+ const controller = new AbortController();
14073
+ const abort = () => controller.abort(signal?.reason);
14074
+ signal?.addEventListener("abort", abort, { once: true });
14075
+ if (signal?.aborted)
14076
+ abort();
14077
+ const timeout = setTimeout(() => controller.abort(new Error("Studio topology refresh timed out")), _ProxyBridgeService.TOPOLOGY_TIMEOUT_MS);
13473
14078
  try {
14079
+ controller.signal.throwIfAborted();
13474
14080
  const res = await fetch(`${this.primaryBaseUrl}/topology`, {
13475
- headers: this.authHeaders()
14081
+ headers: this.authHeaders(),
14082
+ signal: controller.signal
13476
14083
  });
13477
14084
  if (!res.ok)
13478
- return;
14085
+ throw new Error(`Studio topology returned HTTP ${res.status}`);
13479
14086
  const body = await res.json();
13480
- if (!Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
13481
- return;
14087
+ controller.signal.throwIfAborted();
14088
+ if (!body || !Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
14089
+ throw new Error("Primary returned invalid Studio topology");
13482
14090
  }
14091
+ if (generation < this.appliedTopologyGeneration)
14092
+ return;
14093
+ this.appliedTopologyGeneration = generation;
13483
14094
  const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
13484
14095
  this.cachedPeers = body.peers;
13485
14096
  this.cachedInstances = body.instances;
@@ -13489,7 +14100,12 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13489
14100
  this.notifyPeerRegistered(toPublicPeer(peer));
13490
14101
  }
13491
14102
  }
13492
- } catch {
14103
+ } catch (error) {
14104
+ if (requireFresh)
14105
+ throw error;
14106
+ } finally {
14107
+ clearTimeout(timeout);
14108
+ signal?.removeEventListener("abort", abort);
13493
14109
  }
13494
14110
  }
13495
14111
  getPeers() {
@@ -13590,16 +14206,52 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13590
14206
  /** Called when this proxy is being discarded (e.g. promotion to primary
13591
14207
  replaced it). Stops the background refresh so it doesn't leak. */
13592
14208
  stop() {
13593
- if (this.refreshTimer) {
13594
- clearInterval(this.refreshTimer);
13595
- this.refreshTimer = void 0;
13596
- }
14209
+ clearInterval(this.refreshTimer);
14210
+ this.refreshTimer = void 0;
14211
+ }
14212
+ async getRequestStatusEverywhere(requestId) {
14213
+ const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
14214
+ headers: this.authHeaders()
14215
+ });
14216
+ if (!response.ok)
14217
+ throw new Error(`Proxy request status failed (${response.status})`);
14218
+ const body = await response.json();
14219
+ if (!body || typeof body !== "object" || !("status" in body))
14220
+ throw new Error("Proxy returned an invalid request status response");
14221
+ if (body.status === null)
14222
+ return void 0;
14223
+ const status = parseRequestStatus(body.status);
14224
+ if (status.requestId !== requestId)
14225
+ throw new Error("Proxy returned status for a different request");
14226
+ return status;
13597
14227
  }
13598
- async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal) {
14228
+ async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal, operationId) {
14229
+ const requestId = operationId ?? randomUUID4();
14230
+ const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
14231
+ if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
14232
+ throw new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details);
14233
+ }
13599
14234
  if (signal?.aborted)
13600
- throw new Error("Request aborted");
14235
+ throw new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details);
13601
14236
  const controller = new AbortController();
13602
14237
  const effectiveTimeoutMs = Math.max(1, timeoutMs);
14238
+ let requestBody;
14239
+ try {
14240
+ requestBody = JSON.stringify({
14241
+ endpoint,
14242
+ data,
14243
+ targetPeerId,
14244
+ proxyInstanceId: this.proxyInstanceId,
14245
+ timeoutMs: effectiveTimeoutMs,
14246
+ operationId: requestId
14247
+ });
14248
+ } catch {
14249
+ throw new RequestFailure(`Request ${requestId} cannot be serialized at proxy admission; queued; not_executed`, "request_serialization_failed", details);
14250
+ }
14251
+ const requestBytes = Buffer.byteLength(requestBody);
14252
+ if (requestBytes > HTTP_BODY_LIMIT_BYTES) {
14253
+ throw new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at proxy_send; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: HTTP_BODY_LIMIT_BYTES, transportStage: "proxy_send" });
14254
+ }
13603
14255
  let timedOut = false;
13604
14256
  const abortFromCaller = () => controller.abort();
13605
14257
  signal?.addEventListener("abort", abortFromCaller, { once: true });
@@ -13609,39 +14261,54 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13609
14261
  timedOut = true;
13610
14262
  controller.abort();
13611
14263
  }, effectiveTimeoutMs + PROXY_RESPONSE_GRACE_MS);
14264
+ let primaryError = false;
13612
14265
  try {
13613
14266
  const response = await fetch(`${this.primaryBaseUrl}/proxy`, {
13614
14267
  method: "POST",
13615
14268
  headers: this.authHeaders({ "Content-Type": "application/json" }),
13616
- body: JSON.stringify({
13617
- endpoint,
13618
- data,
13619
- targetPeerId,
13620
- proxyInstanceId: this.proxyInstanceId,
13621
- timeoutMs: effectiveTimeoutMs
13622
- }),
14269
+ body: requestBody,
13623
14270
  signal: controller.signal
13624
14271
  });
13625
- if (!response.ok) {
13626
- const body = await response.text();
13627
- throw new Error(`Proxy request failed (${response.status}): ${body}`);
14272
+ const body = await response.text();
14273
+ let result;
14274
+ try {
14275
+ result = JSON.parse(body);
14276
+ } catch {
14277
+ throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
13628
14278
  }
13629
- const result = await response.json();
13630
14279
  if (!result || typeof result !== "object" || Array.isArray(result)) {
13631
14280
  throw new Error("Proxy returned an invalid response");
13632
14281
  }
13633
- if ("error" in result && typeof result.error === "string" && result.error.length > 0) {
13634
- throw new Error(result.error);
14282
+ if ("error" in result && result.error !== void 0 && typeof result.error !== "string") {
14283
+ primaryError = true;
14284
+ throw result.error;
14285
+ }
14286
+ if ("error" in result && typeof result.error === "string") {
14287
+ const details2 = "details" in result ? parseFailureDetails(result.details, { requestId, targetPeerId }) : void 0;
14288
+ if (details2 && "code" in result && typeof result.code === "string") {
14289
+ throw new RequestFailure(result.error, result.code, details2);
14290
+ }
14291
+ throw new RequestFailure(result.error, "studio_response_error", {
14292
+ requestId,
14293
+ targetPeerId,
14294
+ stage: "dispatched",
14295
+ outcome: "unknown",
14296
+ executionOutcome: "unknown"
14297
+ });
14298
+ }
14299
+ if (!response.ok) {
14300
+ throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
13635
14301
  }
13636
14302
  return "response" in result ? result.response : void 0;
13637
14303
  } catch (error) {
14304
+ if (error instanceof RequestFailure || primaryError)
14305
+ throw error;
13638
14306
  const isAbortError = error instanceof Error ? error.name === "AbortError" : !!error && typeof error === "object" && "name" in error && error.name === "AbortError";
13639
14307
  if (isAbortError) {
13640
- if (!timedOut && signal?.aborted)
13641
- throw new Error("Request aborted");
13642
- throw new Error("Proxy request timeout");
14308
+ const message = !timedOut && signal?.aborted ? "Request aborted" : "Proxy request timeout";
14309
+ throw new RequestFailure(`${message}: ${requestId}; dispatched to primary; unknown; use get_request_status`, !timedOut && signal?.aborted ? "request_aborted" : "proxy_request_timeout", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", transportStage: "proxy_send" });
13643
14310
  }
13644
- throw error;
14311
+ throw new RequestFailure(`Proxy connection lost: ${requestId}; dispatched to primary; unknown; use get_request_status. ${error instanceof Error ? error.message : String(error)}`, "proxy_connection_lost", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", connectionLostAt: Date.now(), transportStage: "proxy_send" });
13645
14312
  } finally {
13646
14313
  clearTimeout(timeoutId);
13647
14314
  signal?.removeEventListener("abort", abortFromCaller);
@@ -13653,6 +14320,251 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13653
14320
  }
13654
14321
  };
13655
14322
 
14323
+ // ../core/dist/stdio-transport.js
14324
+ import { parseJSONRPCMessage } from "@modelcontextprotocol/server";
14325
+ import { TextDecoder } from "util";
14326
+ var MAX_STDIO_LINE_BYTES = 80 * 1024 * 1024;
14327
+ var MAX_STDIO_PENDING_OUTPUT_BYTES = 256 * 1024 * 1024;
14328
+ var INITIAL_BUFFER_BYTES = 64 * 1024;
14329
+ var BoundedStdioTransport = class {
14330
+ input;
14331
+ output;
14332
+ onclose;
14333
+ onerror;
14334
+ onmessage;
14335
+ limitBytes;
14336
+ outputLimitBytes;
14337
+ decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
14338
+ buffer;
14339
+ lineBytes = 0;
14340
+ started = false;
14341
+ closed = false;
14342
+ inputEnded = false;
14343
+ outputBlocked = false;
14344
+ pendingChunk;
14345
+ pendingWrites = 0;
14346
+ pendingSends = /* @__PURE__ */ new Set();
14347
+ constructor(input = process.stdin, output = process.stdout, options = {}) {
14348
+ this.input = input;
14349
+ this.output = output;
14350
+ this.limitBytes = options.maxBufferSize ?? MAX_STDIO_LINE_BYTES;
14351
+ this.outputLimitBytes = options.maxPendingOutputBytes ?? MAX_STDIO_PENDING_OUTPUT_BYTES;
14352
+ if (!Number.isSafeInteger(this.limitBytes) || this.limitBytes < 1) {
14353
+ throw new RangeError("Stdio line limit must be a positive safe integer");
14354
+ }
14355
+ if (!Number.isSafeInteger(this.outputLimitBytes) || this.outputLimitBytes < 1) {
14356
+ throw new RangeError("Stdio output limit must be a positive safe integer");
14357
+ }
14358
+ }
14359
+ async start() {
14360
+ if (this.started || this.closed)
14361
+ throw new Error("Stdio transport cannot be started again");
14362
+ if (this.input.readableEncoding)
14363
+ throw new Error("Stdio transport requires a byte stream, not a decoded string stream");
14364
+ this.started = true;
14365
+ this.input.on("data", this.onData);
14366
+ this.input.on("error", this.onStreamError);
14367
+ this.input.on("end", this.onEnd);
14368
+ this.input.on("close", this.onInputClose);
14369
+ this.output.on("error", this.onStreamError);
14370
+ this.output.on("close", this.onOutputClose);
14371
+ this.output.on("drain", this.onDrain);
14372
+ }
14373
+ onData = (chunk) => {
14374
+ let offset = 0;
14375
+ while (offset < chunk.length && !this.closed) {
14376
+ if (this.outputBlocked) {
14377
+ this.pendingChunk = chunk.subarray(offset);
14378
+ return;
14379
+ }
14380
+ const newline = chunk.indexOf(10, offset);
14381
+ const end = newline === -1 ? chunk.length : newline;
14382
+ const bytes = end - offset;
14383
+ const previousBytes = this.lineBytes;
14384
+ this.lineBytes += bytes;
14385
+ if (this.lineBytes > this.limitBytes) {
14386
+ this.buffer = void 0;
14387
+ } else if (previousBytes === 0 && newline !== -1) {
14388
+ this.buffer = chunk.subarray(offset, end);
14389
+ } else if (bytes > 0) {
14390
+ if (!this.buffer || this.buffer.length < this.lineBytes) {
14391
+ const capacity = Math.min(this.limitBytes, Math.max(INITIAL_BUFFER_BYTES, this.lineBytes, (this.buffer?.length ?? 0) * 2));
14392
+ const grown = Buffer.allocUnsafe(capacity);
14393
+ this.buffer?.copy(grown, 0, 0, previousBytes);
14394
+ this.buffer = grown;
14395
+ }
14396
+ chunk.copy(this.buffer, previousBytes, offset, end);
14397
+ }
14398
+ offset = end + 1;
14399
+ if (newline !== -1)
14400
+ this.finishLine();
14401
+ }
14402
+ };
14403
+ finishLine() {
14404
+ const bytes = this.lineBytes;
14405
+ const buffer = this.buffer;
14406
+ this.lineBytes = 0;
14407
+ this.buffer = void 0;
14408
+ if (bytes > this.limitBytes) {
14409
+ void this.rejectLine(-32600, "stdio_request_too_large", bytes).catch(() => {
14410
+ });
14411
+ return;
14412
+ }
14413
+ let value;
14414
+ try {
14415
+ value = JSON.parse(this.decoder.decode(buffer?.subarray(0, bytes)));
14416
+ } catch {
14417
+ void this.rejectLine(-32700, "stdio_parse_error", bytes).catch(() => {
14418
+ });
14419
+ return;
14420
+ }
14421
+ let message;
14422
+ try {
14423
+ message = parseJSONRPCMessage(value);
14424
+ } catch {
14425
+ void this.rejectLine(-32600, "stdio_invalid_request", bytes).catch(() => {
14426
+ });
14427
+ return;
14428
+ }
14429
+ try {
14430
+ this.onmessage?.(message);
14431
+ } catch (error) {
14432
+ this.onerror?.(error instanceof Error ? error : new Error(String(error)));
14433
+ }
14434
+ }
14435
+ rejectLine(code, reason, bytes) {
14436
+ const message = `${reason}: received ${bytes} bytes; limit ${this.limitBytes} bytes; stdio_receive; not_executed`;
14437
+ this.onerror?.(new Error(message));
14438
+ return this.writeMessage({
14439
+ jsonrpc: "2.0",
14440
+ id: null,
14441
+ error: {
14442
+ code,
14443
+ message,
14444
+ data: {
14445
+ code: reason,
14446
+ bytes,
14447
+ limitBytes: this.limitBytes,
14448
+ stage: "stdio_receive",
14449
+ transportStage: "stdio_receive",
14450
+ outcome: "not_executed",
14451
+ executionOutcome: "not_executed"
14452
+ }
14453
+ }
14454
+ });
14455
+ }
14456
+ onEnd = () => {
14457
+ if (this.closed)
14458
+ return;
14459
+ this.inputEnded = true;
14460
+ if (this.pendingChunk)
14461
+ return;
14462
+ const bytes = this.lineBytes;
14463
+ this.lineBytes = 0;
14464
+ this.buffer = void 0;
14465
+ if (bytes > 0) {
14466
+ const oversized = bytes > this.limitBytes;
14467
+ void this.rejectLine(oversized ? -32600 : -32700, oversized ? "stdio_request_too_large" : "stdio_truncated_line", bytes).finally(() => this.close()).catch(() => {
14468
+ });
14469
+ } else {
14470
+ void this.close();
14471
+ }
14472
+ };
14473
+ onInputClose = () => {
14474
+ if (!this.input.readableEnded)
14475
+ this.onEnd();
14476
+ };
14477
+ onOutputClose = () => {
14478
+ void this.close();
14479
+ };
14480
+ onStreamError = (error) => {
14481
+ if (this.closed)
14482
+ return;
14483
+ this.onerror?.(error);
14484
+ void this.close();
14485
+ };
14486
+ onDrain = () => {
14487
+ this.outputBlocked = false;
14488
+ for (const send of this.pendingSends)
14489
+ send.resolve();
14490
+ this.pendingSends.clear();
14491
+ const chunk = this.pendingChunk;
14492
+ this.pendingChunk = void 0;
14493
+ if (chunk && !this.closed)
14494
+ this.onData(chunk);
14495
+ if (this.inputEnded && !this.pendingChunk && !this.closed)
14496
+ this.onEnd();
14497
+ if (!this.outputBlocked && !this.closed)
14498
+ this.input.resume();
14499
+ };
14500
+ send(message) {
14501
+ return this.writeMessage(message);
14502
+ }
14503
+ writeMessage(message) {
14504
+ if (this.closed)
14505
+ return Promise.reject(new Error("Stdio transport is closed"));
14506
+ let json;
14507
+ try {
14508
+ json = JSON.stringify(message) + "\n";
14509
+ } catch (error) {
14510
+ return Promise.reject(error);
14511
+ }
14512
+ const bytes = Buffer.byteLength(json);
14513
+ const queuedBytes = this.output.writableLength + bytes;
14514
+ if (queuedBytes > this.outputLimitBytes) {
14515
+ const error = new Error(`Stdio output backpressure capacity exceeded: ${queuedBytes} bytes; limit ${this.outputLimitBytes} bytes`);
14516
+ this.onStreamError(error);
14517
+ return Promise.reject(error);
14518
+ }
14519
+ const { promise, resolve: resolve5, reject } = Promise.withResolvers();
14520
+ const pending = { resolve: resolve5, reject };
14521
+ this.pendingSends.add(pending);
14522
+ this.pendingWrites++;
14523
+ try {
14524
+ const writable = this.output.write(Buffer.from(json), () => {
14525
+ this.pendingWrites--;
14526
+ if (this.closed && this.pendingWrites === 0) {
14527
+ queueMicrotask(() => this.output.off("error", this.onStreamError));
14528
+ }
14529
+ });
14530
+ if (writable) {
14531
+ this.pendingSends.delete(pending);
14532
+ resolve5();
14533
+ } else if (!this.closed) {
14534
+ this.outputBlocked = true;
14535
+ this.input.pause();
14536
+ }
14537
+ } catch (error) {
14538
+ this.pendingWrites--;
14539
+ this.onStreamError(error instanceof Error ? error : new Error(String(error)));
14540
+ }
14541
+ return promise;
14542
+ }
14543
+ async close() {
14544
+ if (this.closed)
14545
+ return;
14546
+ this.closed = true;
14547
+ this.input.off("data", this.onData);
14548
+ this.input.off("error", this.onStreamError);
14549
+ this.input.off("end", this.onEnd);
14550
+ this.input.off("close", this.onInputClose);
14551
+ if (this.pendingWrites === 0)
14552
+ this.output.off("error", this.onStreamError);
14553
+ this.output.off("close", this.onOutputClose);
14554
+ this.output.off("drain", this.onDrain);
14555
+ if (this.input.listenerCount("data") === 0)
14556
+ this.input.pause();
14557
+ this.buffer = void 0;
14558
+ this.pendingChunk = void 0;
14559
+ this.lineBytes = 0;
14560
+ const error = new Error("Stdio transport is closed");
14561
+ for (const send of this.pendingSends)
14562
+ send.reject(error);
14563
+ this.pendingSends.clear();
14564
+ this.onclose?.();
14565
+ }
14566
+ };
14567
+
13656
14568
  // ../core/dist/server.js
13657
14569
  var RobloxStudioMCPServer = class {
13658
14570
  tools;
@@ -13746,7 +14658,10 @@ var RobloxStudioMCPServer = class {
13746
14658
  throw new Error(`Unknown tool: ${name}`);
13747
14659
  return handler(tools, args, invocation);
13748
14660
  }
13749
- }), { onerror: (error) => console.error("[mcp:stdio]", error) });
14661
+ }), {
14662
+ transport: new BoundedStdioTransport(),
14663
+ onerror: (error) => console.error("[mcp:stdio]", error)
14664
+ });
13750
14665
  console.error(`${this.config.name} v${this.config.version} running on stdio`);
13751
14666
  if (primaryApp) {
13752
14667
  primaryApp.setMCPServerActive(true);
@@ -13904,6 +14819,12 @@ var TOOL_DEFINITIONS = [
13904
14819
  inputSchema: {
13905
14820
  type: "object",
13906
14821
  properties: {
14822
+ operation_id: {
14823
+ type: "string",
14824
+ minLength: 1,
14825
+ maxLength: 128,
14826
+ description: "Unique recovery ID; reuse only for identical arguments."
14827
+ },
13907
14828
  instancePath: {
13908
14829
  type: "string",
13909
14830
  description: "Canonical path of the target instance."
@@ -14079,7 +15000,7 @@ var TOOL_DEFINITIONS = [
14079
15000
  action: {
14080
15001
  type: "string",
14081
15002
  enum: ["get", "set", "view"],
14082
- description: "View frames the target."
15003
+ description: "View frames a target or the current selection and preserves the camera type."
14083
15004
  },
14084
15005
  paths: {
14085
15006
  type: "array",
@@ -14095,7 +15016,7 @@ var TOOL_DEFINITIONS = [
14095
15016
  path: {
14096
15017
  type: "string",
14097
15018
  minLength: 1,
14098
- description: "View needs a BasePart or Model path."
15019
+ description: "Optional BasePart or Model path for view; omit to frame exactly one selected BasePart or Model."
14099
15020
  },
14100
15021
  from: {
14101
15022
  type: "number",
@@ -14129,6 +15050,12 @@ var TOOL_DEFINITIONS = [
14129
15050
  inputSchema: {
14130
15051
  type: "object",
14131
15052
  properties: {
15053
+ operation_id: {
15054
+ type: "string",
15055
+ minLength: 1,
15056
+ maxLength: 128,
15057
+ description: "Unique recovery ID; reuse only for identical arguments."
15058
+ },
14132
15059
  code: {
14133
15060
  type: "string",
14134
15061
  description: "Luau code to execute."
@@ -14973,6 +15900,23 @@ var TOOL_DEFINITIONS = [
14973
15900
  properties: {}
14974
15901
  }
14975
15902
  },
15903
+ {
15904
+ name: "get_request_status",
15905
+ category: "read",
15906
+ description: "Use to recover a retained Studio operation outcome after a timeout.",
15907
+ inputSchema: {
15908
+ type: "object",
15909
+ properties: {
15910
+ request_id: {
15911
+ type: "string",
15912
+ minLength: 1,
15913
+ maxLength: 128,
15914
+ description: "Caller operation ID or request ID from a server error."
15915
+ }
15916
+ },
15917
+ required: ["request_id"]
15918
+ }
15919
+ },
14976
15920
  // === Asset Tools ===
14977
15921
  {
14978
15922
  name: "search_assets",
@@ -15551,7 +16495,7 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
15551
16495
 
15552
16496
  // ../core/dist/install-plugin-helpers.js
15553
16497
  var import_saxes = __toESM(require_saxes(), 1);
15554
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16498
+ 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";
15555
16499
  import { execSync } from "child_process";
15556
16500
  import { randomUUID as randomUUID5 } from "crypto";
15557
16501
  import { basename as basename3, join as join5 } from "path";
@@ -15702,7 +16646,79 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
15702
16646
  }
15703
16647
  var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
15704
16648
  function errorCode(error) {
15705
- return error.code;
16649
+ return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
16650
+ }
16651
+ function pluginInstallLockError(lockPath, reason) {
16652
+ return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
16653
+ }
16654
+ function readPluginInstallLockOwner(lockPath) {
16655
+ const ownerPath = join5(lockPath, "owner.json");
16656
+ if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
16657
+ throw new Error("Lock and owner metadata must be a directory and regular file");
16658
+ }
16659
+ const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16660
+ 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) {
16661
+ throw new Error("Invalid lock owner metadata");
16662
+ }
16663
+ return { pid: owner.pid, token: owner.token, createdAt: owner.createdAt };
16664
+ }
16665
+ function assertPluginInstallOwnerExited(lockPath, owner) {
16666
+ if (owner.pid !== process.pid) {
16667
+ try {
16668
+ process.kill(owner.pid, 0);
16669
+ } catch (error) {
16670
+ if (errorCode(error) === "ESRCH")
16671
+ return;
16672
+ throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
16673
+ }
16674
+ }
16675
+ throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
16676
+ }
16677
+ function recoverPluginInstallLock(lockPath, warn) {
16678
+ let owner;
16679
+ try {
16680
+ owner = readPluginInstallLockOwner(lockPath);
16681
+ } catch (error) {
16682
+ throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
16683
+ }
16684
+ assertPluginInstallOwnerExited(lockPath, owner);
16685
+ const claimPath = join5(lockPath, `recovery-${owner.token}`);
16686
+ try {
16687
+ mkdirSync4(claimPath);
16688
+ } catch (error) {
16689
+ 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})`);
16690
+ }
16691
+ const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
16692
+ let moved = false;
16693
+ try {
16694
+ let currentOwner;
16695
+ try {
16696
+ currentOwner = readPluginInstallLockOwner(lockPath);
16697
+ } catch (error) {
16698
+ throw pluginInstallLockError(lockPath, `Cannot safely recheck plugin installation lock owner (${error})`);
16699
+ }
16700
+ if (currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || currentOwner.createdAt !== owner.createdAt) {
16701
+ throw pluginInstallLockError(lockPath, "Plugin installation lock owner changed; retry installation");
16702
+ }
16703
+ assertPluginInstallOwnerExited(lockPath, currentOwner);
16704
+ renameSync(lockPath, abandonedPath);
16705
+ moved = true;
16706
+ } finally {
16707
+ if (!moved) {
16708
+ try {
16709
+ rmdirSync(claimPath);
16710
+ } catch (error) {
16711
+ if (errorCode(error) !== "ENOENT") {
16712
+ warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
16713
+ }
16714
+ }
16715
+ }
16716
+ }
16717
+ try {
16718
+ rmSync2(abandonedPath, { recursive: true, force: true });
16719
+ } catch (error) {
16720
+ warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
16721
+ }
15706
16722
  }
15707
16723
  function acquirePluginInstallLock(pluginsFolder, warn) {
15708
16724
  const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
@@ -15717,7 +16733,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
15717
16733
  } catch (error) {
15718
16734
  if (errorCode(error) !== "EEXIST")
15719
16735
  throw error;
15720
- throw new Error(`Another Studio plugin installation is already in progress: ${lockPath}. If no installer is running, remove that lock directory and retry.`);
16736
+ recoverPluginInstallLock(lockPath, warn);
16737
+ try {
16738
+ mkdirSync4(lockPath);
16739
+ } catch (retryError) {
16740
+ if (errorCode(retryError) !== "EEXIST")
16741
+ throw retryError;
16742
+ throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
16743
+ }
15721
16744
  }
15722
16745
  try {
15723
16746
  writeFileSync3(ownerPath, `${JSON.stringify(owner)}
@@ -15735,7 +16758,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
15735
16758
  }
15736
16759
  return () => {
15737
16760
  try {
15738
- const currentOwner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16761
+ const currentOwner = readPluginInstallLockOwner(lockPath);
15739
16762
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
15740
16763
  rmSync2(lockPath, { recursive: true, force: true });
15741
16764
  }