@el4cteo/rbx-studio-mcp 0.7.6 → 0.7.8

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.
@@ -1,277 +1,356 @@
1
- --!strict
2
- --[[
3
- The output log, kept ourselves.
4
-
5
- `LogService:GetLogHistory()` looks like the right API and cannot be relied on.
6
- Measured: in an editor session it returns a handful of lines from around
7
- startup and then stops growing -- prints made later never appear -- and in a
8
- playtest server's DataModel it succeeds and returns nothing at all, even
9
- immediately after something printed there. The call reports no error either
10
- way, so the tool built on it answered "the log is empty" for a place that was
11
- logging steadily, which is worse than answering nothing.
12
-
13
- `LogService.MessageOut` does fire reliably in both. So the buffer is filled
14
- from the event, seeded once from whatever history the API does offer, and kept
15
- from growing without bound.
16
-
17
- This has to be connected at plugin load rather than when a read arrives:
18
- anything logged before the first subscription is gone for good.
19
- ]]
20
-
21
- local LogService = game:GetService("LogService")
22
- local ScriptContext = game:GetService("ScriptContext")
23
-
24
- -- Roughly what the Output window keeps. Big enough that a playtest's worth of
25
- -- logging survives, small enough to stay cheap to hold and to scan.
26
- local CAPACITY = 2000
27
-
28
- -- Trimming in batches rather than one entry per insert: a chunk that logs in a
29
- -- loop would otherwise shift the whole array on every single line.
30
- local SLACK = 500
31
-
32
- -- How far back an arriving stack trace will look for the error line it belongs
33
- -- to. The two events fire within moments of each other, so anything beyond a
34
- -- handful of entries is a mismatch rather than a late delivery.
35
- local CORRELATION_WINDOW = 20
36
-
37
- export type Entry = {
38
- level: string,
39
- message: string,
40
- timestamp: number,
41
- -- Present on errors once the matching stack trace has been correlated.
42
- stack: string?,
43
- source: string?,
44
- }
45
-
46
- local LEVELS: { [number]: string } = {
47
- [Enum.MessageType.MessageOutput.Value] = "print",
48
- [Enum.MessageType.MessageInfo.Value] = "info",
49
- [Enum.MessageType.MessageWarning.Value] = "warning",
50
- [Enum.MessageType.MessageError.Value] = "error",
51
- }
52
-
53
- local LogBuffer = {}
54
-
55
- local entries: { Entry } = {}
56
- local dropped = 0
57
- local started = false
58
- local startedAt = 0
59
-
60
- --[[
61
- Stack traces that arrived before the error line they describe.
62
-
63
- `ScriptContext.Error` and `LogService.MessageOut` are independent events for
64
- the same failure and there is no guaranteed order between them, so the trace
65
- is held here until its line shows up rather than being dropped for arriving
66
- first.
67
- ]]
68
- local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
69
- local orphanCount = 0
70
-
71
- --[[
72
- The engine also writes each trace to the log as plain lines -- "Stack Begin",
73
- one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
74
- Output window renders it. Having attached the trace to its error already,
75
- echoing those lines as ordinary entries is duplication that reads like
76
- unrelated output sitting under the failure.
77
-
78
- They are collected instead of stored, and serve as the fallback source of a
79
- trace when ScriptContext.Error is unavailable.
80
- ]]
81
- local STACK_OPEN = "Stack Begin"
82
- local STACK_CLOSE = "Stack End"
83
-
84
- -- If a close never arrives -- a place that prints "Stack Begin" itself -- the
85
- -- collected lines are released rather than swallowed indefinitely.
86
- local MAX_STACK_LINES = 60
87
-
88
- local collecting: { string }? = nil
89
-
90
- local function pushEntry(entry: Entry)
91
- table.insert(entries, entry)
92
-
93
- if #entries > CAPACITY + SLACK then
94
- local excess = #entries - CAPACITY
95
- entries = table.move(entries, excess + 1, #entries, 1, {})
96
- dropped += excess
97
- end
98
- end
99
-
100
- local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
101
- local level = LEVELS[messageType.Value] or "print"
102
- local entry: Entry = {
103
- level = level,
104
- message = message,
105
- timestamp = timestamp or os.time(),
106
- }
107
-
108
- -- Claim a trace that beat its own error line here.
109
- if level == "error" then
110
- local waiting = orphanedStacks[message]
111
- if waiting then
112
- entry.stack = waiting.stack
113
- entry.source = waiting.source
114
- orphanedStacks[message] = nil
115
- orphanCount -= 1
116
- end
117
- end
118
-
119
- pushEntry(entry)
120
- end
121
-
122
- --[[
123
- Attaches a stack trace to the error it belongs to.
124
-
125
- Correlated by message text because the engine offers nothing better: the two
126
- events carry no shared identifier. Matching the most recent unattached error
127
- with the same text is right in every case that matters, and the worst outcome
128
- if two identical errors interleave is that a trace lands on the wrong one of
129
- two identical lines.
130
- ]]
131
- local function attachStack(message: string, stack: string, source: string?)
132
- local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
133
- for index = #entries, first, -1 do
134
- local entry = entries[index]
135
- if entry.level == "error" and entry.message == message and entry.stack == nil then
136
- entry.stack = stack
137
- entry.source = source
138
- return
139
- end
140
- end
141
-
142
- -- Not logged yet. Held for the line to claim, with a cap so a flood of
143
- -- errors that never surface cannot grow this without bound.
144
- if orphanCount < CAPACITY then
145
- if orphanedStacks[message] == nil then
146
- orphanCount += 1
147
- end
148
- orphanedStacks[message] = { stack = stack, source = source }
149
- end
150
- end
151
-
152
- --[[
153
- Begins recording. Safe to call more than once; only the first call connects,
154
- so a reload cannot end up with two subscriptions writing every line twice.
155
- ]]
156
- function LogBuffer.start()
157
- if started then
158
- return
159
- end
160
- started = true
161
- startedAt = os.time()
162
-
163
- -- Seeded before subscribing so the backlog stays in order ahead of live
164
- -- lines. MessageOut only fires for messages after the connection, so nothing
165
- -- is counted twice.
166
- local ok, history = pcall(function()
167
- return LogService:GetLogHistory()
168
- end)
169
- if ok and typeof(history) == "table" then
170
- for _, item in history :: { { [string]: any } } do
171
- local kind = item.messageType
172
- if item.message ~= nil and typeof(kind) == "EnumItem" then
173
- push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
174
- end
175
- end
176
- end
177
-
178
- LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
179
- local buffered = collecting
180
-
181
- if buffered then
182
- if message == STACK_CLOSE then
183
- collecting = nil
184
- --[[
185
- ScriptContext.Error is preferred -- it names the originating
186
- instance too -- but not blindly. Measured on a nested failure
187
- the two agree exactly, one frame each, so folding loses
188
- nothing today. They are still compared by frame count rather
189
- than trusted to stay equal, because the cost of being wrong
190
- is a silently shallower trace and the check is one compare.
191
- ]]
192
- if #buffered > 0 then
193
- local recent = entries[#entries]
194
- if recent and recent.level == "error" then
195
- local existing = recent.stack
196
- local existingFrames = if existing then #string.split(existing, "\n") else 0
197
- if existing == nil or #buffered > existingFrames then
198
- recent.stack = table.concat(buffered, "\n")
199
- end
200
- end
201
- end
202
- return
203
- end
204
-
205
- if #buffered < MAX_STACK_LINES then
206
- table.insert(buffered, message)
207
- return
208
- end
209
-
210
- -- Not a stack after all. Release what was held, in order, and carry
211
- -- on treating this as ordinary output.
212
- collecting = nil
213
- pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
214
- for _, held in buffered do
215
- pushEntry({ level = "print", message = held, timestamp = os.time() })
216
- end
217
- elseif message == STACK_OPEN then
218
- collecting = {}
219
- return
220
- end
221
-
222
- push(message, messageType)
223
- end)
224
-
225
- --[[
226
- Stack traces, which MessageOut does not carry.
227
-
228
- Without this an error in the log is a message and nothing else, while the
229
- trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
230
- End" prints that read as unrelated output. Attaching it to the error turns
231
- three ambiguous lines into one answer with a script and a line number.
232
-
233
- `ErrorDetailed` would be the richer source and is closed to plugins --
234
- it needs the RobloxScript capability. `Error` is reachable and carries
235
- message, trace and originating script, which is the part that matters.
236
- ]]
237
- pcall(function()
238
- ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
239
- local origin: string? = nil
240
- if typeof(source) == "Instance" then
241
- local named = pcall(function()
242
- origin = source:GetFullName()
243
- end)
244
- if not named then
245
- origin = source.Name
246
- end
247
- end
248
- attachStack(message, stack, origin)
249
- end)
250
- end)
251
- end
252
-
253
- --[[
254
- Everything held, oldest first, plus how much fell off the front. Returned as a
255
- copy so a caller filtering it cannot disturb the buffer.
256
- ]]
257
- function LogBuffer.all(): ({ Entry }, number)
258
- return table.move(entries, 1, #entries, 1, {}), dropped
259
- end
260
-
261
- --[[
262
- How long this buffer has been recording, in seconds.
263
-
264
- An empty log is ambiguous without it. In a playtest the plugin loads at the
265
- same moment as the place's own scripts, so a script that logs on startup can
266
- beat the subscription -- and "nothing was logged" then looks identical to
267
- "recording began after the thing you are asking about". Reporting the window
268
- lets the two be told apart instead of guessed at.
269
- ]]
270
- function LogBuffer.recordingSeconds(): number
271
- if not started then
272
- return 0
273
- end
274
- return math.max(0, os.time() - startedAt)
275
- end
276
-
277
- return LogBuffer
1
+ --!strict
2
+ --[[
3
+ The output log, kept ourselves.
4
+
5
+ `LogService:GetLogHistory()` looks like the right API and cannot be relied on.
6
+ Measured: in an editor session it returns a handful of lines from around
7
+ startup and then stops growing -- prints made later never appear -- and in a
8
+ playtest server's DataModel it succeeds and returns nothing at all, even
9
+ immediately after something printed there. The call reports no error either
10
+ way, so the tool built on it answered "the log is empty" for a place that was
11
+ logging steadily, which is worse than answering nothing.
12
+
13
+ `LogService.MessageOut` does fire reliably in both. So the buffer is filled
14
+ from the event, seeded once from whatever history the API does offer, and kept
15
+ from growing without bound.
16
+
17
+ This has to be connected at plugin load rather than when a read arrives:
18
+ anything logged before the first subscription is gone for good.
19
+ ]]
20
+
21
+ local LogService = game:GetService("LogService")
22
+ local ScriptContext = game:GetService("ScriptContext")
23
+ local HttpService = game:GetService("HttpService")
24
+
25
+ -- Roughly what the Output window keeps. Big enough that a playtest's worth of
26
+ -- logging survives, small enough to stay cheap to hold and to scan.
27
+ local CAPACITY = 2000
28
+
29
+ -- Trimming in batches rather than one entry per insert: a chunk that logs in a
30
+ -- loop would otherwise shift the whole array on every single line.
31
+ local SLACK = 500
32
+
33
+ -- How far back an arriving stack trace will look for the error line it belongs
34
+ -- to. The two events fire within moments of each other, so anything beyond a
35
+ -- handful of entries is a mismatch rather than a late delivery.
36
+ local CORRELATION_WINDOW = 20
37
+
38
+ export type Entry = {
39
+ sequence: number?,
40
+ level: string,
41
+ message: string,
42
+ timestamp: number,
43
+ -- Present on errors once the matching stack trace has been correlated.
44
+ stack: string?,
45
+ source: string?,
46
+ }
47
+
48
+ local LEVELS: { [number]: string } = {
49
+ [Enum.MessageType.MessageOutput.Value] = "print",
50
+ [Enum.MessageType.MessageInfo.Value] = "info",
51
+ [Enum.MessageType.MessageWarning.Value] = "warning",
52
+ [Enum.MessageType.MessageError.Value] = "error",
53
+ }
54
+
55
+ local LogBuffer = {}
56
+
57
+ local entries: { Entry } = {}
58
+ local dropped = 0
59
+ local started = false
60
+ local startedAt = 0
61
+ local studioToken = HttpService:GenerateGUID(false)
62
+ local nextSequence = 0
63
+ local clientBuffers: { [Player]: { token: string, entries: { Entry }, dropped: number, sequence: number, startedAt: number, ready: boolean, orphan: { [string]: { stack: string, source: string? } } } } = {}
64
+
65
+ --[[
66
+ Stack traces that arrived before the error line they describe.
67
+
68
+ `ScriptContext.Error` and `LogService.MessageOut` are independent events for
69
+ the same failure and there is no guaranteed order between them, so the trace
70
+ is held here until its line shows up rather than being dropped for arriving
71
+ first.
72
+ ]]
73
+ local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
74
+ local orphanCount = 0
75
+
76
+ --[[
77
+ The engine also writes each trace to the log as plain lines -- "Stack Begin",
78
+ one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
79
+ Output window renders it. Having attached the trace to its error already,
80
+ echoing those lines as ordinary entries is duplication that reads like
81
+ unrelated output sitting under the failure.
82
+
83
+ They are collected instead of stored, and serve as the fallback source of a
84
+ trace when ScriptContext.Error is unavailable.
85
+ ]]
86
+ local STACK_OPEN = "Stack Begin"
87
+ local STACK_CLOSE = "Stack End"
88
+
89
+ -- If a close never arrives -- a place that prints "Stack Begin" itself -- the
90
+ -- collected lines are released rather than swallowed indefinitely.
91
+ local MAX_STACK_LINES = 60
92
+
93
+ local collecting: { string }? = nil
94
+
95
+ local function pushEntry(entry: Entry)
96
+ nextSequence += 1
97
+ entry.sequence = nextSequence
98
+ table.insert(entries, entry)
99
+
100
+ if #entries > CAPACITY + SLACK then
101
+ local excess = #entries - CAPACITY
102
+ entries = table.move(entries, excess + 1, #entries, 1, {})
103
+ dropped += excess
104
+ end
105
+ end
106
+
107
+ -- Each player gets a separate stream and token. Rejoining, reconnecting to a
108
+ -- different server DataModel, or a plugin reload invalidates old cursors.
109
+ function LogBuffer.startClient(player: Player)
110
+ clientBuffers[player] = { token = HttpService:GenerateGUID(false), entries = {}, dropped = 0, sequence = 0, startedAt = os.time(), ready = false, orphan = {} }
111
+ end
112
+
113
+ function LogBuffer.stopClient(player: Player)
114
+ clientBuffers[player] = nil
115
+ end
116
+
117
+ function LogBuffer.pushClient(player: Player, payload: any)
118
+ local buffer = clientBuffers[player]
119
+ if not buffer or typeof(payload) ~= "table" then return end
120
+ if payload.kind == "ready" then buffer.ready = true; return end
121
+ if payload.kind == "trace" then
122
+ if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
123
+ for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
124
+ local item = buffer.entries[index]
125
+ if item.level == "error" and item.message == payload.message and item.stack == nil then
126
+ item.stack = string.sub(payload.stack, 1, 4000)
127
+ item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
128
+ return
129
+ end
130
+ end
131
+ local orphanCount = 0
132
+ for _ in buffer.orphan do orphanCount += 1 end
133
+ if orphanCount < 20 or buffer.orphan[payload.message] ~= nil then
134
+ buffer.orphan[payload.message] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
135
+ end
136
+ return
137
+ end
138
+ local level = payload.level
139
+ if level ~= "print" and level ~= "info" and level ~= "warning" and level ~= "error" then return end
140
+ if typeof(payload.message) ~= "string" then return end
141
+ buffer.sequence += 1
142
+ local entry: Entry = {
143
+ sequence = buffer.sequence,
144
+ level = level,
145
+ message = string.sub(payload.message, 1, 2000),
146
+ timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
147
+ stack = if typeof(payload.stack) == "string" then string.sub(payload.stack, 1, 4000) else nil,
148
+ source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil,
149
+ }
150
+ local waiting = buffer.orphan[entry.message]
151
+ if level == "error" and waiting then
152
+ entry.stack, entry.source = waiting.stack, waiting.source
153
+ buffer.orphan[entry.message] = nil
154
+ end
155
+ table.insert(buffer.entries, entry)
156
+ if #buffer.entries > CAPACITY + SLACK then
157
+ local excess = #buffer.entries - CAPACITY
158
+ buffer.entries = table.move(buffer.entries, excess + 1, #buffer.entries, 1, {})
159
+ buffer.dropped += excess
160
+ end
161
+ end
162
+
163
+ function LogBuffer.clientReady(player: Player): boolean
164
+ local buffer = clientBuffers[player]
165
+ return buffer ~= nil and buffer.ready == true
166
+ end
167
+
168
+ function LogBuffer.stream(player: Player?): ({ Entry }, number, string, number, number)
169
+ if player then
170
+ local buffer = clientBuffers[player]
171
+ if buffer then
172
+ return table.move(buffer.entries, 1, #buffer.entries, 1, {}), buffer.dropped, buffer.token, buffer.sequence, math.max(0, os.time() - buffer.startedAt)
173
+ end
174
+ return {}, 0, "", 0, 0
175
+ end
176
+ return table.move(entries, 1, #entries, 1, {}), dropped, studioToken, nextSequence, LogBuffer.recordingSeconds()
177
+ end
178
+
179
+ local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
180
+ local level = LEVELS[messageType.Value] or "print"
181
+ local entry: Entry = {
182
+ level = level,
183
+ message = message,
184
+ timestamp = timestamp or os.time(),
185
+ }
186
+
187
+ -- Claim a trace that beat its own error line here.
188
+ if level == "error" then
189
+ local waiting = orphanedStacks[message]
190
+ if waiting then
191
+ entry.stack = waiting.stack
192
+ entry.source = waiting.source
193
+ orphanedStacks[message] = nil
194
+ orphanCount -= 1
195
+ end
196
+ end
197
+
198
+ pushEntry(entry)
199
+ end
200
+
201
+ --[[
202
+ Attaches a stack trace to the error it belongs to.
203
+
204
+ Correlated by message text because the engine offers nothing better: the two
205
+ events carry no shared identifier. Matching the most recent unattached error
206
+ with the same text is right in every case that matters, and the worst outcome
207
+ if two identical errors interleave is that a trace lands on the wrong one of
208
+ two identical lines.
209
+ ]]
210
+ local function attachStack(message: string, stack: string, source: string?)
211
+ local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
212
+ for index = #entries, first, -1 do
213
+ local entry = entries[index]
214
+ if entry.level == "error" and entry.message == message and entry.stack == nil then
215
+ entry.stack = stack
216
+ entry.source = source
217
+ return
218
+ end
219
+ end
220
+
221
+ -- Not logged yet. Held for the line to claim, with a cap so a flood of
222
+ -- errors that never surface cannot grow this without bound.
223
+ if orphanCount < CAPACITY then
224
+ if orphanedStacks[message] == nil then
225
+ orphanCount += 1
226
+ end
227
+ orphanedStacks[message] = { stack = stack, source = source }
228
+ end
229
+ end
230
+
231
+ --[[
232
+ Begins recording. Safe to call more than once; only the first call connects,
233
+ so a reload cannot end up with two subscriptions writing every line twice.
234
+ ]]
235
+ function LogBuffer.start()
236
+ if started then
237
+ return
238
+ end
239
+ started = true
240
+ startedAt = os.time()
241
+
242
+ -- Seeded before subscribing so the backlog stays in order ahead of live
243
+ -- lines. MessageOut only fires for messages after the connection, so nothing
244
+ -- is counted twice.
245
+ local ok, history = pcall(function()
246
+ return LogService:GetLogHistory()
247
+ end)
248
+ if ok and typeof(history) == "table" then
249
+ for _, item in history :: { { [string]: any } } do
250
+ local kind = item.messageType
251
+ if item.message ~= nil and typeof(kind) == "EnumItem" then
252
+ push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
253
+ end
254
+ end
255
+ end
256
+
257
+ LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
258
+ local buffered = collecting
259
+
260
+ if buffered then
261
+ if message == STACK_CLOSE then
262
+ collecting = nil
263
+ --[[
264
+ ScriptContext.Error is preferred -- it names the originating
265
+ instance too -- but not blindly. Measured on a nested failure
266
+ the two agree exactly, one frame each, so folding loses
267
+ nothing today. They are still compared by frame count rather
268
+ than trusted to stay equal, because the cost of being wrong
269
+ is a silently shallower trace and the check is one compare.
270
+ ]]
271
+ if #buffered > 0 then
272
+ local recent = entries[#entries]
273
+ if recent and recent.level == "error" then
274
+ local existing = recent.stack
275
+ local existingFrames = if existing then #string.split(existing, "\n") else 0
276
+ if existing == nil or #buffered > existingFrames then
277
+ recent.stack = table.concat(buffered, "\n")
278
+ end
279
+ end
280
+ end
281
+ return
282
+ end
283
+
284
+ if #buffered < MAX_STACK_LINES then
285
+ table.insert(buffered, message)
286
+ return
287
+ end
288
+
289
+ -- Not a stack after all. Release what was held, in order, and carry
290
+ -- on treating this as ordinary output.
291
+ collecting = nil
292
+ pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
293
+ for _, held in buffered do
294
+ pushEntry({ level = "print", message = held, timestamp = os.time() })
295
+ end
296
+ elseif message == STACK_OPEN then
297
+ collecting = {}
298
+ return
299
+ end
300
+
301
+ push(message, messageType)
302
+ end)
303
+
304
+ --[[
305
+ Stack traces, which MessageOut does not carry.
306
+
307
+ Without this an error in the log is a message and nothing else, while the
308
+ trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
309
+ End" prints that read as unrelated output. Attaching it to the error turns
310
+ three ambiguous lines into one answer with a script and a line number.
311
+
312
+ `ErrorDetailed` would be the richer source and is closed to plugins --
313
+ it needs the RobloxScript capability. `Error` is reachable and carries
314
+ message, trace and originating script, which is the part that matters.
315
+ ]]
316
+ pcall(function()
317
+ ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
318
+ local origin: string? = nil
319
+ if typeof(source) == "Instance" then
320
+ local named = pcall(function()
321
+ origin = source:GetFullName()
322
+ end)
323
+ if not named then
324
+ origin = source.Name
325
+ end
326
+ end
327
+ attachStack(message, stack, origin)
328
+ end)
329
+ end)
330
+ end
331
+
332
+ --[[
333
+ Everything held, oldest first, plus how much fell off the front. Returned as a
334
+ copy so a caller filtering it cannot disturb the buffer.
335
+ ]]
336
+ function LogBuffer.all(): ({ Entry }, number)
337
+ return table.move(entries, 1, #entries, 1, {}), dropped
338
+ end
339
+
340
+ --[[
341
+ How long this buffer has been recording, in seconds.
342
+
343
+ An empty log is ambiguous without it. In a playtest the plugin loads at the
344
+ same moment as the place's own scripts, so a script that logs on startup can
345
+ beat the subscription -- and "nothing was logged" then looks identical to
346
+ "recording began after the thing you are asking about". Reporting the window
347
+ lets the two be told apart instead of guessed at.
348
+ ]]
349
+ function LogBuffer.recordingSeconds(): number
350
+ if not started then
351
+ return 0
352
+ end
353
+ return math.max(0, os.time() - startedAt)
354
+ end
355
+
356
+ return LogBuffer
@@ -98,7 +98,7 @@ function Net.explain(message: string): string
98
98
  then
99
99
  return string.format(
100
100
  "Nothing is listening on port %d. Start the server with "
101
- .. "`npx -y roblox-studio-mcp` (or check --port if you changed it).",
101
+ .. "`npx -y @el4cteo/rbx-studio-mcp` (or check --port if you changed it).",
102
102
  Config.getPort()
103
103
  )
104
104
  end