@el4cteo/rbx-studio-mcp 0.1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,289 @@
1
+ --!strict
2
+ --[[
3
+ Hierarchy exploration: tree, inspect and find.
4
+
5
+ These three replace roughly a dozen tools in competing servers
6
+ (get_file_tree, get_project_structure, get_instance_children, search_objects,
7
+ search_by_property, get_attributes, get_tags, get_tagged, get_class_info...).
8
+ Fewer, wider tools mean less schema in the agent's context and fewer chances
9
+ to pick the wrong one.
10
+
11
+ Traversal is bounded everywhere. A production place can hold hundreds of
12
+ thousands of instances, and an unbounded walk would either time out or return
13
+ something no context window can hold.
14
+ ]]
15
+
16
+ local CollectionService = game:GetService("CollectionService")
17
+
18
+ local Dispatch = require(script.Parent.Parent.Dispatch)
19
+ local Paths = require(script.Parent.Parent.Paths)
20
+ local Scope = require(script.Parent.Parent.Scope)
21
+ local Serialize = require(script.Parent.Parent.Serialize)
22
+
23
+ -- Ceiling on nodes visited in one call, independent of how many are returned.
24
+ -- Protects against a `find` over a whole place blocking Studio's main thread.
25
+ local MAX_VISITS = 200_000
26
+
27
+ local Discover = {}
28
+
29
+ -- Root-level noise filtering lives in Scope, which the script tools share.
30
+ local isNoisy = Scope.isNoisy
31
+
32
+ -- One memo per request keeps path formatting linear when many siblings share a
33
+ -- name; see Paths.of.
34
+ local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]: any }
35
+ return {
36
+ path = Paths.of(instance, memo),
37
+ className = instance.ClassName,
38
+ childCount = #instance:GetChildren(),
39
+ }
40
+ end
41
+
42
+ --[[
43
+ Breadth-first walk to `depth`, newest level last, so a truncated result is
44
+ still a coherent picture of the top of the tree rather than one deep spur.
45
+ ]]
46
+ function Discover.tree(params: { [string]: any }): { [string]: any }
47
+ local root = if params.path then Paths.resolve(params.path) else game
48
+ local depth = tonumber(params.depth) or 2
49
+ local limit = tonumber(params.limit) or 100
50
+ local offset = tonumber(params.offset) or 0
51
+ local classFilter = params.className
52
+ local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
53
+
54
+ local matched: { Instance } = {}
55
+ local hidden = 0
56
+ local visits = 0
57
+ local frontier: { Instance } = { root }
58
+
59
+ for level = 1, depth do
60
+ local nextFrontier: { Instance } = {}
61
+ for _, parent in frontier do
62
+ for _, child in parent:GetChildren() do
63
+ visits += 1
64
+ if visits > MAX_VISITS then
65
+ break
66
+ end
67
+ if isNoisy(child) then
68
+ if parent == game then
69
+ hidden += 1
70
+ end
71
+ continue
72
+ end
73
+
74
+ local keep = true
75
+ if classFilter and not child:IsA(classFilter) then
76
+ keep = false
77
+ end
78
+ if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
79
+ keep = false
80
+ end
81
+ if keep then
82
+ table.insert(matched, child)
83
+ end
84
+ if level < depth then
85
+ table.insert(nextFrontier, child)
86
+ end
87
+ end
88
+ end
89
+ frontier = nextFrontier
90
+ if #frontier == 0 or visits > MAX_VISITS then
91
+ break
92
+ end
93
+ end
94
+
95
+ local items: { { [string]: any } } = {}
96
+ local memo: Paths.NameIndex = {}
97
+ for index = offset + 1, math.min(offset + limit, #matched) do
98
+ table.insert(items, summarise(matched[index] :: Instance, memo))
99
+ end
100
+
101
+ return {
102
+ root = Paths.of(root, memo),
103
+ items = items,
104
+ total = #matched,
105
+ offset = offset,
106
+ hiddenServices = hidden,
107
+ }
108
+ end
109
+
110
+ --[[
111
+ Detailed read of specific instances. `properties` is chosen server-side from
112
+ the live API dump, so this handler never needs its own class table.
113
+ ]]
114
+ function Discover.inspect(params: { [string]: any }): { [string]: any }
115
+ local paths = params.paths
116
+ if typeof(paths) ~= "table" or #paths == 0 then
117
+ Dispatch.fail(
118
+ "BAD_PARAMS",
119
+ "inspect requires a non-empty `paths` array.",
120
+ 'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
121
+ )
122
+ end
123
+
124
+ local requested: { string }? = params.properties
125
+ local includeChildren = params.includeChildren ~= false
126
+ local childLimit = tonumber(params.childLimit) or 25
127
+
128
+ local results: { { [string]: any } } = {}
129
+ local failures: { string } = {}
130
+ local memo: Paths.NameIndex = {}
131
+
132
+ for _, path in paths do
133
+ local ok, instance = pcall(Paths.resolve, path)
134
+ if not ok then
135
+ local err = instance :: any
136
+ -- The hint carries the sibling listing ("It does have: ..."), which is
137
+ -- what lets the agent correct the path without another round trip.
138
+ local reason = if typeof(err) == "table"
139
+ then (if err.hint then err.message .. " " .. err.hint else err.message)
140
+ else tostring(err)
141
+ table.insert(failures, string.format("%s: %s", path, reason))
142
+ continue
143
+ end
144
+
145
+ local target = instance :: Instance
146
+ local properties: { [string]: any } = {}
147
+ if requested then
148
+ for _, name in requested do
149
+ local readOk, value = Serialize.readProperty(target, name)
150
+ if readOk then
151
+ properties[name] = value
152
+ end
153
+ end
154
+ end
155
+
156
+ local entry: { [string]: any } = {
157
+ path = Paths.of(target, memo),
158
+ -- Echoed back so a caller can correlate the answer with what it asked
159
+ -- for. `path` is the canonical form and often differs: ask about
160
+ -- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
161
+ requested = path,
162
+ className = target.ClassName,
163
+ properties = properties,
164
+ childCount = #target:GetChildren(),
165
+ }
166
+
167
+ -- Empty attribute and tag sets are omitted rather than sent as empty
168
+ -- containers: most instances have neither, and Luau encodes an empty
169
+ -- table as [] which reads as a list and confuses the shape.
170
+ local attributes: { [string]: any } = {}
171
+ local hasAttributes = false
172
+ for name, value in target:GetAttributes() do
173
+ attributes[name] = Serialize.value(value)
174
+ hasAttributes = true
175
+ end
176
+ if hasAttributes then
177
+ entry.attributes = attributes
178
+ end
179
+
180
+ local tags = CollectionService:GetTags(target)
181
+ if #tags > 0 then
182
+ entry.tags = tags
183
+ end
184
+
185
+ if includeChildren then
186
+ local children: { { [string]: any } } = {}
187
+ for index, child in target:GetChildren() do
188
+ if index > childLimit then
189
+ break
190
+ end
191
+ table.insert(children, { name = child.Name, className = child.ClassName })
192
+ end
193
+ entry.children = children
194
+ end
195
+
196
+ table.insert(results, entry)
197
+ end
198
+
199
+ return { items = results, failures = failures }
200
+ end
201
+
202
+ --[[
203
+ One search over name, class, property value and tag.
204
+
205
+ Every filter supplied must match (AND), which is what lets a single tool
206
+ answer "every anchored Part under Workspace whose name contains 'door'"
207
+ without the agent chaining three calls and intersecting the results itself.
208
+ ]]
209
+ function Discover.find(params: { [string]: any }): { [string]: any }
210
+ local root = if params.path then Paths.resolve(params.path) else game
211
+ local limit = tonumber(params.limit) or 100
212
+ local offset = tonumber(params.offset) or 0
213
+
214
+ local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
215
+ local classFilter = params.className
216
+ local propertyName = params.propertyName
217
+ local propertyValue = params.propertyValue
218
+ local tagFilter = params.tag
219
+
220
+ -- A tag query is answered from CollectionService's index rather than by
221
+ -- walking the tree, which is orders of magnitude cheaper on a big place.
222
+ local candidates: { Instance }
223
+ if tagFilter then
224
+ candidates = CollectionService:GetTagged(tagFilter)
225
+ else
226
+ candidates = root:GetDescendants()
227
+ end
228
+
229
+ if #candidates > MAX_VISITS then
230
+ Dispatch.fail(
231
+ "TOO_BROAD",
232
+ string.format(
233
+ "That search would visit %d instances, over the %d limit.",
234
+ #candidates,
235
+ MAX_VISITS
236
+ ),
237
+ "Narrow it with `path` to search one service or model instead of the whole place."
238
+ )
239
+ end
240
+
241
+ local matched: { Instance } = {}
242
+ for _, instance in candidates do
243
+ if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
244
+ continue
245
+ end
246
+ if isNoisy(instance) then
247
+ continue
248
+ end
249
+ if classFilter and not instance:IsA(classFilter) then
250
+ continue
251
+ end
252
+ if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
253
+ continue
254
+ end
255
+ if propertyName then
256
+ local readOk, value = Serialize.readProperty(instance, propertyName)
257
+ if not readOk then
258
+ continue
259
+ end
260
+ if propertyValue ~= nil and tostring(value) ~= tostring(propertyValue) then
261
+ continue
262
+ end
263
+ end
264
+ table.insert(matched, instance)
265
+ end
266
+
267
+ local items: { { [string]: any } } = {}
268
+ local memo: Paths.NameIndex = {}
269
+ for index = offset + 1, math.min(offset + limit, #matched) do
270
+ table.insert(items, summarise(matched[index] :: Instance, memo))
271
+ end
272
+
273
+ return {
274
+ items = items,
275
+ total = #matched,
276
+ offset = offset,
277
+ searched = #candidates,
278
+ }
279
+ end
280
+
281
+ function Discover.register()
282
+ Dispatch.registerAll("discover", {
283
+ tree = Discover.tree,
284
+ inspect = Discover.inspect,
285
+ find = Discover.find,
286
+ })
287
+ end
288
+
289
+ return Discover
@@ -0,0 +1,270 @@
1
+ --!strict
2
+ --[[
3
+ Arbitrary Luau, run in the plugin's context.
4
+
5
+ This is the escape hatch, not the default. Every dedicated tool validates its
6
+ input, types values from the API dump, and wraps writes in an undo recording;
7
+ code run here does none of that. It exists for the cases nothing else covers.
8
+
9
+ `LogService:ExecuteScript` -- the API the command bar uses -- is
10
+ RobloxScriptSecurity, so this compiles with `loadstring` instead. Output is
11
+ captured while the chunk runs, because "what did it print" is almost always
12
+ the reason for running it at all.
13
+ ]]
14
+
15
+ local LogService = game:GetService("LogService")
16
+
17
+ local Dispatch = require(script.Parent.Parent.Dispatch)
18
+ local Serialize = require(script.Parent.Parent.Serialize)
19
+
20
+ -- A chunk that prints in a loop would otherwise return a response no context
21
+ -- window can hold.
22
+ local MAX_OUTPUT_LINES = 200
23
+ local MAX_RETURN_VALUES = 10
24
+
25
+ -- Returned tables are walked rather than counted, but not without limit: a
26
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
27
+ -- able to produce a response nothing can read.
28
+ local MAX_TABLE_ENTRIES = 50
29
+ local MAX_TABLE_DEPTH = 4
30
+
31
+ local CHUNK_NAME = "StudioMCP.execute_luau"
32
+
33
+ local Exec = {}
34
+
35
+ --[[
36
+ Describes a returned value.
37
+
38
+ Tables used to come back as "<table with 5 entries>", which threw away the
39
+ answer whenever the answer was a table -- and returning a table is the natural
40
+ way to report several things at once, so this hit constantly. They are walked
41
+ now, bounded by breadth and depth. Everything else goes through the same
42
+ serializer the rest of the server uses, so a Vector3 reads the way it does
43
+ everywhere else.
44
+ ]]
45
+ local function describe(value: any, depth: number?): any
46
+ local level = depth or 0
47
+ local kind = typeof(value)
48
+
49
+ if kind == "function" or kind == "thread" then
50
+ return string.format("<%s>", kind)
51
+ end
52
+ if kind ~= "table" then
53
+ return Serialize.value(value)
54
+ end
55
+
56
+ local source = value :: { [any]: any }
57
+ -- Also the cycle guard: a self-referencing table stops here rather than
58
+ -- recursing until the stack gives out.
59
+ if level >= MAX_TABLE_DEPTH then
60
+ return "<nested table>"
61
+ end
62
+
63
+ local count = 0
64
+ for _ in source do
65
+ count += 1
66
+ end
67
+
68
+ -- Arrays keep their shape so they read as lists on the other side; anything
69
+ -- with non-sequential keys becomes a map with stringified keys.
70
+ if count == #source then
71
+ local list: { any } = {}
72
+ for index, item in ipairs(source) do
73
+ if index > MAX_TABLE_ENTRIES then
74
+ table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
75
+ break
76
+ end
77
+ table.insert(list, describe(item, level + 1))
78
+ end
79
+ return list
80
+ end
81
+
82
+ local map: { [string]: any } = {}
83
+ local shown = 0
84
+ for key, item in source do
85
+ shown += 1
86
+ if shown > MAX_TABLE_ENTRIES then
87
+ map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
88
+ break
89
+ end
90
+ map[tostring(key)] = describe(item, level + 1)
91
+ end
92
+ return map
93
+ end
94
+
95
+ --[[
96
+ Compiles the chunk by whichever route this session actually allows.
97
+
98
+ `loadstring` is the direct one and the only one that keeps plugin identity,
99
+ but the global exists and *throws* unless `ServerScriptService.LoadStringEnabled`
100
+ is set -- which it is not by default. So every call against a running playtest
101
+ server failed with "loadstring() is not available" while the identical code ran
102
+ fine in the editor session, which is the worst shape a limitation can take.
103
+
104
+ The fallback compiles through a ModuleScript: a plugin may set `.Source`, and
105
+ requiring a fresh, unparented instance runs it without touching the place. The
106
+ cost is real and gets reported back rather than hidden -- a required module
107
+ runs at script identity, so plugin-only APIs are out of reach on that path.
108
+
109
+ Returns the callable, a compile error, which route was taken, and any module
110
+ to clean up once the chunk has finished with it.
111
+ ]]
112
+ local function compile(
113
+ source: string
114
+ ): (((...any) -> ...any)?, string?, string, ModuleScript?)
115
+ local ok, chunk, syntaxError = pcall(loadstring :: any, source, CHUNK_NAME)
116
+ if ok then
117
+ if typeof(chunk) == "function" then
118
+ return chunk, nil, "loadstring", nil
119
+ end
120
+ return nil, tostring(syntaxError), "loadstring", nil
121
+ end
122
+
123
+ -- `chunk` carries the pcall failure when `ok` is false.
124
+ local refusal = tostring(chunk)
125
+
126
+ local module = Instance.new("ModuleScript")
127
+ -- The wrapper opens on the same line the source starts on, so a syntax error
128
+ -- still reports the line the caller wrote.
129
+ local settable = pcall(function()
130
+ module.Source = "return function(...) " .. source .. "\nend"
131
+ end)
132
+ if not settable then
133
+ module:Destroy()
134
+ return nil,
135
+ string.format(
136
+ "no route to compile is open in this session: loadstring refused (%s) "
137
+ .. "and setting a script's source is not permitted either",
138
+ refusal
139
+ ),
140
+ "none",
141
+ nil
142
+ end
143
+
144
+ local required, result = pcall(require, module)
145
+ if not required then
146
+ module:Destroy()
147
+ return nil, tostring(result), "module", nil
148
+ end
149
+ if typeof(result) ~= "function" then
150
+ module:Destroy()
151
+ return nil, "the chunk compiled but produced nothing callable", "module", nil
152
+ end
153
+ return result :: any, nil, "module", module
154
+ end
155
+
156
+ function Exec.run(params: { [string]: any }): { [string]: any }
157
+ local source = params.source
158
+ if typeof(source) ~= "string" or source == "" then
159
+ Dispatch.fail(
160
+ "BAD_PARAMS",
161
+ "execute_luau requires `source`.",
162
+ "Pass the Luau to run as a string."
163
+ )
164
+ end
165
+
166
+ local chunk, compileError, route, module = compile(source)
167
+ if chunk == nil then
168
+ if route == "none" then
169
+ Dispatch.fail(
170
+ "NO_COMPILER",
171
+ string.format("Cannot run code in this session: %s", tostring(compileError)),
172
+ "Use the dedicated tools instead: create, modify, script_edit and find "
173
+ .. "cover most of what execute_luau is reached for."
174
+ )
175
+ end
176
+ Dispatch.fail(
177
+ "COMPILE_ERROR",
178
+ string.format("The code did not compile: %s", tostring(compileError)),
179
+ "Fix the syntax and try again. The chunk is compiled as a whole, so a "
180
+ .. "stray end or missing then reports here rather than at a line."
181
+ )
182
+ end
183
+
184
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
185
+ -- reading them back from the log afterwards would also pick up whatever else
186
+ -- the place logged in the meantime.
187
+ local output: { { [string]: any } } = {}
188
+ local connection = LogService.MessageOut:Connect(function(message, messageType)
189
+ if #output < MAX_OUTPUT_LINES then
190
+ table.insert(output, {
191
+ message = message,
192
+ level = if messageType == Enum.MessageType.MessageError
193
+ then "error"
194
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
195
+ else "print",
196
+ })
197
+ end
198
+ end)
199
+
200
+ local started = os.clock()
201
+ local results = table.pack(pcall(chunk :: () -> ...any))
202
+ local elapsed = os.clock() - started
203
+
204
+ --[[
205
+ MessageOut is deferred: `print` returns before the event fires, so
206
+ disconnecting straight after the chunk finished captured nothing at all.
207
+ A short settle picks the lines up.
208
+
209
+ It is bounded and stops as soon as the flow stops, rather than waiting a
210
+ fixed period, so a chunk that logged nothing costs almost no time and one
211
+ that logged plenty is not truncated.
212
+ ]]
213
+ local settleDeadline = os.clock() + 0.5
214
+ local seen = #output
215
+ local quietFrames = 0
216
+ while os.clock() < settleDeadline and quietFrames < 3 do
217
+ task.wait()
218
+ if #output == seen then
219
+ quietFrames += 1
220
+ else
221
+ seen = #output
222
+ quietFrames = 0
223
+ end
224
+ end
225
+
226
+ connection:Disconnect()
227
+
228
+ if module then
229
+ module:Destroy()
230
+ end
231
+
232
+ -- Only mentioned when it constrains what the code could do. On the direct
233
+ -- route there is nothing to say.
234
+ local identity = if route == "module"
235
+ then "Ran through a ModuleScript because loadstring is disabled in this "
236
+ .. "session, so the code held script identity rather than plugin "
237
+ .. "identity -- plugin-only APIs would have been unavailable to it."
238
+ else nil
239
+
240
+ if not results[1] then
241
+ return {
242
+ ok = false,
243
+ error = tostring(results[2]),
244
+ output = output,
245
+ note = identity,
246
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
247
+ }
248
+ end
249
+
250
+ local returned: { any } = {}
251
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
252
+ table.insert(returned, describe(results[index]))
253
+ end
254
+
255
+ return {
256
+ ok = true,
257
+ returned = returned,
258
+ output = output,
259
+ note = identity,
260
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
261
+ }
262
+ end
263
+
264
+ function Exec.register()
265
+ Dispatch.registerAll("exec", {
266
+ run = Exec.run,
267
+ })
268
+ end
269
+
270
+ return Exec