@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,296 @@
1
+ --!strict
2
+ --[[
3
+ The edit engine: pure text in, pure text out.
4
+
5
+ Kept free of Roblox APIs on purpose. Getting a line splice or a find/replace
6
+ subtly wrong is the one bug class here that produces no error at all -- it
7
+ just writes the wrong code into the user's game -- so this logic is unit
8
+ tested outside Studio (see tests/textedit.luau). Everything that actually
9
+ touches the DataModel lives in handlers/Scripts.luau.
10
+ ]]
11
+
12
+ local Dispatch = require(script.Parent.Dispatch)
13
+
14
+ local TextEdit = {}
15
+
16
+ export type Edit = {
17
+ path: string,
18
+ find: string?,
19
+ replace: string?,
20
+ replaceAll: boolean?,
21
+ startLine: number?,
22
+ endLine: number?,
23
+ replacement: string?,
24
+ source: string?,
25
+ }
26
+
27
+ type LineEdit = {
28
+ startLine: number,
29
+ endLine: number,
30
+ replacement: string,
31
+ }
32
+
33
+ --[[
34
+ Splits source into lines. A trailing newline does not produce a final empty
35
+ line, so a round trip through `toLines`/`concat` leaves the text unchanged.
36
+ ]]
37
+ function TextEdit.toLines(source: string): { string }
38
+ local lines: { string } = {}
39
+ for line in string.gmatch(source .. "\n", "([^\n]*)\n") do
40
+ table.insert(lines, line)
41
+ end
42
+ -- gmatch on the sentinel newline yields one trailing empty entry.
43
+ if #lines > 0 and lines[#lines] == "" then
44
+ table.remove(lines)
45
+ end
46
+ return lines
47
+ end
48
+
49
+ --[[
50
+ Literal (non-pattern) find and replace.
51
+
52
+ `string.gsub` would be shorter but treats `%` as an escape in both the needle
53
+ and the replacement, so a single `%d` in the user's code silently changes
54
+ meaning. Agent-supplied text has to be taken exactly as given.
55
+ ]]
56
+ function TextEdit.replaceLiteral(text: string, needle: string, replacement: string, all: boolean): string
57
+ local out: { string } = {}
58
+ local index = 1
59
+
60
+ while true do
61
+ local from, to = string.find(text, needle, index, true)
62
+ if not from or not to then
63
+ break
64
+ end
65
+ table.insert(out, string.sub(text, index, from - 1))
66
+ table.insert(out, replacement)
67
+ index = to + 1
68
+ if not all then
69
+ break
70
+ end
71
+ end
72
+
73
+ table.insert(out, string.sub(text, index))
74
+ return table.concat(out)
75
+ end
76
+
77
+ function TextEdit.countLiteral(text: string, needle: string): number
78
+ local index = 1
79
+ local count = 0
80
+ while true do
81
+ local from, to = string.find(text, needle, index, true)
82
+ if not from or not to then
83
+ return count
84
+ end
85
+ count += 1
86
+ index = to + 1
87
+ end
88
+ end
89
+
90
+ --[[
91
+ Rejects a malformed edit before anything is written.
92
+
93
+ Validation is a separate pass on purpose: a batch that turns out to be
94
+ nonsense should fail with nothing touched and no undo entry, rather than
95
+ applying three scripts' worth of changes and then rolling them back.
96
+ ]]
97
+ function TextEdit.validate(edit: Edit, position: number)
98
+ if typeof(edit.path) ~= "string" or edit.path == "" then
99
+ Dispatch.fail(
100
+ "BAD_PARAMS",
101
+ string.format("edits[%d] has no `path`.", position),
102
+ 'Every edit needs the path of the script to change, e.g. "ServerScriptService.Main".'
103
+ )
104
+ end
105
+
106
+ local modes = 0
107
+ if edit.source ~= nil then
108
+ modes += 1
109
+ end
110
+ if edit.startLine ~= nil then
111
+ modes += 1
112
+ end
113
+ if edit.find ~= nil then
114
+ modes += 1
115
+ end
116
+
117
+ if modes == 0 then
118
+ Dispatch.fail(
119
+ "BAD_PARAMS",
120
+ string.format("edits[%d] (%s) says what to edit but not how.", position, edit.path),
121
+ "Give exactly one of: `find` + `replace`, `startLine` (+ `endLine`) + "
122
+ .. "`replacement`, or `source` to replace the whole script."
123
+ )
124
+ end
125
+ if modes > 1 then
126
+ Dispatch.fail(
127
+ "BAD_PARAMS",
128
+ string.format("edits[%d] (%s) combines more than one edit mode.", position, edit.path),
129
+ "Use `find`, `startLine` or `source` -- one per edit. Send several entries "
130
+ .. "in the array if you need several changes."
131
+ )
132
+ end
133
+
134
+ if edit.find ~= nil then
135
+ if edit.find == "" then
136
+ Dispatch.fail(
137
+ "BAD_PARAMS",
138
+ string.format("edits[%d] (%s) has an empty `find`.", position, edit.path)
139
+ )
140
+ end
141
+ if typeof(edit.replace) ~= "string" then
142
+ Dispatch.fail(
143
+ "BAD_PARAMS",
144
+ string.format("edits[%d] (%s) has `find` but no `replace`.", position, edit.path),
145
+ "Pass an empty `replace` to delete the matched text."
146
+ )
147
+ end
148
+ elseif edit.startLine ~= nil then
149
+ if typeof(edit.replacement) ~= "string" then
150
+ Dispatch.fail(
151
+ "BAD_PARAMS",
152
+ string.format("edits[%d] (%s) has `startLine` but no `replacement`.", position, edit.path),
153
+ "Pass an empty `replacement` to delete those lines."
154
+ )
155
+ end
156
+ end
157
+ end
158
+
159
+ --[[
160
+ Applies every edit destined for one script to its source text. `path` is used
161
+ only for error messages.
162
+
163
+ Order is fixed rather than left to the caller: line edits run first, bottom
164
+ up, so all their numbers refer to the file as the agent read it instead of
165
+ drifting as earlier edits resize it. Find/replace edits then run in the given
166
+ order against the result, which is safe because they address text, not
167
+ positions.
168
+ ]]
169
+ function TextEdit.apply(path: string, source: string, edits: { Edit }): string
170
+ for _, edit in edits do
171
+ if edit.source ~= nil then
172
+ if #edits > 1 then
173
+ Dispatch.fail(
174
+ "CONFLICTING_EDITS",
175
+ string.format("%s gets a whole-script `source` edit plus %d other edit(s).", path, #edits - 1),
176
+ "A `source` edit replaces the entire script, so any other edit to the "
177
+ .. "same script would be discarded. Send it on its own."
178
+ )
179
+ end
180
+ return edit.source :: string
181
+ end
182
+ end
183
+
184
+ local lineEdits: { LineEdit } = {}
185
+ local findEdits: { Edit } = {}
186
+ for _, edit in edits do
187
+ local start = edit.startLine
188
+ if start ~= nil then
189
+ table.insert(lineEdits, {
190
+ startLine = start,
191
+ endLine = tonumber(edit.endLine) or start,
192
+ replacement = edit.replacement :: string,
193
+ })
194
+ else
195
+ table.insert(findEdits, edit)
196
+ end
197
+ end
198
+
199
+ local lines = TextEdit.toLines(source)
200
+
201
+ if #lineEdits > 0 then
202
+ table.sort(lineEdits, function(a, b)
203
+ return a.startLine > b.startLine
204
+ end)
205
+
206
+ -- Every range is checked against the file as the agent read it, before any
207
+ -- of them is applied. Validating inside the apply loop instead would test
208
+ -- later edits against a file the earlier ones had already resized, which
209
+ -- reports an invented line count and hides genuine overlaps behind a
210
+ -- spurious BAD_RANGE.
211
+ local lineCount = #lines
212
+ for index, edit in lineEdits do
213
+ if edit.startLine < 1 or edit.startLine > lineCount + 1 then
214
+ Dispatch.fail(
215
+ "BAD_RANGE",
216
+ string.format("startLine %d is outside %s, which has %d lines.", edit.startLine, path, lineCount),
217
+ "Call script_read on this script first. Line numbers shift after every edit."
218
+ )
219
+ end
220
+ if edit.endLine < edit.startLine - 1 or edit.endLine > lineCount then
221
+ Dispatch.fail(
222
+ "BAD_RANGE",
223
+ string.format(
224
+ "endLine %d is invalid for %s (%d lines): it must be at least startLine - 1 and at most the line count.",
225
+ edit.endLine,
226
+ path,
227
+ lineCount
228
+ ),
229
+ "Set endLine one below startLine to insert without replacing anything."
230
+ )
231
+ end
232
+
233
+ -- Sorted descending, so the previous entry is the next range up the
234
+ -- file. Overlapping ranges would make the result depend on apply order.
235
+ local previous = lineEdits[index - 1]
236
+ if previous and edit.endLine >= previous.startLine then
237
+ Dispatch.fail(
238
+ "CONFLICTING_EDITS",
239
+ string.format(
240
+ "Two edits to %s cover overlapping lines (%d-%d and %d-%d).",
241
+ path,
242
+ edit.startLine,
243
+ edit.endLine,
244
+ previous.startLine,
245
+ previous.endLine
246
+ ),
247
+ "Merge them into one edit spanning the whole range."
248
+ )
249
+ end
250
+ end
251
+
252
+ for _, edit in lineEdits do
253
+ local result: { string } = {}
254
+ table.move(lines, 1, edit.startLine - 1, 1, result)
255
+ for _, line in TextEdit.toLines(edit.replacement) do
256
+ table.insert(result, line)
257
+ end
258
+ if edit.endLine < #lines then
259
+ table.move(lines, edit.endLine + 1, #lines, #result + 1, result)
260
+ end
261
+ lines = result
262
+ end
263
+ end
264
+
265
+ local text = table.concat(lines, "\n")
266
+
267
+ for _, edit in findEdits do
268
+ local needle = edit.find :: string
269
+ local occurrences = TextEdit.countLiteral(text, needle)
270
+
271
+ if occurrences == 0 then
272
+ Dispatch.fail(
273
+ "NO_MATCH",
274
+ string.format("The `find` text does not appear in %s.", path),
275
+ "Call script_read on the script and copy the text exactly, including "
276
+ .. "indentation -- the match is literal and whitespace-sensitive."
277
+ )
278
+ end
279
+ -- Refusing an ambiguous match is the point: silently taking the first of
280
+ -- five identical lines is how an agent edits the wrong function.
281
+ if occurrences > 1 and edit.replaceAll ~= true then
282
+ Dispatch.fail(
283
+ "AMBIGUOUS_EDIT",
284
+ string.format("The `find` text appears %d times in %s.", occurrences, path),
285
+ "Include surrounding lines so the text is unique, or set "
286
+ .. "`replaceAll` to change every occurrence."
287
+ )
288
+ end
289
+
290
+ text = TextEdit.replaceLiteral(text, needle, edit.replace :: string, edit.replaceAll == true)
291
+ end
292
+
293
+ return text
294
+ end
295
+
296
+ return TextEdit
@@ -0,0 +1,328 @@
1
+ --!strict
2
+ --[[
3
+ Command channel between the bridge and this Studio instance.
4
+
5
+ Preferred path is a single Server-Sent Events stream opened with
6
+ `HttpService:CreateWebStreamClient`, which pushes commands the moment they are
7
+ issued. Every other Roblox Studio MCP server long-polls on a fixed interval,
8
+ which adds half the poll period to every single call and keeps a request in
9
+ flight forever; a stream costs nothing while idle.
10
+
11
+ Studio allows only 4 concurrent web stream connections and closes them after
12
+ 30 minutes, so exactly one stream is held and reconnects are treated as
13
+ routine. Where web streams are unavailable (older Studio builds, or the 4
14
+ slots being taken by other plugins) the same command loop runs over long-poll
15
+ instead, and neither the server nor the handlers can tell the difference.
16
+ ]]
17
+
18
+ local HttpService = game:GetService("HttpService")
19
+
20
+ local Config = require(script.Parent.Config)
21
+ local Context = require(script.Parent.Context)
22
+ local Net = require(script.Parent.Net)
23
+
24
+ local Transport = {}
25
+
26
+ export type Status = "disconnected" | "connecting" | "connected"
27
+
28
+ type Callbacks = {
29
+ onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
30
+ onStatus: (status: Status, detail: string?) -> (),
31
+ }
32
+
33
+ local running = false
34
+ local studioId: string = ""
35
+ local activeStream: any = nil
36
+ local mode: "sse" | "poll" = "sse"
37
+
38
+ --[[
39
+ Forces the long-poll path even where streaming is available.
40
+
41
+ Two reasons it is worth being able to ask for the slower transport. Studio
42
+ caps SSE at four concurrent connections for the whole process, shared with
43
+ every other plugin the user has installed, so a session can find itself
44
+ unable to open a stream through no fault of its own -- and being able to say
45
+ "just poll" beats discovering that as an intermittent failure to connect.
46
+
47
+ The other is honesty: this project's headline claim is that pushing beats
48
+ polling, and a claim like that should be checkable by whoever doubts it, on
49
+ their own machine, without patching the source to see the other number.
50
+ ]]
51
+ local forcePoll = false
52
+
53
+ --[[
54
+ True when this Studio build exposes web streams. Guarded rather than assumed:
55
+ the API shipped in August 2025 and users on older builds must still work.
56
+ ]]
57
+ local function supportsWebStream(): boolean
58
+ if typeof((HttpService :: any).CreateWebStreamClient) ~= "function" then
59
+ return false
60
+ end
61
+ local ok, enumItem = pcall(function()
62
+ return (Enum :: any).WebStreamClientType.SSE
63
+ end)
64
+ return ok and enumItem ~= nil
65
+ end
66
+
67
+ local function identity(transport: string): { [string]: any }
68
+ return {
69
+ studioId = studioId,
70
+ placeName = if game.Name ~= "" then game.Name else "Untitled place",
71
+ placeId = game.PlaceId,
72
+ pluginVersion = Config.PLUGIN_VERSION,
73
+ buildId = Config.BUILD_ID,
74
+ transport = transport,
75
+ -- Announced at the handshake rather than left for a later status call.
76
+ -- The ambiguity error that forces someone to choose between two rows
77
+ -- with the same place name is raised before any such call could have
78
+ -- run, so without this it lists them with nothing to choose between.
79
+ context = Context.of(),
80
+ }
81
+ end
82
+
83
+ --[[
84
+ Parses one SSE payload. `MessageReceived` hands over the event data, but the
85
+ exact framing is not contractual, so a stray `data:` prefix is tolerated and
86
+ comment/keepalive lines are dropped.
87
+ ]]
88
+ local function parseFrame(message: string): { [string]: any }?
89
+ local text = (string.gsub(message, "^%s+", ""))
90
+ if text == "" or string.sub(text, 1, 1) == ":" then
91
+ return nil
92
+ end
93
+ if string.sub(text, 1, 5) == "data:" then
94
+ text = (string.gsub(string.sub(text, 6), "^%s+", ""))
95
+ end
96
+ local decoded = Net.decode(text)
97
+ if typeof(decoded) ~= "table" then
98
+ return nil
99
+ end
100
+ return decoded
101
+ end
102
+
103
+ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
104
+ local id = frame.id
105
+ local op = frame.op
106
+ if typeof(id) == "string" and typeof(op) == "string" then
107
+ callbacks.onCommand(id, op, frame.params)
108
+ end
109
+ end
110
+
111
+ --[[
112
+ Opens the stream and blocks until it closes. Returns false when the stream
113
+ could not be opened at all, which is the signal to fall back to long-poll.
114
+ ]]
115
+ local function runStream(callbacks: Callbacks): boolean
116
+ local opened = false
117
+
118
+ local ok, client = pcall(function()
119
+ return (HttpService :: any):CreateWebStreamClient((Enum :: any).WebStreamClientType.SSE, {
120
+ Url = Config.baseUrl() .. "/events",
121
+ Method = "POST",
122
+ Headers = Config.headers(),
123
+ Body = HttpService:JSONEncode(identity("sse")),
124
+ })
125
+ end)
126
+
127
+ if not ok or client == nil then
128
+ return false
129
+ end
130
+
131
+ activeStream = client
132
+ local connections: { RBXScriptConnection } = {}
133
+
134
+ table.insert(
135
+ connections,
136
+ client.Opened:Connect(function(statusCode: number)
137
+ if statusCode == 200 then
138
+ opened = true
139
+ mode = "sse"
140
+ callbacks.onStatus("connected", "streaming")
141
+ end
142
+ end)
143
+ )
144
+
145
+ table.insert(
146
+ connections,
147
+ client.MessageReceived:Connect(function(message: string)
148
+ local frame = parseFrame(message)
149
+ if frame then
150
+ handleFrame(callbacks, frame)
151
+ end
152
+ end)
153
+ )
154
+
155
+ local streamError: string? = nil
156
+ table.insert(
157
+ connections,
158
+ client.Error:Connect(function(statusCode: number, errorMessage: string)
159
+ streamError = string.format("stream error %d: %s", statusCode, tostring(errorMessage))
160
+ end)
161
+ )
162
+
163
+ client.Closed:Wait()
164
+ for _, connection in connections do
165
+ connection:Disconnect()
166
+ end
167
+ activeStream = nil
168
+
169
+ if not opened then
170
+ callbacks.onStatus("disconnected", streamError or Net.explain("could not connect"))
171
+ return false
172
+ end
173
+ callbacks.onStatus("disconnected", "stream closed")
174
+ return true
175
+ end
176
+
177
+ --[[
178
+ Long-poll fallback. The bridge parks each request for ~25s and answers with a
179
+ command or an idle marker, so this is a blocking wait rather than a busy loop.
180
+
181
+ Returns true when it is giving way so streaming can be tried again.
182
+
183
+ That return exists because this loop used to have no exit while it was
184
+ succeeding, which quietly made the fallback permanent: streaming is only
185
+ attempted when a connection is being established, so a single failed attempt
186
+ -- Studio's four-stream cap being momentarily full, or the bridge restarting
187
+ at the wrong instant -- dropped the session onto long-poll for as long as it
188
+ stayed connected. Nothing reported it, because polling works; it is just
189
+ slower, which is precisely the thing this project claims to fix. Both windows
190
+ of this session were found sitting on poll with no error anywhere.
191
+ ]]
192
+ local function runPolling(callbacks: Callbacks): boolean
193
+ mode = "poll"
194
+ local handshake = Net.postJson("/connect", identity("poll"))
195
+ if not handshake.ok then
196
+ callbacks.onStatus("disconnected", handshake.error or "handshake failed")
197
+ return false
198
+ end
199
+ callbacks.onStatus("connected", "polling")
200
+
201
+ local retryStreamAt = os.clock() + Config.RESTREAM_INTERVAL
202
+
203
+ while running do
204
+ local response = Net.request("GET", "/poll?studioId=" .. studioId)
205
+ if not running then
206
+ return false
207
+ end
208
+
209
+ if response.statusCode == 409 then
210
+ -- The server restarted and no longer knows us; re-handshake.
211
+ local retry = Net.postJson("/connect", identity("poll"))
212
+ if not retry.ok then
213
+ callbacks.onStatus("disconnected", retry.error or "reconnect failed")
214
+ return false
215
+ end
216
+ elseif not response.ok then
217
+ callbacks.onStatus("disconnected", response.error or "poll failed")
218
+ return false
219
+ else
220
+ local payload = Net.decode(response.body)
221
+ if typeof(payload) == "table" and typeof(payload.command) == "table" then
222
+ handleFrame(callbacks, payload.command)
223
+ end
224
+ end
225
+
226
+ -- Checked after handling rather than before waiting, so the handover
227
+ -- happens between commands instead of while one is outstanding.
228
+ if os.clock() >= retryStreamAt then
229
+ return true
230
+ end
231
+ end
232
+ return false
233
+ end
234
+
235
+ --[[
236
+ Posts a command result. Fire-and-forget: if it fails the server's own timeout
237
+ will surface an actionable error to the agent, and retrying here would risk
238
+ answering a request that has already been abandoned.
239
+ ]]
240
+ function Transport.sendResult(result: { [string]: any })
241
+ task.spawn(function()
242
+ Net.postJson("/result?studioId=" .. studioId, result)
243
+ end)
244
+ end
245
+
246
+ function Transport.getMode(): string
247
+ return mode
248
+ end
249
+
250
+ function Transport.getForcePoll(): boolean
251
+ return forcePoll
252
+ end
253
+
254
+ --[[
255
+ Takes effect on the next connection, not this one: the change has to happen
256
+ between commands, and tearing the stream down from inside a handler would
257
+ strand the reply to the very call that asked for it.
258
+ ]]
259
+ function Transport.setForcePoll(value: boolean)
260
+ forcePoll = value
261
+ end
262
+
263
+ function Transport.isRunning(): boolean
264
+ return running
265
+ end
266
+
267
+ --[[
268
+ Connects and keeps reconnecting until `stop` is called. Backs off on repeated
269
+ failure so a server that is simply not running does not spin the CPU.
270
+ ]]
271
+ function Transport.start(id: string, callbacks: Callbacks)
272
+ if running then
273
+ return
274
+ end
275
+ running = true
276
+ studioId = id
277
+
278
+ task.spawn(function()
279
+ local delay = Config.RECONNECT_DELAY
280
+ while running do
281
+ callbacks.onStatus("connecting")
282
+
283
+ local streamed = not forcePoll and supportsWebStream() and runStream(callbacks)
284
+ local upgrading = false
285
+ if running and not streamed then
286
+ upgrading = runPolling(callbacks)
287
+ end
288
+
289
+ if not running then
290
+ break
291
+ end
292
+
293
+ -- Polling was healthy and stood aside to let streaming be retried.
294
+ -- The backoff exists to slow down repeated failures, and this is not
295
+ -- one, so go straight back round.
296
+ if upgrading then
297
+ delay = Config.RECONNECT_DELAY
298
+ continue
299
+ end
300
+
301
+ task.wait(delay)
302
+ delay = math.min(delay * 2, Config.MAX_RECONNECT_DELAY)
303
+ -- A connection that lasted long enough to do real work resets the
304
+ -- backoff; only repeated fast failures should slow retries down.
305
+ if activeStream ~= nil then
306
+ delay = Config.RECONNECT_DELAY
307
+ end
308
+ end
309
+ end)
310
+ end
311
+
312
+ function Transport.stop()
313
+ if not running then
314
+ return
315
+ end
316
+ running = false
317
+ pcall(function()
318
+ Net.postJson("/bye?studioId=" .. studioId, {})
319
+ end)
320
+ if activeStream then
321
+ pcall(function()
322
+ activeStream:Close()
323
+ end)
324
+ activeStream = nil
325
+ end
326
+ end
327
+
328
+ return Transport
@@ -0,0 +1,72 @@
1
+ --!strict
2
+ --[[
3
+ Wraps every mutation in a ChangeHistoryService recording.
4
+
5
+ Two things fall out of this that competing servers do not give you:
6
+
7
+ 1. One agent action is one Ctrl+Z. A tool that creates 200 parts collapses to
8
+ a single undo step named after the tool, instead of 200 separate steps or
9
+ -- worse -- no undo entry at all.
10
+ 2. A batch that fails part way through is *cancelled*, not committed. Without
11
+ this, a `modify` call that sets 5 of 10 properties and then hits a bad
12
+ value leaves the place in a half-edited state the user has to unpick by
13
+ hand.
14
+
15
+ `SetWaypoint` is deliberately not used; it has been superseded by recordings
16
+ and cannot express the cancel case.
17
+ ]]
18
+
19
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
20
+
21
+ local Undo = {}
22
+
23
+ --[[
24
+ Runs `body` inside a named recording. Returns its result, and whether a
25
+ recording was actually opened.
26
+
27
+ `TryBeginRecording` returns nil when another recording is already open (for
28
+ example the user is mid-drag in the viewport) or when history is unavailable.
29
+ The work still runs in that case -- refusing to act would be worse than a
30
+ slightly coarser undo stack -- it simply joins the surrounding recording.
31
+
32
+ That second return value is not decoration. Whether an operation is undoable
33
+ is something tools tell the agent, and therefore the user, so it has to be
34
+ observed rather than assumed: a claim of "one Ctrl+Z" that silently degrades
35
+ to no undo entry at all is worse than no claim.
36
+ ]]
37
+ function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, boolean)
38
+ local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
39
+ if not identifier then
40
+ return body(), false
41
+ end
42
+
43
+ local ok, result = pcall(body)
44
+ if ok then
45
+ ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
46
+ return result, true
47
+ end
48
+
49
+ ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
50
+ error(result, 0)
51
+ end
52
+
53
+ --[[
54
+ Commits the recording even when `body` fails, for operations whose partial
55
+ effects are still wanted. Used only by handlers that report per-item results
56
+ and therefore tell the agent exactly what did and did not apply.
57
+ ]]
58
+ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T): (T, boolean)
59
+ local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
60
+ if not identifier then
61
+ return body(), false
62
+ end
63
+
64
+ local ok, result = pcall(body)
65
+ ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
66
+ if not ok then
67
+ error(result, 0)
68
+ end
69
+ return result, true
70
+ end
71
+
72
+ return Undo