@chrrxs/robloxstudio-mcp-inspector 3.2.0 → 3.3.0

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.
@@ -64,8 +64,8 @@ applyRememberedServerUrl()
64
64
  -- down their VM. Close the persistent WebStreamClient while BindToClose still
65
65
  -- gives this server peer a live execution context; otherwise each play cycle
66
66
  -- can retain one native WebSocket until Studio itself is restarted. The
67
- -- transport releases that native socket before yielding to unregister the
68
- -- logical server peer.
67
+ -- transport unregisters the server peer on that socket and waits at most two
68
+ -- seconds for the server to close it, so no HTTP request can hold up shutdown.
69
69
  if startupRole == "server" then
70
70
  game:BindToClose(StudioWebSocket.suspendForShutdown)
71
71
  end
@@ -104,8 +104,20 @@ plugin.Unloading:Connect(function()
104
104
  Communication.deactivateAll()
105
105
  end)
106
106
  UI.updateUIState()
107
- Communication.checkForUpdates()
107
+ -- Every DataModel shares Studio's five plugin HTTP request slots, and a
108
+ -- request has no time limit. Check for updates once, from the edit DataModel,
109
+ -- instead of again in every play server and client.
110
+ if startupRole == "edit" then
111
+ Communication.checkForUpdates()
112
+ end
108
113
  task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
114
+ --* Runs one startup step so that its failure cannot skip the steps after it.
115
+ local function runStartupStep(description, step)
116
+ local ok, err = pcall(step)
117
+ if not ok then
118
+ warn(`[robloxstudio-mcp] {description} failed: {err}`)
119
+ end
120
+ end
109
121
  -- Auto-activate per Peer. Runtime plugin VMs can load before their first
110
122
  -- Heartbeat; task.delay() would then wait behind the very multiplayer startup
111
123
  -- that needs this Peer to register. Start runtime initialization immediately,
@@ -123,10 +135,13 @@ local function autoActivatePeer()
123
135
  if role == "server" then
124
136
  -- Yields until this playtest server knows which edit session (or
125
137
  -- multiplayer group) it belongs to, so it registers under that identity.
126
- PluginSession.resolveRuntimeTopology()
138
+ -- On failure the server still registers, under its own identity.
139
+ runStartupStep("Playtest topology resolution", function()
140
+ return PluginSession.resolveRuntimeTopology()
141
+ end)
127
142
  end
128
143
  if role == "edit" or role == "server" then
129
- local activationOk, activationError = pcall(function()
144
+ runStartupStep(`Automatic {role} Peer activation`, function()
130
145
  local conn = State.getActiveConnection()
131
146
  if not conn.isActive then
132
147
  if role == "server" then
@@ -155,19 +170,20 @@ local function autoActivatePeer()
155
170
  Communication.activatePlugin()
156
171
  end
157
172
  end)
158
- if not activationOk then
159
- warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
160
- end
161
173
  end
162
174
  if role == "server" then
163
- ClientBroker.setupServerBroker()
175
+ runStartupStep("Client broker setup", function()
176
+ return ClientBroker.setupServerBroker()
177
+ end)
164
178
  -- The play-server DM is the only one where StudioTestService:EndTest is
165
179
  -- legal, so the stop-play monitor lives here. It consumes tokenized
166
180
  -- stop requests from plugin settings and acknowledges EndTest results.
167
- StopPlayMonitor.startMonitor({
168
- beforeEndTest = StudioWebSocket.suspendForShutdown,
169
- afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
170
- })
181
+ runStartupStep("Stop-play monitor startup", function()
182
+ return StopPlayMonitor.startMonitor({
183
+ beforeEndTest = StudioWebSocket.suspendForShutdown,
184
+ afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
185
+ })
186
+ end)
171
187
  elseif role == "client" then
172
188
  ClientBroker.setupClientBroker()
173
189
  end
@@ -1455,6 +1471,7 @@ local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
1455
1471
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
1456
1472
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
1457
1473
  local StudioWebSocket = TS.import(script, script.Parent, "StudioWebSocket")
1474
+ local PathGuard = TS.import(script, script.Parent, "PathGuard")
1458
1475
  local assignedRole
1459
1476
  local lastReadyPlaceKey
1460
1477
  local initialRole = PluginSession.getRole()
@@ -1507,12 +1524,17 @@ local routeMap = {
1507
1524
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
1508
1525
  ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
1509
1526
  }
1527
+ local pathGuard = PathGuard.createPathGuard(function(path)
1528
+ return Utils.resolveInstancePath(path).ambiguity
1529
+ end)
1510
1530
  local function processRequest(request, context)
1511
1531
  local endpoint = request.endpoint
1512
- local data = request.data or {}
1532
+ local data = (request.data or {})
1513
1533
  local handler = routeMap[endpoint]
1514
1534
  if handler then
1515
- return handler(data, context)
1535
+ return pathGuard.run(endpoint, data, function()
1536
+ return handler(data, context)
1537
+ end)
1516
1538
  else
1517
1539
  return {
1518
1540
  error = `Unknown endpoint: {endpoint}`,
@@ -1959,9 +1981,9 @@ local function computeBridgeStamp()
1959
1981
  for i = 1, #combined do
1960
1982
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1961
1983
  end
1962
- -- "3.2.0" is replaced with the package version at package time
1984
+ -- "3.3.0" is replaced with the package version at package time
1963
1985
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1964
- return `{tostring(h)}-3.2.0`
1986
+ return `{tostring(h)}-3.3.0`
1965
1987
  end
1966
1988
  local BRIDGE_STAMP = computeBridgeStamp()
1967
1989
  local function setSource(scriptInst, source)
@@ -4197,15 +4219,20 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
4197
4219
  -- capability and drives the REAL input pipeline: SendKey feeds
4198
4220
  -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
4199
4221
  -- character at full WalkSpeed with controls intact, no Humanoid hijack),
4200
- -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
4201
- -- CoreGui), and SendTextInput types into the focused TextBox.
4222
+ -- SendMouseButton feeds UIS and activates GUI buttons, SendMousePosition moves
4223
+ -- the cursor (hover, MouseEnter, InputChanged), SendPointerAction scrolls
4224
+ -- under a position, and SendTextInput types into the focused TextBox. Every method throws when the
4225
+ -- input would hit interactive CoreGui.
4226
+ --
4227
+ -- Verified live on a play client: move and scroll reach game scripts with
4228
+ -- the device simulator stopped. Under touch device emulation the client has
4229
+ -- no mouse: clicks arrive as Touch, scroll still works, but moves never reach
4230
+ -- the game, so we reject move there instead of returning a false success.
4202
4231
  --
4203
- -- Method set on the VirtualInput object (verified live):
4204
- -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
4205
- -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
4206
- -- SendTextInput(text: string)
4207
- -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
4208
- -- "scroll" mouse actions are not supported.
4232
+ -- SendMouseDelta is deliberately NOT exposed. It only works while the cursor
4233
+ -- is locked (MouseBehavior.LockCenter), and on desktop Studio holds that lock
4234
+ -- by pinning and hiding the user's real OS cursor — an unacceptable takeover
4235
+ -- of the host's input.
4209
4236
  --
4210
4237
  -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
4211
4238
  -- what capture_screenshot returns (window space, origin at the top-left of the
@@ -4238,6 +4265,7 @@ local MOUSE_TYPE_MAP = {
4238
4265
  Right = Enum.UserInputType.MouseButton2,
4239
4266
  Middle = Enum.UserInputType.MouseButton3,
4240
4267
  }
4268
+ local NO_MOUSE_ERROR = "This client has no mouse (touch device emulation is active), so cursor movement never reaches the game. " .. "Stop it with set_device_simulator stopSimulation=true, then retry."
4241
4269
  local function simulateMouseInput(requestData)
4242
4270
  local action = requestData.action
4243
4271
  local x = requestData.x
@@ -4247,6 +4275,7 @@ local function simulateMouseInput(requestData)
4247
4275
  _condition = "Left"
4248
4276
  end
4249
4277
  local button = _condition
4278
+ local wheel = requestData.wheel
4250
4279
  if not (action ~= "" and action) then
4251
4280
  return {
4252
4281
  error = "action is required",
@@ -4257,6 +4286,11 @@ local function simulateMouseInput(requestData)
4257
4286
  error = "x and y are required",
4258
4287
  }
4259
4288
  end
4289
+ if action == "scroll" and (not (type(wheel) == "number") or wheel == 0) then
4290
+ return {
4291
+ error = "wheel must be a non-zero number for scroll",
4292
+ }
4293
+ end
4260
4294
  -- Input is silently dropped by the engine when the window isn't rendering
4261
4295
  -- (e.g. minimized). Surface that instead of returning a false success.
4262
4296
  local notRendering = RenderMonitor.notRenderingReason()
@@ -4265,6 +4299,11 @@ local function simulateMouseInput(requestData)
4265
4299
  error = notRendering,
4266
4300
  }
4267
4301
  end
4302
+ if action == "move" and not UserInputService.MouseEnabled then
4303
+ return {
4304
+ error = NO_MOUSE_ERROR,
4305
+ }
4306
+ end
4268
4307
  local vi = getVI()
4269
4308
  if not vi then
4270
4309
  return {
@@ -4282,18 +4321,29 @@ local function simulateMouseInput(requestData)
4282
4321
  vi:SendMouseButton(pos, inputType, true)
4283
4322
  elseif action == "mouseUp" then
4284
4323
  vi:SendMouseButton(pos, inputType, false)
4324
+ elseif action == "move" then
4325
+ vi:SendMousePosition(pos)
4326
+ elseif action == "scroll" then
4327
+ vi:SendPointerAction(pos, {
4328
+ Wheel = wheel,
4329
+ })
4285
4330
  else
4286
- error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
4331
+ error(`Unsupported action "{action}". Use click, mouseDown, mouseUp, move, or scroll.`)
4287
4332
  end
4288
4333
  end)
4289
4334
  if success then
4290
- return {
4335
+ local result = {
4291
4336
  success = true,
4292
4337
  action = action,
4293
4338
  x = x,
4294
4339
  y = y,
4295
- button = button,
4296
4340
  }
4341
+ if action == "scroll" then
4342
+ result.wheel = wheel
4343
+ elseif action ~= "move" then
4344
+ result.button = button
4345
+ end
4346
+ return result
4297
4347
  end
4298
4348
  return {
4299
4349
  error = `Failed to simulate mouse input: {err}`,
@@ -4484,6 +4534,7 @@ local Selection = game:GetService("Selection")
4484
4534
  local _binding = Utils
4485
4535
  local getInstancePath = _binding.getInstancePath
4486
4536
  local getInstanceByPath = _binding.getInstanceByPath
4537
+ local createPathAmbiguityChecker = _binding.createPathAmbiguityChecker
4487
4538
  local function serializeValue(value)
4488
4539
  local _value = value
4489
4540
  local vType = typeof(_value)
@@ -4585,15 +4636,20 @@ local function getSelection(_requestData)
4585
4636
  message = "No objects selected",
4586
4637
  }
4587
4638
  end
4639
+ local isAmbiguous = createPathAmbiguityChecker()
4588
4640
  -- ▼ ReadonlyArray.map ▼
4589
4641
  local _newValue = table.create(#selection)
4590
4642
  local _callback = function(instance)
4591
- return {
4643
+ local entry = {
4592
4644
  name = instance.Name,
4593
4645
  className = instance.ClassName,
4594
4646
  path = getInstancePath(instance),
4595
4647
  parent = if instance.Parent then getInstancePath(instance.Parent) else nil,
4596
4648
  }
4649
+ if isAmbiguous(instance) then
4650
+ entry.ambiguous = true
4651
+ end
4652
+ return entry
4597
4653
  end
4598
4654
  for _k, _v in selection do
4599
4655
  _newValue[_k] = _callback(_v, _k - 1, selection)
@@ -6732,9 +6788,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
6732
6788
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
6733
6789
  local CooperativeJobRunner = TS.import(script, script.Parent.Parent, "CooperativeJobRunner")
6734
6790
  local ScriptSearch = TS.import(script, script.Parent.Parent, "ScriptSearch")
6791
+ local InstanceSearch = TS.import(script, script.Parent.Parent, "InstanceSearch")
6735
6792
  local _binding = Utils
6736
6793
  local getInstancePath = _binding.getInstancePath
6737
6794
  local getInstanceByPath = _binding.getInstanceByPath
6795
+ local createPathAmbiguityChecker = _binding.createPathAmbiguityChecker
6738
6796
  local readScriptSource = _binding.readScriptSource
6739
6797
  local function getFileTree(requestData)
6740
6798
  local _condition = (requestData.path)
@@ -6860,62 +6918,42 @@ local function getPlaceInfo(_requestData)
6860
6918
  },
6861
6919
  }
6862
6920
  end
6863
- local function searchObjects(requestData)
6864
- local query = requestData.query
6865
- local _condition = (requestData.searchType)
6866
- if _condition == nil then
6867
- _condition = "name"
6868
- end
6869
- local searchType = _condition
6870
- local propertyName = requestData.propertyName
6871
- if not (query ~= "" and query) then
6872
- return {
6873
- error = "Query is required",
6874
- }
6875
- end
6876
- local results = {}
6877
- local function searchRecursive(instance)
6878
- local match = false
6879
- if searchType == "name" then
6880
- local _exp = string.lower(instance.Name)
6881
- local _arg0 = string.lower(query)
6882
- match = (string.find(_exp, _arg0)) ~= nil
6883
- elseif searchType == "class" then
6884
- local _exp = string.lower(instance.ClassName)
6885
- local _arg0 = string.lower(query)
6886
- match = (string.find(_exp, _arg0)) ~= nil
6887
- else
6888
- local _value = searchType == "property" and propertyName
6889
- if _value ~= "" and _value then
6890
- local success, value = pcall(function()
6891
- return tostring(instance[propertyName])
6892
- end)
6893
- if success then
6894
- local _exp = string.lower(value)
6895
- local _arg0 = string.lower(query)
6896
- match = (string.find(_exp, _arg0)) ~= nil
6897
- end
6898
- end
6899
- end
6900
- if match then
6901
- local _arg0 = {
6902
- name = instance.Name,
6903
- className = instance.ClassName,
6904
- path = getInstancePath(instance),
6905
- }
6906
- table.insert(results, _arg0)
6907
- end
6908
- for _, child in instance:GetChildren() do
6909
- searchRecursive(child)
6910
- end
6921
+ local instanceSearch = InstanceSearch.createInstanceSearch({
6922
+ resolveRoot = getInstanceByPath,
6923
+ getChildren = function(instance)
6924
+ return instance:GetChildren()
6925
+ end,
6926
+ getName = function(instance)
6927
+ return instance.Name
6928
+ end,
6929
+ getClassName = function(instance)
6930
+ return instance.ClassName
6931
+ end,
6932
+ isA = function(instance, className)
6933
+ return instance:IsA(className)
6934
+ end,
6935
+ readPropertyText = function(instance, propertyName)
6936
+ local success, value = pcall(function()
6937
+ return tostring(instance[propertyName])
6938
+ end)
6939
+ return if success then value else nil
6940
+ end,
6941
+ getPath = getInstancePath,
6942
+ createAmbiguityChecker = createPathAmbiguityChecker,
6943
+ })
6944
+ local function searchObjects(requestData, execution)
6945
+ -- Keyed per request: concurrent searches are independent reads, so the
6946
+ -- runner is used only for yielding, deadlines and cancellation.
6947
+ local result = CooperativeJobRunner.runExclusive(`instance-search:{execution.requestId}`, execution, function(control)
6948
+ return instanceSearch.search(requestData, control)
6949
+ end)
6950
+ if result.error == "deadline_exceeded" then
6951
+ local _object = table.clone(result)
6952
+ setmetatable(_object, nil)
6953
+ _object.message = "search_objects exceeded its bridge deadline before the scan completed. Narrow it with path or className."
6954
+ return _object
6911
6955
  end
6912
- searchRecursive(game)
6913
- return {
6914
- results = results,
6915
- query = query,
6916
- searchType = searchType,
6917
- count = #results,
6918
- }
6956
+ return result
6919
6957
  end
6920
6958
  local function getInstanceProperties(requestData)
6921
6959
  local instancePath = requestData.instancePath
@@ -7183,9 +7221,10 @@ local function getProjectStructure(requestData)
7183
7221
  error = `Path not found: {startPath}`,
7184
7222
  }
7185
7223
  end
7224
+ local isAmbiguous = createPathAmbiguityChecker()
7186
7225
  local function getStructure(instance, depth)
7187
7226
  if depth > maxDepth then
7188
- return {
7227
+ local truncatedNode = {
7189
7228
  name = instance.Name,
7190
7229
  className = instance.ClassName,
7191
7230
  path = getInstancePath(instance),
@@ -7193,12 +7232,19 @@ local function getProjectStructure(requestData)
7193
7232
  hasMore = true,
7194
7233
  note = "Max depth reached - use this path to explore further",
7195
7234
  }
7235
+ if isAmbiguous(instance) then
7236
+ truncatedNode.ambiguous = true
7237
+ end
7238
+ return truncatedNode
7196
7239
  end
7197
7240
  local node = {
7198
7241
  name = instance.Name,
7199
7242
  className = instance.ClassName,
7200
7243
  path = getInstancePath(instance),
7201
7244
  }
7245
+ if isAmbiguous(instance) then
7246
+ node.ambiguous = true
7247
+ end
7202
7248
  if instance:IsA("LuaSourceContainer") then
7203
7249
  node.hasSource = true
7204
7250
  node.scriptType = instance.ClassName
@@ -7328,6 +7374,9 @@ local scriptSearch = ScriptSearch.createScriptSearch({
7328
7374
  resolveRoot = function(self, path)
7329
7375
  return getInstanceByPath(path)
7330
7376
  end,
7377
+ createAmbiguityChecker = function(self)
7378
+ return createPathAmbiguityChecker()
7379
+ end,
7331
7380
  getChildren = function(self, instance)
7332
7381
  return instance:GetChildren()
7333
7382
  end,
@@ -7635,6 +7684,9 @@ return {
7635
7684
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
7636
7685
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
7637
7686
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
7687
+ local _ScriptEdits = TS.import(script, script.Parent.Parent, "ScriptEdits")
7688
+ local planScriptEdits = _ScriptEdits.planScriptEdits
7689
+ local sourceRevision = _ScriptEdits.sourceRevision
7638
7690
  local _binding = Utils
7639
7691
  local getInstancePath = _binding.getInstancePath
7640
7692
  local getInstanceByPath = _binding.getInstanceByPath
@@ -7750,6 +7802,7 @@ local function getScriptSource(requestData)
7750
7802
  numberedSource = numberLines(selectedLines, returnedStartLine),
7751
7803
  sourceLength = #fullSource,
7752
7804
  lineCount = totalLineCount,
7805
+ revision = sourceRevision(fullSource),
7753
7806
  startLine = returnedStartLine,
7754
7807
  endLine = returnedEndLine,
7755
7808
  isPartial = explicitRange,
@@ -7821,14 +7874,74 @@ local function setScriptSource(requestData)
7821
7874
  error = `Failed to set script source: {applyResult.error}`,
7822
7875
  }
7823
7876
  end
7877
+ -- Accepts the batched `edits` array or the single top-level old_string form.
7878
+ local function readScriptEdits(requestData)
7879
+ local rawEdits = requestData.edits
7880
+ if rawEdits == nil then
7881
+ local oldString = requestData.old_string
7882
+ local newString = requestData.new_string
7883
+ local startLine = requestData.startLine
7884
+ if not (type(oldString) == "string") or not (type(newString) == "string") then
7885
+ return "Instance path, old_string, and new_string are required"
7886
+ end
7887
+ if startLine ~= nil and not (type(startLine) == "number") then
7888
+ return "startLine must be a number"
7889
+ end
7890
+ return { {
7891
+ oldString = oldString,
7892
+ newString = newString,
7893
+ startLine = startLine,
7894
+ } }
7895
+ end
7896
+ if not (type(rawEdits) == "table") then
7897
+ return "edits must be an array"
7898
+ end
7899
+ local edits = {}
7900
+ for _, rawEdit in rawEdits do
7901
+ local index = #edits
7902
+ if not (type(rawEdit) == "table") then
7903
+ return `edits[{index}] must be an object`
7904
+ end
7905
+ local fields = rawEdit
7906
+ local oldString = fields.old_string
7907
+ local newString = fields.new_string
7908
+ local startLine = fields.startLine
7909
+ if not (type(oldString) == "string") or not (type(newString) == "string") then
7910
+ return `edits[{index}] requires old_string and new_string`
7911
+ end
7912
+ if startLine ~= nil and not (type(startLine) == "number") then
7913
+ return `edits[{index}].startLine must be a number`
7914
+ end
7915
+ local _arg0 = {
7916
+ oldString = oldString,
7917
+ newString = newString,
7918
+ startLine = startLine,
7919
+ }
7920
+ table.insert(edits, _arg0)
7921
+ end
7922
+ if #edits == 0 then
7923
+ return "edits must contain at least one edit"
7924
+ end
7925
+ return edits
7926
+ end
7824
7927
  local function editScriptLines(requestData)
7825
7928
  local instancePath = requestData.instancePath
7826
- local oldString = requestData.old_string
7827
- local newString = requestData.new_string
7828
- local startLine = requestData.startLine
7829
- if not (instancePath ~= "" and instancePath) or oldString == nil or newString == nil then
7929
+ local expectedRevision = requestData.expectedRevision
7930
+ local dryRun = requestData.dryRun == true
7931
+ if not (instancePath ~= "" and instancePath) then
7830
7932
  return {
7831
- error = "Instance path, old_string, and new_string are required",
7933
+ error = "Instance path is required",
7934
+ }
7935
+ end
7936
+ local edits = readScriptEdits(requestData)
7937
+ if type(edits) == "string" then
7938
+ return {
7939
+ error = edits,
7940
+ }
7941
+ end
7942
+ if expectedRevision ~= nil and not (type(expectedRevision) == "string") then
7943
+ return {
7944
+ error = "expectedRevision must be a string",
7832
7945
  }
7833
7946
  end
7834
7947
  local instance = getInstanceByPath(instancePath)
@@ -7842,76 +7955,63 @@ local function editScriptLines(requestData)
7842
7955
  error = `Instance is not a script-like object: {instance.ClassName}`,
7843
7956
  }
7844
7957
  end
7845
- local recordingId = beginRecording(`Edit script: {instance.Name}`)
7846
- local success, result = pcall(function()
7847
- local source = readScriptSource(instance)
7848
- local searchLen = #oldString
7849
- local matchStart
7850
- if startLine ~= nil then
7851
- if startLine < 1 then
7852
- error(`startLine must be >= 1 (got {startLine})`)
7853
- end
7854
- local lineStartByte = 1
7855
- local currentLine = 1
7856
- while currentLine < startLine do
7857
- local nlPos = string.find(source, "\n", lineStartByte, true)
7858
- if nlPos == nil then
7859
- error(`startLine {startLine} is past end of script ({currentLine} lines)`)
7860
- end
7861
- lineStartByte = nlPos + 1
7862
- currentLine += 1
7863
- end
7864
- local candidate = string.sub(source, lineStartByte, lineStartByte + searchLen - 1)
7865
- if candidate ~= oldString then
7866
- error(`old_string does not match at line {startLine}. Use get_script_source to verify the exact text at that line.`)
7867
- end
7868
- matchStart = lineStartByte
7869
- else
7870
- local count = 0
7871
- local searchPos = 1
7872
- local firstMatch
7873
- while true do
7874
- local foundStart = string.find(source, oldString, searchPos, true)
7875
- if foundStart == nil then
7876
- break
7877
- end
7878
- if firstMatch == nil then
7879
- firstMatch = foundStart
7880
- end
7881
- count += 1
7882
- if count > 1 then
7883
- break
7884
- end
7885
- searchPos = foundStart + searchLen
7886
- end
7887
- if count == 0 then
7888
- error("old_string not found in script. If old_string contains repeated patterns (e.g. closing braces), pass startLine to anchor the edit.")
7889
- end
7890
- if count > 1 then
7891
- error("old_string matches multiple locations. Provide more surrounding context, or pass startLine to anchor the edit to a specific line.")
7892
- end
7893
- matchStart = firstMatch
7894
- end
7895
- -- Byte-slice replacement avoids Lua pattern escaping (safe for multi-byte chars like em dashes).
7896
- local newSource = string.sub(source, 1, matchStart - 1) .. newString .. string.sub(source, matchStart + searchLen)
7897
- local applyResult = applyScriptSource(instance, newSource, source)
7898
- if not applyResult.success then
7899
- error(applyResult.error)
7900
- end
7958
+ local readSuccess, readResult = pcall(function()
7959
+ return readScriptSource(instance)
7960
+ end)
7961
+ if not readSuccess then
7901
7962
  return {
7963
+ error = `Failed to edit script: {readResult}`,
7964
+ }
7965
+ end
7966
+ local source = readResult
7967
+ local revision = sourceRevision(source)
7968
+ if expectedRevision ~= nil and expectedRevision ~= revision then
7969
+ return {
7970
+ error = "Script changed since expected_revision; read it again with get_script_source and retry.",
7971
+ code = "revision_mismatch",
7972
+ instancePath = instancePath,
7973
+ expectedRevision = expectedRevision,
7974
+ revision = revision,
7975
+ }
7976
+ end
7977
+ local plan = planScriptEdits(source, edits, dryRun)
7978
+ if not plan.ok then
7979
+ return {
7980
+ error = `Failed to edit script: {plan.error}`,
7981
+ }
7982
+ end
7983
+ if dryRun then
7984
+ local preview = {
7902
7985
  success = true,
7986
+ dryRun = true,
7903
7987
  instancePath = instancePath,
7904
- method = applyResult.method,
7905
- message = "Script edited successfully",
7988
+ editCount = #edits,
7989
+ revision = revision,
7990
+ resultRevision = sourceRevision(plan.newSource),
7991
+ diff = plan.diff,
7906
7992
  }
7907
- end)
7908
- if success then
7909
- finishRecording(recordingId, true)
7910
- return result
7993
+ if plan.diffTruncated then
7994
+ preview.diffTruncated = true
7995
+ end
7996
+ return preview
7911
7997
  end
7912
- finishRecording(recordingId, false)
7998
+ local recordingId = beginRecording(if #edits > 1 then `Edit script ({#edits} edits): {instance.Name}` else `Edit script: {instance.Name}`)
7999
+ -- One compare-and-set write: a draft typed since the read rejects the whole batch.
8000
+ local applyResult = applyScriptSource(instance, plan.newSource, source)
8001
+ if not applyResult.success then
8002
+ finishRecording(recordingId, false)
8003
+ return {
8004
+ error = `Failed to edit script: {applyResult.error}`,
8005
+ }
8006
+ end
8007
+ finishRecording(recordingId, true)
7913
8008
  return {
7914
- error = `Failed to edit script: {result}`,
8009
+ success = true,
8010
+ instancePath = instancePath,
8011
+ editCount = #edits,
8012
+ revision = sourceRevision(plan.newSource),
8013
+ method = applyResult.method,
8014
+ message = "Script edited successfully",
7915
8015
  }
7916
8016
  end
7917
8017
  local function insertScriptLines(requestData)
@@ -9432,6 +9532,166 @@ return {
9432
9532
  </Properties>
9433
9533
  </Item>
9434
9534
  <Item class="ModuleScript" referent="27">
9535
+ <Properties>
9536
+ <string name="Name">InstanceSearch</string>
9537
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9538
+ -- Bounded instance search behind search_objects. It walks one subtree in
9539
+ -- GetChildren order, applies an optional IsA filter, and stops after
9540
+ -- maxResults matches. An ambiguous `path` is reported by PathGuard before this
9541
+ -- runs. It is written against an injected corpus so it can run outside Studio.
9542
+ -- Function properties rather than methods, so plain functions can be assigned
9543
+ -- directly under roblox-ts.
9544
+ local SEARCH_TYPES = {
9545
+ ["name"] = true,
9546
+ ["class"] = true,
9547
+ ["property"] = true,
9548
+ }
9549
+ local DEFAULT_MAX_RESULTS = 100
9550
+ local MAX_RESULTS = 10000
9551
+ local function invalid(message)
9552
+ return {
9553
+ error = "invalid_request",
9554
+ message = message,
9555
+ }
9556
+ end
9557
+ local function containsText(haystack, needle)
9558
+ return (string.find(string.lower(haystack), needle, 1, true)) ~= nil
9559
+ end
9560
+ local function createInstanceSearch(corpus)
9561
+ local function search(requestData, control)
9562
+ local rawQuery = requestData.query
9563
+ if rawQuery ~= nil and not (type(rawQuery) == "string") then
9564
+ return invalid("query must be a string")
9565
+ end
9566
+ local _condition = rawQuery
9567
+ if _condition == nil then
9568
+ _condition = ""
9569
+ end
9570
+ local query = _condition
9571
+ local _condition_1 = requestData.searchType
9572
+ if _condition_1 == nil then
9573
+ _condition_1 = "name"
9574
+ end
9575
+ local rawSearchType = _condition_1
9576
+ if not (type(rawSearchType) == "string") or not (SEARCH_TYPES[rawSearchType] ~= nil) then
9577
+ return invalid("searchType must be one of name, class, property")
9578
+ end
9579
+ local searchType = rawSearchType
9580
+ local propertyName = requestData.propertyName
9581
+ if searchType == "property" and (not (type(propertyName) == "string") or propertyName == "") then
9582
+ return invalid("propertyName is required when searchType is property")
9583
+ end
9584
+ local className = requestData.className
9585
+ if className ~= nil and (not (type(className) == "string") or className == "") then
9586
+ return invalid("className must be a non-empty string")
9587
+ end
9588
+ if query == "" and className == nil then
9589
+ return {
9590
+ error = "query is required unless className is set",
9591
+ }
9592
+ end
9593
+ local requestedMaxResults = requestData.maxResults
9594
+ if requestedMaxResults ~= nil and (not (type(requestedMaxResults) == "number") or math.floor(requestedMaxResults) ~= requestedMaxResults or requestedMaxResults < 1 or requestedMaxResults > MAX_RESULTS) then
9595
+ return invalid(`maxResults must be an integer between 1 and {MAX_RESULTS}`)
9596
+ end
9597
+ local _condition_2 = requestedMaxResults
9598
+ if _condition_2 == nil then
9599
+ _condition_2 = DEFAULT_MAX_RESULTS
9600
+ end
9601
+ local maxResults = _condition_2
9602
+ local rawPath = requestData.path
9603
+ if rawPath ~= nil and not (type(rawPath) == "string") then
9604
+ return invalid("path must be a string")
9605
+ end
9606
+ local _condition_3 = rawPath
9607
+ if _condition_3 == nil then
9608
+ _condition_3 = ""
9609
+ end
9610
+ local searchPath = _condition_3
9611
+ local root = corpus.resolveRoot(searchPath)
9612
+ if root == nil then
9613
+ return {
9614
+ error = `Path not found: {searchPath}`,
9615
+ }
9616
+ end
9617
+ local needle = string.lower(query)
9618
+ local isAmbiguous = corpus.createAmbiguityChecker()
9619
+ local function matches(node)
9620
+ if className ~= nil and not corpus.isA(node, className) then
9621
+ return false
9622
+ end
9623
+ if needle == "" then
9624
+ return true
9625
+ end
9626
+ if searchType == "name" then
9627
+ return containsText(corpus.getName(node), needle)
9628
+ end
9629
+ if searchType == "class" then
9630
+ return containsText(corpus.getClassName(node), needle)
9631
+ end
9632
+ local text = corpus.readPropertyText(node, propertyName)
9633
+ return text ~= nil and containsText(text, needle)
9634
+ end
9635
+ local results = {}
9636
+ local stack = { root }
9637
+ local truncated = false
9638
+ while #stack > 0 do
9639
+ control:checkpoint()
9640
+ -- ▼ Array.pop ▼
9641
+ local _length = #stack
9642
+ local _result = stack[_length]
9643
+ stack[_length] = nil
9644
+ -- ▲ Array.pop ▲
9645
+ local node = _result
9646
+ if matches(node) then
9647
+ if #results >= maxResults then
9648
+ truncated = true
9649
+ break
9650
+ end
9651
+ local entry = {
9652
+ name = corpus.getName(node),
9653
+ className = corpus.getClassName(node),
9654
+ path = corpus.getPath(node),
9655
+ }
9656
+ if isAmbiguous(node) then
9657
+ entry.ambiguous = true
9658
+ end
9659
+ table.insert(results, entry)
9660
+ end
9661
+ local children = corpus.getChildren(node)
9662
+ for index = #children - 1, 0, -1 do
9663
+ local _arg0 = children[index + 1]
9664
+ table.insert(stack, _arg0)
9665
+ end
9666
+ end
9667
+ local response = {
9668
+ results = results,
9669
+ query = query,
9670
+ searchType = searchType,
9671
+ count = #results,
9672
+ truncated = truncated,
9673
+ maxResults = maxResults,
9674
+ path = corpus.getPath(root),
9675
+ }
9676
+ if searchType == "property" then
9677
+ response.propertyName = propertyName
9678
+ end
9679
+ if className ~= nil then
9680
+ response.className = className
9681
+ end
9682
+ return response
9683
+ end
9684
+ return {
9685
+ search = search,
9686
+ }
9687
+ end
9688
+ return {
9689
+ createInstanceSearch = createInstanceSearch,
9690
+ }
9691
+ ]]></string>
9692
+ </Properties>
9693
+ </Item>
9694
+ <Item class="ModuleScript" referent="28">
9435
9695
  <Properties>
9436
9696
  <string name="Name">LuauExec</string>
9437
9697
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9865,7 +10125,331 @@ return {
9865
10125
  ]]></string>
9866
10126
  </Properties>
9867
10127
  </Item>
9868
- <Item class="ModuleScript" referent="28">
10128
+ <Item class="ModuleScript" referent="29">
10129
+ <Properties>
10130
+ <string name="Name">PathAmbiguity</string>
10131
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10132
+ -- Canonical instance paths address children by name, so siblings that share a
10133
+ -- name produce identical paths. This module resolves paths while reporting the
10134
+ -- first ambiguous segment, and marks emitted paths that cannot identify one
10135
+ -- instance. It is written against an injected tree so it can run outside Studio.
10136
+ local function findNamedChild(tree, parent, name)
10137
+ local first
10138
+ local count = 0
10139
+ for _, child in tree.getChildren(parent) do
10140
+ if tree.getName(child) == name then
10141
+ if first == nil then
10142
+ first = child
10143
+ end
10144
+ count += 1
10145
+ end
10146
+ end
10147
+ return { first, count }
10148
+ end
10149
+ -- Mirrors Utils.getInstanceByPath: the root segment prefers service lookup and
10150
+ -- later segments take the first child with a matching name. The resolved
10151
+ -- instance is returned even when ambiguous, so callers decide whether to refuse.
10152
+ local function resolvePath(tree, parts)
10153
+ if #parts == 0 then
10154
+ return {
10155
+ instance = tree.root,
10156
+ }
10157
+ end
10158
+ local ambiguity
10159
+ local current = tree.getService(parts[1])
10160
+ if current == nil then
10161
+ local _binding = findNamedChild(tree, tree.root, parts[1])
10162
+ local first = _binding[1]
10163
+ local count = _binding[2]
10164
+ current = first
10165
+ if count > 1 then
10166
+ ambiguity = {
10167
+ segment = parts[1],
10168
+ parentPath = tree.getPath(tree.root),
10169
+ matchCount = count,
10170
+ }
10171
+ end
10172
+ end
10173
+ for i = 1, #parts - 1 do
10174
+ if current == nil then
10175
+ break
10176
+ end
10177
+ local _binding = findNamedChild(tree, current, parts[i + 1])
10178
+ local first = _binding[1]
10179
+ local count = _binding[2]
10180
+ if count > 1 and ambiguity == nil then
10181
+ ambiguity = {
10182
+ segment = parts[i + 1],
10183
+ parentPath = tree.getPath(current),
10184
+ matchCount = count,
10185
+ }
10186
+ end
10187
+ current = first
10188
+ end
10189
+ return {
10190
+ instance = current,
10191
+ ambiguity = ambiguity,
10192
+ }
10193
+ end
10194
+ -- Returns a checker for one request. Sibling name counts and verdicts are
10195
+ -- cached per node, so marking many results under one parent stays linear.
10196
+ local function createAmbiguityChecker(tree)
10197
+ local nameCounts = {}
10198
+ local verdicts = {}
10199
+ local function countNamed(parent, name)
10200
+ local _parent = parent
10201
+ local counts = nameCounts[_parent]
10202
+ if counts == nil then
10203
+ counts = {}
10204
+ for _, child in tree.getChildren(parent) do
10205
+ local childName = tree.getName(child)
10206
+ local _counts = counts
10207
+ local _condition = counts[childName]
10208
+ if _condition == nil then
10209
+ _condition = 0
10210
+ end
10211
+ _counts[childName] = _condition + 1
10212
+ end
10213
+ local _parent_1 = parent
10214
+ local _counts = counts
10215
+ nameCounts[_parent_1] = _counts
10216
+ end
10217
+ local _counts = counts
10218
+ local _name = name
10219
+ local _condition = _counts[_name]
10220
+ if _condition == nil then
10221
+ _condition = 0
10222
+ end
10223
+ return _condition
10224
+ end
10225
+ local function segmentIsAmbiguous(node, parent)
10226
+ local name = tree.getName(node)
10227
+ if parent == tree.root then
10228
+ if tree.isRootService(node) then
10229
+ return false
10230
+ end
10231
+ -- A root segment that names a service resolves to that service,
10232
+ -- never to this child.
10233
+ local service = tree.getService(name)
10234
+ if service ~= nil and service ~= node then
10235
+ return true
10236
+ end
10237
+ end
10238
+ return countNamed(parent, name) > 1
10239
+ end
10240
+ return function(node)
10241
+ local visited = {}
10242
+ local result = false
10243
+ local current = node
10244
+ while current ~= tree.root do
10245
+ local _current = current
10246
+ local cached = verdicts[_current]
10247
+ if cached ~= nil then
10248
+ result = cached
10249
+ break
10250
+ end
10251
+ local parent = tree.getParent(current)
10252
+ if parent == nil then
10253
+ break
10254
+ end
10255
+ local _current_1 = current
10256
+ table.insert(visited, _current_1)
10257
+ if segmentIsAmbiguous(current, parent) then
10258
+ result = true
10259
+ break
10260
+ end
10261
+ current = parent
10262
+ end
10263
+ for _, visitedNode in visited do
10264
+ local _result = result
10265
+ verdicts[visitedNode] = _result
10266
+ end
10267
+ return result
10268
+ end
10269
+ end
10270
+ return {
10271
+ resolvePath = resolvePath,
10272
+ createAmbiguityChecker = createAmbiguityChecker,
10273
+ }
10274
+ ]]></string>
10275
+ </Properties>
10276
+ </Item>
10277
+ <Item class="ModuleScript" referent="30">
10278
+ <Properties>
10279
+ <string name="Name">PathGuard</string>
10280
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10281
+ -- Central handling of request paths that name more than one instance.
10282
+ -- Every endpoint that accepts instance paths declares them here. Before the
10283
+ -- handler runs, each declared path is checked:
10284
+ -- - write: an ambiguous path refuses the request, so no arbitrary duplicate
10285
+ -- is modified;
10286
+ -- - read: the handler runs on the first match, and the response gains
10287
+ -- `ambiguousPaths` and `pathWarning` so the caller knows it may be wrong.
10288
+ -- Written against an injected resolver so it can run outside Studio.
10289
+ local function pathField(field, access)
10290
+ return function(data)
10291
+ local value = data[field]
10292
+ if not (type(value) == "string") or value == "" then
10293
+ return {}
10294
+ end
10295
+ local _object = {
10296
+ field = field,
10297
+ path = value,
10298
+ }
10299
+ local _left = "access"
10300
+ local _access = access
10301
+ _object[_left] = if type(_access) == "string" then access else access(data)
10302
+ return { _object }
10303
+ end
10304
+ end
10305
+ local function pathListField(field, access)
10306
+ return function(data)
10307
+ local value = data[field]
10308
+ local references = {}
10309
+ if not (type(value) == "table") then
10310
+ return references
10311
+ end
10312
+ local index = 0
10313
+ for _, entry in value do
10314
+ if type(entry) == "string" and entry ~= "" then
10315
+ local _arg0 = {
10316
+ field = `{field}[{index}]`,
10317
+ path = entry,
10318
+ access = access,
10319
+ }
10320
+ table.insert(references, _arg0)
10321
+ end
10322
+ index += 1
10323
+ end
10324
+ return references
10325
+ end
10326
+ end
10327
+ -- set_properties takes instance paths as the values of reference properties.
10328
+ local REFERENCE_PROPERTIES = { "Parent", "PrimaryPart" }
10329
+ local function referencePropertyPaths(data)
10330
+ local properties = data.properties
10331
+ local references = {}
10332
+ if not (type(properties) == "table") then
10333
+ return references
10334
+ end
10335
+ for _, propertyName in REFERENCE_PROPERTIES do
10336
+ local value = properties[propertyName]
10337
+ if type(value) == "string" and value ~= "" then
10338
+ local _arg0 = {
10339
+ field = `properties.{propertyName}`,
10340
+ path = value,
10341
+ access = "write",
10342
+ }
10343
+ table.insert(references, _arg0)
10344
+ end
10345
+ end
10346
+ return references
10347
+ end
10348
+ local dryRunIsRead = function(data)
10349
+ return if data.dryRun == true then "read" else "write"
10350
+ end
10351
+ local ENDPOINT_PATHS = {
10352
+ ["/api/file-tree"] = { pathField("path", "read") },
10353
+ ["/api/search-objects"] = { pathField("path", "read") },
10354
+ ["/api/instance-properties"] = { pathField("instancePath", "read") },
10355
+ ["/api/project-structure"] = { pathField("path", "read") },
10356
+ ["/api/grep-scripts"] = { pathField("path", "read") },
10357
+ ["/api/get-script-source"] = { pathField("instancePath", "read") },
10358
+ ["/api/get-attributes"] = { pathField("instancePath", "read") },
10359
+ ["/api/set-selection"] = { pathListField("paths", "read") },
10360
+ ["/api/focus-viewport"] = { pathField("path", "read") },
10361
+ ["/api/breakpoints"] = { pathField("script_path", "read") },
10362
+ ["/api/export-rbxm"] = { pathListField("instance_paths", "read") },
10363
+ ["/api/set-properties"] = { pathField("instancePath", "write"), referencePropertyPaths },
10364
+ ["/api/set-script-source"] = { pathField("instancePath", "write") },
10365
+ ["/api/edit-script-lines"] = { pathField("instancePath", dryRunIsRead) },
10366
+ ["/api/insert-script-lines"] = { pathField("instancePath", "write") },
10367
+ ["/api/delete-script-lines"] = { pathField("instancePath", "write") },
10368
+ ["/api/find-and-replace-in-scripts"] = { pathField("path", dryRunIsRead) },
10369
+ ["/api/insert-asset"] = { pathField("parentPath", "write") },
10370
+ ["/api/import-rbxm"] = { pathField("parent_path", "write") },
10371
+ }
10372
+ local function describe(report)
10373
+ return `{report.path} ({report.field}): {report.matchCount} children of {report.parentPath} are named "{report.segment}"`
10374
+ end
10375
+ local function describeAll(reports)
10376
+ local parts = {}
10377
+ for _, report in reports do
10378
+ local _arg0 = describe(report)
10379
+ table.insert(parts, _arg0)
10380
+ end
10381
+ return table.concat(parts, "; ")
10382
+ end
10383
+ local function createPathGuard(findAmbiguity)
10384
+ local function inspect(endpoint, data)
10385
+ local reports = {}
10386
+ local extractors = ENDPOINT_PATHS[endpoint]
10387
+ if extractors == nil then
10388
+ return reports
10389
+ end
10390
+ for _, extract in extractors do
10391
+ for _1, reference in extract(data) do
10392
+ local ambiguity = findAmbiguity(reference.path)
10393
+ if ambiguity ~= nil then
10394
+ local _arg0 = {
10395
+ field = reference.field,
10396
+ path = reference.path,
10397
+ access = reference.access,
10398
+ segment = ambiguity.segment,
10399
+ parentPath = ambiguity.parentPath,
10400
+ matchCount = ambiguity.matchCount,
10401
+ }
10402
+ table.insert(reports, _arg0)
10403
+ end
10404
+ end
10405
+ end
10406
+ return reports
10407
+ end
10408
+ local function run(endpoint, data, handle)
10409
+ local reports = inspect(endpoint, data)
10410
+ if #reports == 0 then
10411
+ return handle()
10412
+ end
10413
+ -- ▼ ReadonlyArray.filter ▼
10414
+ local _newValue = {}
10415
+ local _callback = function(report)
10416
+ return report.access == "write"
10417
+ end
10418
+ local _length = 0
10419
+ for _k, _v in reports do
10420
+ if _callback(_v, _k - 1, reports) == true then
10421
+ _length += 1
10422
+ _newValue[_length] = _v
10423
+ end
10424
+ end
10425
+ -- ▲ ReadonlyArray.filter ▲
10426
+ local writes = _newValue
10427
+ if #writes > 0 then
10428
+ return {
10429
+ error = `Ambiguous path {describeAll(writes)}. Refusing to modify an arbitrary match; rename the duplicates or target the instance with execute_luau.`,
10430
+ ambiguousPaths = writes,
10431
+ }
10432
+ end
10433
+ local result = handle()
10434
+ if type(result) == "table" then
10435
+ local response = result
10436
+ response.ambiguousPaths = reports
10437
+ response.pathWarning = `Ambiguous path {describeAll(reports)}; the first match was used.`
10438
+ end
10439
+ return result
10440
+ end
10441
+ return {
10442
+ inspect = inspect,
10443
+ run = run,
10444
+ }
10445
+ end
10446
+ return {
10447
+ createPathGuard = createPathGuard,
10448
+ }
10449
+ ]]></string>
10450
+ </Properties>
10451
+ </Item>
10452
+ <Item class="ModuleScript" referent="31">
9869
10453
  <Properties>
9870
10454
  <string name="Name">PeerRole</string>
9871
10455
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9886,7 +10470,7 @@ return {
9886
10470
  ]]></string>
9887
10471
  </Properties>
9888
10472
  </Item>
9889
- <Item class="ModuleScript" referent="29">
10473
+ <Item class="ModuleScript" referent="32">
9890
10474
  <Properties>
9891
10475
  <string name="Name">PlaytestTickets</string>
9892
10476
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10067,7 +10651,7 @@ return {
10067
10651
  ]]></string>
10068
10652
  </Properties>
10069
10653
  </Item>
10070
- <Item class="ModuleScript" referent="30">
10654
+ <Item class="ModuleScript" referent="33">
10071
10655
  <Properties>
10072
10656
  <string name="Name">PluginSession</string>
10073
10657
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10092,10 +10676,12 @@ local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
10092
10676
  -- saved or published DataModel, so edit sessions remove them on load.
10093
10677
  local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
10094
10678
  local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
10095
- -- Attributes on the CoreGui identity value: CoreGui survives plugin reloads but
10096
- -- is never saved with the place or copied into playtest DataModels.
10097
- local PLAY_RECORD_ATTRIBUTE = "PlayRecord"
10098
- local RUNTIME_TOPOLOGY_ATTRIBUTE = "RuntimeTopology"
10679
+ -- JSON session state lives in StringValue children of the CoreGui identity
10680
+ -- value: CoreGui survives plugin reloads but is never saved with the place or
10681
+ -- copied into playtest DataModels. Not attributes: with NextGenerationReplication,
10682
+ -- playtest DataModels reject attribute strings longer than 50 characters.
10683
+ local PLAY_RECORD_NAME = "PlayRecord"
10684
+ local RUNTIME_TOPOLOGY_NAME = "RuntimeTopology"
10099
10685
  -- MCP-started solo and Run playtests carry their topology in StudioTestService
10100
10686
  -- test args, which never touch the DataModel.
10101
10687
  local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
@@ -10216,18 +10802,54 @@ local function optionalString(value)
10216
10802
  end
10217
10803
  return if _condition then value else nil
10218
10804
  end
10219
- function readPlayRecord()
10220
- local raw = sessionIdentity:GetAttribute(PLAY_RECORD_ATTRIBUTE)
10221
- if not (type(raw) == "string") then
10805
+ local function readSessionState(name)
10806
+ local holder = sessionIdentity:FindFirstChild(name)
10807
+ if holder == nil or not holder:IsA("StringValue") then
10222
10808
  return nil
10223
10809
  end
10224
10810
  local ok, decoded = pcall(function()
10225
- return HttpService:JSONDecode(raw)
10811
+ return HttpService:JSONDecode(holder.Value)
10226
10812
  end)
10227
10813
  if not ok or not (type(decoded) == "table") then
10228
10814
  return nil
10229
10815
  end
10230
- local data = decoded
10816
+ return decoded
10817
+ end
10818
+ --* Best effort: saved session state only lets a plugin reload resume where it left off.
10819
+ local function writeSessionState(name, state)
10820
+ local ok, err = pcall(function()
10821
+ local existing = sessionIdentity:FindFirstChild(name)
10822
+ if state == nil then
10823
+ local _result = existing
10824
+ if _result ~= nil then
10825
+ _result:Destroy()
10826
+ end
10827
+ return nil
10828
+ end
10829
+ local encoded = HttpService:JSONEncode(state)
10830
+ if existing ~= nil and existing:IsA("StringValue") then
10831
+ existing.Value = encoded
10832
+ return nil
10833
+ end
10834
+ local _result = existing
10835
+ if _result ~= nil then
10836
+ _result:Destroy()
10837
+ end
10838
+ local holder = Instance.new("StringValue")
10839
+ holder.Name = name
10840
+ holder.Value = encoded
10841
+ holder.Archivable = false
10842
+ holder.Parent = sessionIdentity
10843
+ end)
10844
+ if not ok then
10845
+ warn(`[robloxstudio-mcp] Could not save {name} session state: {err}`)
10846
+ end
10847
+ end
10848
+ function readPlayRecord()
10849
+ local data = readSessionState(PLAY_RECORD_NAME)
10850
+ if data == nil then
10851
+ return nil
10852
+ end
10231
10853
  local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
10232
10854
  if phase == nil then
10233
10855
  return nil
@@ -10243,16 +10865,7 @@ function readPlayRecord()
10243
10865
  }
10244
10866
  end
10245
10867
  local function writePlayRecord(record)
10246
- if record == nil then
10247
- sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, nil)
10248
- return nil
10249
- end
10250
- local ok, encoded = pcall(function()
10251
- return HttpService:JSONEncode(record)
10252
- end)
10253
- if ok and type(encoded) == "string" then
10254
- sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, encoded)
10255
- end
10868
+ writeSessionState(PLAY_RECORD_NAME, record)
10256
10869
  end
10257
10870
  local function createTicket(instanceId, groupId)
10258
10871
  local now = PlaytestTickets.nowMs()
@@ -10544,17 +11157,10 @@ local function topologyFromTestArgs()
10544
11157
  }
10545
11158
  end
10546
11159
  local function readRememberedRuntimeTopology()
10547
- local raw = sessionIdentity:GetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE)
10548
- if not (type(raw) == "string") then
11160
+ local data = readSessionState(RUNTIME_TOPOLOGY_NAME)
11161
+ if data == nil then
10549
11162
  return nil
10550
11163
  end
10551
- local ok, decoded = pcall(function()
10552
- return HttpService:JSONDecode(raw)
10553
- end)
10554
- if not ok or not (type(decoded) == "table") then
10555
- return nil
10556
- end
10557
- local data = decoded
10558
11164
  return {
10559
11165
  instanceId = optionalString(data.instanceId),
10560
11166
  groupId = optionalString(data.groupId),
@@ -10564,12 +11170,7 @@ local function readRememberedRuntimeTopology()
10564
11170
  end
10565
11171
  local function adoptRuntimeTopology(topology)
10566
11172
  runtimeTopology = topology
10567
- local ok, encoded = pcall(function()
10568
- return HttpService:JSONEncode(topology)
10569
- end)
10570
- if ok and type(encoded) == "string" then
10571
- sessionIdentity:SetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE, encoded)
10572
- end
11173
+ writeSessionState(RUNTIME_TOPOLOGY_NAME, topology)
10573
11174
  end
10574
11175
  --[[
10575
11176
  *
@@ -10717,7 +11318,7 @@ return {
10717
11318
  ]]></string>
10718
11319
  </Properties>
10719
11320
  </Item>
10720
- <Item class="ModuleScript" referent="31">
11321
+ <Item class="ModuleScript" referent="34">
10721
11322
  <Properties>
10722
11323
  <string name="Name">Recording</string>
10723
11324
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10747,7 +11348,7 @@ return {
10747
11348
  ]]></string>
10748
11349
  </Properties>
10749
11350
  </Item>
10750
- <Item class="ModuleScript" referent="32">
11351
+ <Item class="ModuleScript" referent="35">
10751
11352
  <Properties>
10752
11353
  <string name="Name">RenderMonitor</string>
10753
11354
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10815,7 +11416,7 @@ return {
10815
11416
  ]]></string>
10816
11417
  </Properties>
10817
11418
  </Item>
10818
- <Item class="ModuleScript" referent="33">
11419
+ <Item class="ModuleScript" referent="36">
10819
11420
  <Properties>
10820
11421
  <string name="Name">RuntimeLogBuffer</string>
10821
11422
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11051,7 +11652,357 @@ return {
11051
11652
  ]]></string>
11052
11653
  </Properties>
11053
11654
  </Item>
11054
- <Item class="ModuleScript" referent="34">
11655
+ <Item class="ModuleScript" referent="37">
11656
+ <Properties>
11657
+ <string name="Name">ScriptEdits</string>
11658
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11659
+ -- Exact-text script edits resolved against one source snapshot, so several
11660
+ -- replacements either all apply or none do. Kept free of Studio services so
11661
+ -- the planning logic can be exercised outside Studio.
11662
+ local FNV_OFFSET_BASIS = 2166136261
11663
+ local UINT32_MODULUS = 4294967296
11664
+ local BYTE_CHUNK = 4096
11665
+ local DIFF_CHAR_BUDGET = 20000
11666
+ --[[
11667
+ *
11668
+ * Content revision of the exact script text: byte length plus FNV-1a 32.
11669
+ * It is an optimistic-concurrency token, not a security hash.
11670
+
11671
+ ]]
11672
+ local function sourceRevision(source)
11673
+ local length = #source
11674
+ local hash = FNV_OFFSET_BASIS
11675
+ do
11676
+ local chunkStart = 1
11677
+ local _shouldIncrement = false
11678
+ while true do
11679
+ if _shouldIncrement then
11680
+ chunkStart += BYTE_CHUNK
11681
+ else
11682
+ _shouldIncrement = true
11683
+ end
11684
+ if not (chunkStart <= length) then
11685
+ break
11686
+ end
11687
+ local _array = {}
11688
+ local _length = #_array
11689
+ local _array_1 = { string.byte(source, chunkStart, math.min(chunkStart + BYTE_CHUNK - 1, length)) }
11690
+ table.move(_array_1, 1, #_array_1, _length + 1, _array)
11691
+ local bytes = _array
11692
+ for _, byte in bytes do
11693
+ hash = bit32.bxor(hash, byte)
11694
+ -- hash * 16777619 mod 2^32. The FNV prime is 2^24 + 403; splitting the
11695
+ -- product keeps every intermediate below 2^53, where doubles stay exact.
11696
+ hash = (bit32.lshift(hash, 24) + hash * 403) % UINT32_MODULUS
11697
+ end
11698
+ end
11699
+ end
11700
+ return `{length}:{hash}`
11701
+ end
11702
+ -- 1-based byte offset at which each line starts. A trailing newline yields a
11703
+ -- final empty line, which is where an anchored insertion at EOF lands.
11704
+ local function lineStarts(source)
11705
+ local starts = { 1 }
11706
+ local searchFrom = 1
11707
+ while true do
11708
+ local newline = string.find(source, "\n", searchFrom, true)
11709
+ if newline == nil then
11710
+ break
11711
+ end
11712
+ local _arg0 = newline + 1
11713
+ table.insert(starts, _arg0)
11714
+ searchFrom = newline + 1
11715
+ end
11716
+ return starts
11717
+ end
11718
+ -- Line containing a byte offset; a newline belongs to the line it ends.
11719
+ local function lineAt(starts, offset)
11720
+ local low = 0
11721
+ local high = #starts - 1
11722
+ while low < high do
11723
+ local middle = math.floor((low + high + 1) / 2)
11724
+ if starts[middle + 1] <= offset then
11725
+ low = middle
11726
+ else
11727
+ high = middle - 1
11728
+ end
11729
+ end
11730
+ return low + 1
11731
+ end
11732
+ -- Byte offset just past the line's terminator, or past the end of the source.
11733
+ local function lineEnd(starts, source, line)
11734
+ return if line < #starts then starts[line + 1] else #source + 1
11735
+ end
11736
+ local function locateEdit(source, starts, edit, label)
11737
+ local searchLength = #edit.oldString
11738
+ if edit.startLine ~= nil then
11739
+ if edit.startLine < 1 or edit.startLine % 1 ~= 0 then
11740
+ return `{label}line must be a positive whole number (got {edit.startLine}).`
11741
+ end
11742
+ if edit.startLine > #starts then
11743
+ return `{label}line {edit.startLine} is past the end of the script.`
11744
+ end
11745
+ local start = starts[edit.startLine]
11746
+ if string.sub(source, start, start + searchLength - 1) ~= edit.oldString then
11747
+ return `{label}old_string does not match at line {edit.startLine}. Use get_script_source to verify the exact text at that line.`
11748
+ end
11749
+ return start
11750
+ end
11751
+ if searchLength == 0 then
11752
+ return `{label}old_string is empty; pass line_range to insert at the start of a line.`
11753
+ end
11754
+ local first = string.find(source, edit.oldString, 1, true)
11755
+ if first == nil then
11756
+ return `{label}old_string not found in script. If old_string contains repeated patterns (e.g. closing braces), pass line_range to anchor the edit.`
11757
+ end
11758
+ local second = string.find(source, edit.oldString, first + searchLength, true)
11759
+ if second ~= nil then
11760
+ return `{label}old_string matches multiple locations (lines {lineAt(starts, first)} and {lineAt(starts, second)}). Provide more surrounding context, or pass line_range to anchor the edit.`
11761
+ end
11762
+ return first
11763
+ end
11764
+ -- Source text in [regionStart, regionEnd) with every edit in resolved[from, to) applied.
11765
+ local function applyWithin(source, resolved, from, to, regionStart, regionEnd)
11766
+ local parts = {}
11767
+ local cursor = regionStart
11768
+ do
11769
+ local index = from
11770
+ local _shouldIncrement = false
11771
+ while true do
11772
+ if _shouldIncrement then
11773
+ index += 1
11774
+ else
11775
+ _shouldIncrement = true
11776
+ end
11777
+ if not (index < to) then
11778
+ break
11779
+ end
11780
+ local edit = resolved[index + 1]
11781
+ local _arg0 = string.sub(source, cursor, edit.start - 1)
11782
+ table.insert(parts, _arg0)
11783
+ local _newString = edit.newString
11784
+ table.insert(parts, _newString)
11785
+ cursor = edit.finish
11786
+ end
11787
+ end
11788
+ local _arg0 = string.sub(source, cursor, regionEnd - 1)
11789
+ table.insert(parts, _arg0)
11790
+ return table.concat(parts, "")
11791
+ end
11792
+ local NO_FINAL_NEWLINE = "\"
11793
+ -- Lines of a diff region. A last line without its newline only occurs at the
11794
+ -- end of the file; it is flagged so that adding or removing that newline still
11795
+ -- differs, as in a unified diff.
11796
+ local function textLines(text)
11797
+ if text == "" then
11798
+ return {
11799
+ lines = {},
11800
+ missingFinalNewline = false,
11801
+ }
11802
+ end
11803
+ local lines = string.split(text, "\n")
11804
+ local missingFinalNewline = string.sub(text, -1) ~= "\n"
11805
+ if not missingFinalNewline then
11806
+ lines[#lines] = nil
11807
+ end
11808
+ return {
11809
+ lines = lines,
11810
+ missingFinalNewline = missingFinalNewline,
11811
+ }
11812
+ end
11813
+ local function sameLine(a, aIndex, b, bIndex)
11814
+ return a.lines[aIndex + 1] == b.lines[bIndex + 1] and (a.missingFinalNewline and aIndex == #a.lines - 1) == (b.missingFinalNewline and bIndex == #b.lines - 1)
11815
+ end
11816
+ local function pushHunkLines(hunk, text, from, count, prefix)
11817
+ do
11818
+ local index = from
11819
+ local _shouldIncrement = false
11820
+ while true do
11821
+ if _shouldIncrement then
11822
+ index += 1
11823
+ else
11824
+ _shouldIncrement = true
11825
+ end
11826
+ if not (index < from + count) then
11827
+ break
11828
+ end
11829
+ local _hunk = hunk
11830
+ local _arg0 = `{prefix}{text.lines[index + 1]}`
11831
+ table.insert(_hunk, _arg0)
11832
+ if text.missingFinalNewline and index == #text.lines - 1 then
11833
+ table.insert(hunk, NO_FINAL_NEWLINE)
11834
+ end
11835
+ end
11836
+ end
11837
+ end
11838
+ local function hunkStart(line, count)
11839
+ return if count == 0 then line - 1 else line
11840
+ end
11841
+ local function editFirstLine(starts, edit)
11842
+ return lineAt(starts, edit.start)
11843
+ end
11844
+ local function editLastLine(starts, edit)
11845
+ return lineAt(starts, if edit.finish > edit.start then edit.finish - 1 else edit.start)
11846
+ end
11847
+ -- Unified diff of whole changed lines, without context lines. Edits that share
11848
+ -- a line become one hunk; lines the edit left unchanged are trimmed away.
11849
+ local function buildDiff(source, starts, resolved)
11850
+ local output = {}
11851
+ local outputSize = 0
11852
+ local truncated = false
11853
+ local lineDelta = 0
11854
+ local groupStart = 0
11855
+ while groupStart < #resolved and not truncated do
11856
+ local firstLine = editFirstLine(starts, resolved[groupStart + 1])
11857
+ local lastLine = editLastLine(starts, resolved[groupStart + 1])
11858
+ local groupEnd = groupStart + 1
11859
+ local oldText
11860
+ local newText
11861
+ while true do
11862
+ while groupEnd < #resolved and editFirstLine(starts, resolved[groupEnd + 1]) <= lastLine do
11863
+ lastLine = math.max(lastLine, editLastLine(starts, resolved[groupEnd + 1]))
11864
+ groupEnd += 1
11865
+ end
11866
+ local regionStart = starts[firstLine]
11867
+ local regionEnd = lineEnd(starts, source, lastLine)
11868
+ oldText = string.sub(source, regionStart, regionEnd - 1)
11869
+ newText = applyWithin(source, resolved, groupStart, groupEnd, regionStart, regionEnd)
11870
+ -- A replacement that drops the region's final newline joins the next
11871
+ -- line onto it, so that line belongs in the hunk too.
11872
+ local nextLineHasText = lastLine < #starts and starts[lastLine + 1] <= #source
11873
+ if nextLineHasText and string.sub(oldText, -1) == "\n" and string.sub(newText, -1) ~= "\n" then
11874
+ lastLine += 1
11875
+ continue
11876
+ end
11877
+ break
11878
+ end
11879
+ local oldRegion = textLines(oldText)
11880
+ local newRegion = textLines(newText)
11881
+ local oldSize = #oldRegion.lines
11882
+ local newSize = #newRegion.lines
11883
+ local prefix = 0
11884
+ while prefix < oldSize and prefix < newSize and sameLine(oldRegion, prefix, newRegion, prefix) do
11885
+ prefix += 1
11886
+ end
11887
+ local suffix = 0
11888
+ while suffix < oldSize - prefix and suffix < newSize - prefix and sameLine(oldRegion, oldSize - 1 - suffix, newRegion, newSize - 1 - suffix) do
11889
+ suffix += 1
11890
+ end
11891
+ local oldCount = oldSize - prefix - suffix
11892
+ local newCount = newSize - prefix - suffix
11893
+ if oldCount > 0 or newCount > 0 then
11894
+ local oldStart = firstLine + prefix
11895
+ local newStart = oldStart + lineDelta
11896
+ local hunk = { `@@ -{hunkStart(oldStart, oldCount)},{oldCount} +{hunkStart(newStart, newCount)},{newCount} @@` }
11897
+ pushHunkLines(hunk, oldRegion, prefix, oldCount, "-")
11898
+ pushHunkLines(hunk, newRegion, prefix, newCount, "+")
11899
+ for _, line in hunk do
11900
+ if outputSize + #line + 1 > DIFF_CHAR_BUDGET then
11901
+ truncated = true
11902
+ break
11903
+ end
11904
+ table.insert(output, line)
11905
+ outputSize += #line + 1
11906
+ end
11907
+ end
11908
+ lineDelta += newSize - oldSize
11909
+ groupStart = groupEnd
11910
+ end
11911
+ return {
11912
+ diff = table.concat(output, "\n"),
11913
+ truncated = truncated,
11914
+ }
11915
+ end
11916
+ --[[
11917
+ *
11918
+ * Resolves every edit against the same source, rejects overlaps, and builds
11919
+ * the edited source in memory. Nothing is written: the caller applies
11920
+ * newSource in one compare-and-set write, so a bad edit changes nothing.
11921
+ * line_range anchors refer to the source as it was before any edit.
11922
+
11923
+ ]]
11924
+ local function planScriptEdits(source, edits, includeDiff)
11925
+ if #edits == 0 then
11926
+ return {
11927
+ ok = false,
11928
+ error = "At least one edit is required.",
11929
+ }
11930
+ end
11931
+ local starts = lineStarts(source)
11932
+ local resolved = {}
11933
+ for index = 0, #edits - 1 do
11934
+ local edit = edits[index + 1]
11935
+ local label = if #edits > 1 then `edits[{index}]: ` else ""
11936
+ local located = locateEdit(source, starts, edit, label)
11937
+ if type(located) == "string" then
11938
+ return {
11939
+ ok = false,
11940
+ error = located,
11941
+ }
11942
+ end
11943
+ local _arg0 = {
11944
+ index = index,
11945
+ start = located,
11946
+ finish = located + #edit.oldString,
11947
+ newString = edit.newString,
11948
+ }
11949
+ table.insert(resolved, _arg0)
11950
+ -- Insertion sort by position; k is small and ties keep request order.
11951
+ do
11952
+ local at = #resolved - 1
11953
+ local _shouldIncrement = false
11954
+ while true do
11955
+ if _shouldIncrement then
11956
+ at -= 1
11957
+ else
11958
+ _shouldIncrement = true
11959
+ end
11960
+ if not (at > 0 and resolved[at].start > resolved[at + 1].start) then
11961
+ break
11962
+ end
11963
+ local swap = resolved[at]
11964
+ resolved[at] = resolved[at + 1]
11965
+ resolved[at + 1] = swap
11966
+ end
11967
+ end
11968
+ end
11969
+ for at = 1, #resolved - 1 do
11970
+ local previous = resolved[at]
11971
+ local current = resolved[at + 1]
11972
+ if current.start < previous.finish or current.start == previous.start then
11973
+ local first = math.min(previous.index, current.index)
11974
+ local second = math.max(previous.index, current.index)
11975
+ return {
11976
+ ok = false,
11977
+ error = `edits[{first}] and edits[{second}] overlap at line {lineAt(starts, current.start)}; combine them into one edit.`,
11978
+ }
11979
+ end
11980
+ end
11981
+ local newSource = applyWithin(source, resolved, 0, #resolved, 1, #source + 1)
11982
+ if not includeDiff then
11983
+ return {
11984
+ ok = true,
11985
+ newSource = newSource,
11986
+ diff = "",
11987
+ diffTruncated = false,
11988
+ }
11989
+ end
11990
+ local built = buildDiff(source, starts, resolved)
11991
+ return {
11992
+ ok = true,
11993
+ newSource = newSource,
11994
+ diff = built.diff,
11995
+ diffTruncated = built.truncated,
11996
+ }
11997
+ end
11998
+ return {
11999
+ sourceRevision = sourceRevision,
12000
+ planScriptEdits = planScriptEdits,
12001
+ }
12002
+ ]]></string>
12003
+ </Properties>
12004
+ </Item>
12005
+ <Item class="ModuleScript" referent="38">
11055
12006
  <Properties>
11056
12007
  <string name="Name">ScriptSearch</string>
11057
12008
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11240,6 +12191,18 @@ local function createScriptSearch(corpus)
11240
12191
  error = `Path not found: {searchPath}`,
11241
12192
  }
11242
12193
  end
12194
+ local _self = corpus
12195
+ local _result_1 = _self.createAmbiguityChecker
12196
+ if _result_1 ~= nil then
12197
+ _result_1 = _result_1(_self)
12198
+ end
12199
+ local _condition_6 = _result_1
12200
+ if _condition_6 == nil then
12201
+ _condition_6 = (function()
12202
+ return false
12203
+ end)
12204
+ end
12205
+ local isAmbiguous = _condition_6
11243
12206
  local searchPattern = if caseSensitive then pattern else string.lower(pattern)
11244
12207
  local patternAlternatives = if usePattern then splitLuaAlternation(searchPattern) else nil
11245
12208
  local results = {}
@@ -11251,10 +12214,10 @@ local function createScriptSearch(corpus)
11251
12214
  control:checkpoint()
11252
12215
  -- ▼ Array.pop ▼
11253
12216
  local _length = #stack
11254
- local _result_1 = stack[_length]
12217
+ local _result_2 = stack[_length]
11255
12218
  stack[_length] = nil
11256
12219
  -- ▲ Array.pop ▲
11257
- local instance = _result_1
12220
+ local instance = _result_2
11258
12221
  local snapshot = corpus:readScript(instance, classFilter)
11259
12222
  if snapshot ~= nil and (classFilter == nil or snapshot.className == classFilter) then
11260
12223
  scriptsSearched += 1
@@ -11339,6 +12302,9 @@ local function createScriptSearch(corpus)
11339
12302
  if snapshot.enabled ~= nil then
11340
12303
  scriptResult.enabled = snapshot.enabled
11341
12304
  end
12305
+ if isAmbiguous(instance) then
12306
+ scriptResult.ambiguous = true
12307
+ end
11342
12308
  table.insert(results, scriptResult)
11343
12309
  end
11344
12310
  end
@@ -11378,7 +12344,7 @@ return {
11378
12344
  ]]></string>
11379
12345
  </Properties>
11380
12346
  </Item>
11381
- <Item class="ModuleScript" referent="35">
12347
+ <Item class="ModuleScript" referent="39">
11382
12348
  <Properties>
11383
12349
  <string name="Name">ServerUrlSettings</string>
11384
12350
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11492,11 +12458,11 @@ return {
11492
12458
  ]]></string>
11493
12459
  </Properties>
11494
12460
  </Item>
11495
- <Item class="ModuleScript" referent="36">
12461
+ <Item class="ModuleScript" referent="40">
11496
12462
  <Properties>
11497
12463
  <string name="Name">State</string>
11498
12464
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11499
- local CURRENT_VERSION = "3.2.0"
12465
+ local CURRENT_VERSION = "3.3.0"
11500
12466
  local PLUGIN_VARIANT = "inspector"
11501
12467
  local BASE_PORT = 58741
11502
12468
  local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
@@ -11634,7 +12600,7 @@ return {
11634
12600
  ]]></string>
11635
12601
  </Properties>
11636
12602
  </Item>
11637
- <Item class="ModuleScript" referent="37">
12603
+ <Item class="ModuleScript" referent="41">
11638
12604
  <Properties>
11639
12605
  <string name="Name">StopPlayMonitor</string>
11640
12606
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11920,7 +12886,7 @@ return {
11920
12886
  ]]></string>
11921
12887
  </Properties>
11922
12888
  </Item>
11923
- <Item class="ModuleScript" referent="38">
12889
+ <Item class="ModuleScript" referent="42">
11924
12890
  <Properties>
11925
12891
  <string name="Name">StudioWebSocket</string>
11926
12892
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11943,6 +12909,10 @@ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
11943
12909
  local RESERVED_ERROR_BYTES = 4096
11944
12910
  -- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
11945
12911
  local MAX_REQUEST_ID_BYTES = 128 * 3
12912
+ -- Upper bound on BindToClose waiting for the server to close the socket after
12913
+ -- the unregister frame; it normally closes within milliseconds.
12914
+ local SHUTDOWN_UNREGISTER_WAIT_SECONDS = 2
12915
+ local SHUTDOWN_UNREGISTER_POLL_SECONDS = 0.05
11946
12916
  local options
11947
12917
  local active = false
11948
12918
  local shutdownSuspended = false
@@ -12985,8 +13955,13 @@ local function refresh()
12985
13955
  refreshRequested = true
12986
13956
  flushRefresh()
12987
13957
  end
12988
- -- EndTest may tear down this VM without an Unloading callback. Release the
12989
- -- native socket before yielding to unregister; a failed EndTest can resume.
13958
+ -- EndTest may tear down this VM without an Unloading callback, and BindToClose
13959
+ -- waits for this function. It unregisters on the socket itself: Studio runs at
13960
+ -- most five plugin HTTP requests at a time across the whole process, with no
13961
+ -- time limit, so a /disconnect queued behind five that never finish held
13962
+ -- shutdown until Studio's 30 s deadline. Closing right after Send can drop the
13963
+ -- frame, so it waits briefly for the server to close the socket first. A
13964
+ -- failed EndTest can resume and registers again.
12990
13965
  local function suspendForShutdown()
12991
13966
  local currentOptions = options
12992
13967
  if not active or currentOptions == nil then
@@ -12998,9 +13973,34 @@ local function suspendForShutdown()
12998
13973
  cancelTransportWork()
12999
13974
  refreshRequested = false
13000
13975
  reconnectAttempt = 0
13976
+ local client = socketClient
13977
+ local unregistered = false
13978
+ if client ~= nil and socketOpen then
13979
+ local serverClosed = false
13980
+ local closedConnection = client.Closed:Connect(function()
13981
+ serverClosed = true
13982
+ end)
13983
+ local sent = pcall(function()
13984
+ return client:Send(HttpService:JSONEncode({
13985
+ kind = "disconnect",
13986
+ }))
13987
+ end)
13988
+ unregistered = sent
13989
+ local deadline = os.clock() + SHUTDOWN_UNREGISTER_WAIT_SECONDS
13990
+ while sent and not serverClosed and os.clock() < deadline do
13991
+ task.wait(SHUTDOWN_UNREGISTER_POLL_SECONDS)
13992
+ end
13993
+ closedConnection:Disconnect()
13994
+ end
13001
13995
  closeCurrentSocket()
13002
13996
  cachedReady = nil
13003
- disconnectSession(currentOptions)
13997
+ -- Without an open socket, unregister over HTTP on another thread rather
13998
+ -- than wait; the server also drops a peer whose socket stays closed.
13999
+ if not unregistered then
14000
+ task.spawn(function()
14001
+ return disconnectSession(currentOptions)
14002
+ end)
14003
+ end
13004
14004
  end
13005
14005
  local function resumeAfterShutdownFailure()
13006
14006
  if not shutdownSuspended or options == nil then
@@ -13044,7 +14044,7 @@ return {
13044
14044
  ]]></string>
13045
14045
  </Properties>
13046
14046
  </Item>
13047
- <Item class="ModuleScript" referent="39">
14047
+ <Item class="ModuleScript" referent="43">
13048
14048
  <Properties>
13049
14049
  <string name="Name">TopologyId</string>
13050
14050
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13089,7 +14089,7 @@ return {
13089
14089
  ]]></string>
13090
14090
  </Properties>
13091
14091
  </Item>
13092
- <Item class="ModuleScript" referent="40">
14092
+ <Item class="ModuleScript" referent="44">
13093
14093
  <Properties>
13094
14094
  <string name="Name">UI</string>
13095
14095
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13614,10 +14614,12 @@ return {
13614
14614
  ]]></string>
13615
14615
  </Properties>
13616
14616
  </Item>
13617
- <Item class="ModuleScript" referent="41">
14617
+ <Item class="ModuleScript" referent="45">
13618
14618
  <Properties>
13619
14619
  <string name="Name">Utils</string>
13620
14620
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
14621
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
14622
+ local PathAmbiguity = TS.import(script, script.Parent, "PathAmbiguity")
13621
14623
  local ScriptEditorService = game:GetService("ScriptEditorService")
13622
14624
  local LUAU_KEYWORDS = {
13623
14625
  ["and"] = true,
@@ -13832,16 +14834,17 @@ local function parseInstancePath(path)
13832
14834
  end
13833
14835
  return parts
13834
14836
  end
14837
+ local function getService(segment)
14838
+ local ok, service = pcall(function()
14839
+ return game:GetService(segment)
14840
+ end)
14841
+ return if ok and service then service else nil
14842
+ end
14843
+ local function isRootService(instance)
14844
+ return instance.Parent == game and getService(instance.ClassName) == instance
14845
+ end
13835
14846
  local function getRootSegment(instance)
13836
- if instance.Parent == game then
13837
- local ok, service = pcall(function()
13838
- return game:GetService(instance.ClassName)
13839
- end)
13840
- if ok and service == instance then
13841
- return instance.ClassName
13842
- end
13843
- end
13844
- return instance.Name
14847
+ return if isRootService(instance) then instance.ClassName else instance.Name
13845
14848
  end
13846
14849
  local function getInstancePath(instance)
13847
14850
  if not instance or instance == game then
@@ -13865,13 +14868,7 @@ local function getInstancePath(instance)
13865
14868
  return path
13866
14869
  end
13867
14870
  local function getRootInstance(segment)
13868
- local ok, service = pcall(function()
13869
- return game:GetService(segment)
13870
- end)
13871
- if ok and service then
13872
- return service
13873
- end
13874
- return game:FindFirstChild(segment)
14871
+ return getService(segment) or game:FindFirstChild(segment)
13875
14872
  end
13876
14873
  local function getInstanceByPath(path)
13877
14874
  local parts = parseInstancePath(path)
@@ -13891,6 +14888,36 @@ local function getInstanceByPath(path)
13891
14888
  end
13892
14889
  return current
13893
14890
  end
14891
+ local instancePathTree = {
14892
+ root = game,
14893
+ getParent = function(instance)
14894
+ return instance.Parent
14895
+ end,
14896
+ getChildren = function(instance)
14897
+ return instance:GetChildren()
14898
+ end,
14899
+ getName = function(instance)
14900
+ return instance.Name
14901
+ end,
14902
+ getService = getService,
14903
+ isRootService = isRootService,
14904
+ getPath = getInstancePath,
14905
+ }
14906
+ -- Like getInstanceByPath, but also reports the first segment that names more
14907
+ -- than one sibling. Costs one child scan per segment. PathGuard applies it to
14908
+ -- every declared request path before the handler runs.
14909
+ local function resolveInstancePath(path)
14910
+ local parts = parseInstancePath(path)
14911
+ if parts == nil then
14912
+ return {}
14913
+ end
14914
+ return PathAmbiguity.resolvePath(instancePathTree, parts)
14915
+ end
14916
+ -- Returns a per-request predicate that is true when getInstancePath(instance)
14917
+ -- does not identify exactly that instance.
14918
+ local function createPathAmbiguityChecker()
14919
+ return PathAmbiguity.createAmbiguityChecker(instancePathTree)
14920
+ end
13894
14921
  local function splitLines(source)
13895
14922
  local _condition = source
13896
14923
  if _condition == nil then
@@ -14445,6 +15472,8 @@ return {
14445
15472
  safeCall = safeCall,
14446
15473
  getInstancePath = getInstancePath,
14447
15474
  getInstanceByPath = getInstanceByPath,
15475
+ resolveInstancePath = resolveInstancePath,
15476
+ createPathAmbiguityChecker = createPathAmbiguityChecker,
14448
15477
  splitLines = splitLines,
14449
15478
  joinLines = joinLines,
14450
15479
  readScriptSource = readScriptSource,
@@ -14457,11 +15486,11 @@ return {
14457
15486
  </Properties>
14458
15487
  </Item>
14459
15488
  </Item>
14460
- <Item class="Folder" referent="46">
15489
+ <Item class="Folder" referent="50">
14461
15490
  <Properties>
14462
15491
  <string name="Name">include</string>
14463
15492
  </Properties>
14464
- <Item class="ModuleScript" referent="42">
15493
+ <Item class="ModuleScript" referent="46">
14465
15494
  <Properties>
14466
15495
  <string name="Name">LibMP</string>
14467
15496
  <string name="Source"><![CDATA[-- =============================================================================
@@ -170845,7 +171874,7 @@ return LibMP
170845
171874
  ]]></string>
170846
171875
  </Properties>
170847
171876
  </Item>
170848
- <Item class="ModuleScript" referent="43">
171877
+ <Item class="ModuleScript" referent="47">
170849
171878
  <Properties>
170850
171879
  <string name="Name">Promise</string>
170851
171880
  <string name="Source"><![CDATA[--[[
@@ -172919,7 +173948,7 @@ return Promise
172919
173948
  ]]></string>
172920
173949
  </Properties>
172921
173950
  </Item>
172922
- <Item class="ModuleScript" referent="44">
173951
+ <Item class="ModuleScript" referent="48">
172923
173952
  <Properties>
172924
173953
  <string name="Name">RuntimeLib</string>
172925
173954
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -173186,15 +174215,15 @@ return TS
173186
174215
  </Properties>
173187
174216
  </Item>
173188
174217
  </Item>
173189
- <Item class="Folder" referent="47">
174218
+ <Item class="Folder" referent="51">
173190
174219
  <Properties>
173191
174220
  <string name="Name">node_modules</string>
173192
174221
  </Properties>
173193
- <Item class="Folder" referent="48">
174222
+ <Item class="Folder" referent="52">
173194
174223
  <Properties>
173195
174224
  <string name="Name">@rbxts</string>
173196
174225
  </Properties>
173197
- <Item class="ModuleScript" referent="45">
174226
+ <Item class="ModuleScript" referent="49">
173198
174227
  <Properties>
173199
174228
  <string name="Name">services</string>
173200
174229
  <string name="Source"><![CDATA[return setmetatable({}, {