@chrrxs/robloxstudio-mcp-inspector 2.22.0 → 2.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -32,7 +32,7 @@ function publishedInstanceId(placeId) {
|
|
|
32
32
|
return void 0;
|
|
33
33
|
return `place:${Math.trunc(placeId)}`;
|
|
34
34
|
}
|
|
35
|
-
var RoutingFailure, STALE_INSTANCE_MS, INSTANCE_ALIAS_TTL_MS, BridgeService;
|
|
35
|
+
var RoutingFailure, STALE_INSTANCE_MS, DUPLICATE_TAKEOVER_MS, INSTANCE_ALIAS_TTL_MS, BridgeService;
|
|
36
36
|
var init_bridge_service = __esm({
|
|
37
37
|
"../core/dist/bridge-service.js"() {
|
|
38
38
|
"use strict";
|
|
@@ -45,6 +45,7 @@ var init_bridge_service = __esm({
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
STALE_INSTANCE_MS = 3e4;
|
|
48
|
+
DUPLICATE_TAKEOVER_MS = 3e3;
|
|
48
49
|
INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
|
|
49
50
|
BridgeService = class {
|
|
50
51
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
@@ -167,14 +168,18 @@ var init_bridge_service = __esm({
|
|
|
167
168
|
}
|
|
168
169
|
const existing = Array.from(this.instances.values()).find((i) => i.instanceId === instanceId && i.role === assignedRole && i.pluginSessionId !== pluginSessionId);
|
|
169
170
|
if (existing) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
171
|
+
if (Date.now() - existing.lastActivity > DUPLICATE_TAKEOVER_MS) {
|
|
172
|
+
this.unregisterInstance(existing.pluginSessionId);
|
|
173
|
+
} else {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: {
|
|
177
|
+
code: "duplicate_instance_role",
|
|
178
|
+
message: `Another plugin is already registered as (${instanceId}, ${assignedRole}).`,
|
|
179
|
+
existing: toPublic(existing)
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
178
183
|
}
|
|
179
184
|
const registered = {
|
|
180
185
|
pluginSessionId,
|
|
@@ -501,6 +506,93 @@ function cacheSet(key, entry) {
|
|
|
501
506
|
function docUrl(category, name) {
|
|
502
507
|
return `${DOCS_BASE_URL}/${category}/${encodeURIComponent(name)}.md`;
|
|
503
508
|
}
|
|
509
|
+
function normalizeDocName(value) {
|
|
510
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
511
|
+
}
|
|
512
|
+
function queryNameVariants(value) {
|
|
513
|
+
const parts = value.split(/[./:#\s]+/).filter(Boolean);
|
|
514
|
+
const variants = /* @__PURE__ */ new Set([normalizeDocName(value)]);
|
|
515
|
+
for (const part of parts) {
|
|
516
|
+
const normalized = normalizeDocName(part);
|
|
517
|
+
if (normalized && normalized !== "enum")
|
|
518
|
+
variants.add(normalized);
|
|
519
|
+
}
|
|
520
|
+
return Array.from(variants).filter(Boolean);
|
|
521
|
+
}
|
|
522
|
+
function editDistance(left, right) {
|
|
523
|
+
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
524
|
+
for (let i = 1; i <= left.length; i++) {
|
|
525
|
+
let diagonal = previous[0];
|
|
526
|
+
previous[0] = i;
|
|
527
|
+
for (let j = 1; j <= right.length; j++) {
|
|
528
|
+
const above = previous[j];
|
|
529
|
+
previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
|
|
530
|
+
diagonal = above;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return previous[right.length];
|
|
534
|
+
}
|
|
535
|
+
async function fetchDocCatalog() {
|
|
536
|
+
if (catalogCache && Date.now() - catalogCache.fetchedAt <= CACHE_TTL_MS) {
|
|
537
|
+
return catalogCache.pages;
|
|
538
|
+
}
|
|
539
|
+
const controller = new AbortController();
|
|
540
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
541
|
+
let response;
|
|
542
|
+
try {
|
|
543
|
+
response = await fetch(DOCS_INDEX_URL, {
|
|
544
|
+
signal: controller.signal,
|
|
545
|
+
headers: { Accept: "text/html" }
|
|
546
|
+
});
|
|
547
|
+
} finally {
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
}
|
|
550
|
+
if (!response.ok) {
|
|
551
|
+
throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
|
|
552
|
+
}
|
|
553
|
+
const html = await response.text();
|
|
554
|
+
const pages = /* @__PURE__ */ new Map();
|
|
555
|
+
const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
|
|
556
|
+
for (const match of html.matchAll(routePattern)) {
|
|
557
|
+
const category = match[1];
|
|
558
|
+
const name = match[2];
|
|
559
|
+
const key = `${category}/${name}`;
|
|
560
|
+
pages.set(key, { category, name, url: docUrl(category, name) });
|
|
561
|
+
}
|
|
562
|
+
const result = Array.from(pages.values());
|
|
563
|
+
if (result.length === 0) {
|
|
564
|
+
throw new Error("Roblox docs index did not contain any engine reference pages");
|
|
565
|
+
}
|
|
566
|
+
catalogCache = { fetchedAt: Date.now(), pages: result };
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
async function recommendRobloxDocs(category, name, limit = 5) {
|
|
570
|
+
const variants = queryNameVariants(name);
|
|
571
|
+
const catalog = await fetchDocCatalog();
|
|
572
|
+
return catalog.map((page) => {
|
|
573
|
+
const candidate = normalizeDocName(page.name);
|
|
574
|
+
const similarity = Math.max(...variants.map((variant) => {
|
|
575
|
+
if (variant === candidate)
|
|
576
|
+
return 2;
|
|
577
|
+
const distance = editDistance(variant, candidate);
|
|
578
|
+
const ratio = 1 - distance / Math.max(variant.length, candidate.length, 1);
|
|
579
|
+
const prefixBonus = variant.startsWith(candidate) || candidate.startsWith(variant) ? 0.15 : 0;
|
|
580
|
+
const containsBonus = variant.includes(candidate) || candidate.includes(variant) ? 0.05 : 0;
|
|
581
|
+
return ratio + prefixBonus + containsBonus;
|
|
582
|
+
}));
|
|
583
|
+
return { page, score: similarity + (page.category === category ? 0.08 : 0) };
|
|
584
|
+
}).sort((a, b) => b.score - a.score || a.page.name.localeCompare(b.page.name)).slice(0, Math.max(1, limit)).map(({ page }) => page);
|
|
585
|
+
}
|
|
586
|
+
function recommendationsMarkdown(requestedCategory, requestedName, recommendations) {
|
|
587
|
+
const lines = recommendations.map((page) => `- [${page.category}/${page.name}](${page.url}) \u2014 retry with \`name="${page.name}", doc_type="${page.category}"\``);
|
|
588
|
+
return [
|
|
589
|
+
`# No exact Roblox documentation page found`,
|
|
590
|
+
"",
|
|
591
|
+
`The lookup \`${requestedCategory}/${requestedName}\` did not resolve. Recommended pages:`,
|
|
592
|
+
"",
|
|
593
|
+
...lines
|
|
594
|
+
].join("\n");
|
|
595
|
+
}
|
|
504
596
|
async function fetchRobloxDoc(category, name) {
|
|
505
597
|
const key = `${category}/${name}`;
|
|
506
598
|
const cached = cacheGet(key);
|
|
@@ -565,7 +657,24 @@ function extractSection(markdown, section) {
|
|
|
565
657
|
return lines.slice(start, end).join("\n").trimEnd();
|
|
566
658
|
}
|
|
567
659
|
async function getRobloxDoc(category, name, section) {
|
|
568
|
-
|
|
660
|
+
let markdown;
|
|
661
|
+
try {
|
|
662
|
+
markdown = await fetchRobloxDoc(category, name);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (!(error instanceof DocNotFoundError))
|
|
665
|
+
throw error;
|
|
666
|
+
try {
|
|
667
|
+
const recommendations = await recommendRobloxDocs(category, name);
|
|
668
|
+
return {
|
|
669
|
+
content: recommendationsMarkdown(category, name, recommendations),
|
|
670
|
+
truncated: false,
|
|
671
|
+
sections: [],
|
|
672
|
+
recommendations
|
|
673
|
+
};
|
|
674
|
+
} catch {
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
569
678
|
const sections = listSections(markdown);
|
|
570
679
|
if (section) {
|
|
571
680
|
const extracted = extractSection(markdown, section);
|
|
@@ -584,12 +693,13 @@ async function getRobloxDoc(category, name, section) {
|
|
|
584
693
|
}
|
|
585
694
|
return { content: markdown, truncated: false, sections };
|
|
586
695
|
}
|
|
587
|
-
var DOC_CATEGORIES, DOCS_BASE_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, DocNotFoundError;
|
|
696
|
+
var DOC_CATEGORIES, DOCS_BASE_URL, DOCS_INDEX_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, catalogCache, DocNotFoundError;
|
|
588
697
|
var init_roblox_docs = __esm({
|
|
589
698
|
"../core/dist/roblox-docs.js"() {
|
|
590
699
|
"use strict";
|
|
591
700
|
DOC_CATEGORIES = ["classes", "enums", "datatypes", "libraries", "globals"];
|
|
592
701
|
DOCS_BASE_URL = "https://create.roblox.com/docs/reference/engine";
|
|
702
|
+
DOCS_INDEX_URL = DOCS_BASE_URL;
|
|
593
703
|
FETCH_TIMEOUT_MS = 15e3;
|
|
594
704
|
CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
595
705
|
NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -13381,7 +13491,7 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
13381
13491
|
{
|
|
13382
13492
|
name: "get_roblox_docs",
|
|
13383
13493
|
category: "read",
|
|
13384
|
-
description: 'Fetch official Roblox engine API documentation as markdown from create.roblox.com. Call this BEFORE writing or editing code that uses an engine class, enum, datatype, or Luau library you are not fully certain about (e.g. ProximityPrompt, Enum.KeyCode, CFrame, TweenService) \u2014 the page includes the description, properties, methods, events, and code samples. Results are cached, so repeat lookups are cheap. Very large pages are truncated with a section index; pass section (e.g. "Properties", "Methods", "Events") to read one section in full.',
|
|
13494
|
+
description: 'Fetch official Roblox engine API documentation as markdown from create.roblox.com. Call this BEFORE writing or editing code that uses an engine class, enum, datatype, or Luau library you are not fully certain about (e.g. ProximityPrompt, Enum.KeyCode, CFrame, TweenService) \u2014 the page includes the description, properties, methods, events, and code samples. Unresolved names return ranked recommendations from the official engine index, including pages in other doc categories. Results are cached, so repeat lookups are cheap. Very large pages are truncated with a section index; pass section (e.g. "Properties", "Methods", "Events") to read one section in full.',
|
|
13385
13495
|
inputSchema: {
|
|
13386
13496
|
type: "object",
|
|
13387
13497
|
properties: {
|
package/package.json
CHANGED
|
@@ -712,11 +712,11 @@ local function computeInstanceId()
|
|
|
712
712
|
return `anon:{fresh}`
|
|
713
713
|
end
|
|
714
714
|
local assignedRole
|
|
715
|
-
local duplicateInstanceRole = false
|
|
716
715
|
local hasVersionMismatch = false
|
|
717
716
|
local lastVersionMismatchWarningKey
|
|
718
717
|
local lastReadyInstanceId
|
|
719
718
|
local readyFailureLogKeys = {}
|
|
719
|
+
local retryingDuplicateReady = false
|
|
720
720
|
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
721
721
|
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
722
722
|
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
@@ -906,11 +906,12 @@ local function ensureIdentityWatcher(conn)
|
|
|
906
906
|
end
|
|
907
907
|
end
|
|
908
908
|
function sendReady(conn)
|
|
909
|
-
if duplicateInstanceRole then
|
|
910
|
-
return nil
|
|
911
|
-
end
|
|
912
909
|
local now = tick()
|
|
913
|
-
|
|
910
|
+
-- Normal identity refreshes stay conservatively throttled. Once a stale
|
|
911
|
+
-- predecessor causes 409, retry once per second so takeover follows the
|
|
912
|
+
-- server's short inactivity lease without another two seconds of jitter.
|
|
913
|
+
local readyInterval = if retryingDuplicateReady then 1 else 2
|
|
914
|
+
if now - lastReadyPostAt < readyInterval then
|
|
914
915
|
return nil
|
|
915
916
|
end
|
|
916
917
|
lastReadyPostAt = now
|
|
@@ -942,28 +943,39 @@ function sendReady(conn)
|
|
|
942
943
|
local readyRole = detectRole()
|
|
943
944
|
local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
|
|
944
945
|
if not readyOk then
|
|
946
|
+
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
945
947
|
readyFailureLogKeys[readyLogKey] = true
|
|
946
|
-
|
|
948
|
+
if shouldLog then
|
|
949
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
|
|
950
|
+
end
|
|
947
951
|
return nil
|
|
948
952
|
end
|
|
949
953
|
if not readyResult.Success then
|
|
950
954
|
local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
955
|
+
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
951
956
|
readyFailureLogKeys[readyLogKey] = true
|
|
952
|
-
--
|
|
957
|
+
-- A predecessor can remain registered briefly when Studio exits before
|
|
958
|
+
-- its asynchronous /disconnect completes. Keep polling and retrying
|
|
959
|
+
-- /ready: the server will take us over once the predecessor has stopped
|
|
960
|
+
-- polling, while a genuinely active duplicate continues to hold routing.
|
|
953
961
|
if readyResult.StatusCode == 409 then
|
|
954
|
-
|
|
955
|
-
conn.isActive = false
|
|
962
|
+
retryingDuplicateReady = true
|
|
956
963
|
local ui = UI.getElements()
|
|
957
|
-
ui.statusLabel.Text = "
|
|
958
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
964
|
+
ui.statusLabel.Text = "Waiting for previous instance"
|
|
965
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
959
966
|
ui.detailStatusLabel.Text = reason
|
|
960
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(
|
|
961
|
-
|
|
967
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
968
|
+
if shouldLog then
|
|
969
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
970
|
+
end
|
|
962
971
|
return nil
|
|
963
972
|
end
|
|
964
|
-
|
|
973
|
+
if shouldLog then
|
|
974
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
975
|
+
end
|
|
965
976
|
return nil
|
|
966
977
|
end
|
|
978
|
+
retryingDuplicateReady = false
|
|
967
979
|
local parseOk, readyData = pcall(function()
|
|
968
980
|
return HttpService:JSONDecode(readyResult.Body)
|
|
969
981
|
end)
|
|
@@ -1413,9 +1425,9 @@ local function computeBridgeStamp()
|
|
|
1413
1425
|
for i = 1, #combined do
|
|
1414
1426
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1415
1427
|
end
|
|
1416
|
-
-- "2.22.
|
|
1428
|
+
-- "2.22.1" is replaced with the package version at package time
|
|
1417
1429
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1418
|
-
return `{tostring(h)}-2.22.
|
|
1430
|
+
return `{tostring(h)}-2.22.1`
|
|
1419
1431
|
end
|
|
1420
1432
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1421
1433
|
local function setSource(scriptInst, source)
|
|
@@ -10449,24 +10461,30 @@ local function pushEntry(msg, t, ts)
|
|
|
10449
10461
|
totalBytes += bytes
|
|
10450
10462
|
end
|
|
10451
10463
|
local function seedRuntimeHistory()
|
|
10452
|
-
if not RunService:IsRunning() then
|
|
10453
|
-
return nil
|
|
10454
|
-
end
|
|
10455
10464
|
local ok, history = pcall(function()
|
|
10456
10465
|
return LogService:GetLogHistory()
|
|
10457
10466
|
end)
|
|
10458
10467
|
if not ok then
|
|
10459
10468
|
return nil
|
|
10460
10469
|
end
|
|
10470
|
+
local isEdit = not RunService:IsRunning()
|
|
10471
|
+
-- GetLogHistory timestamps and DateTime.now() share Unix time, while
|
|
10472
|
+
-- os.clock() is elapsed time for this Studio process. Their difference is
|
|
10473
|
+
-- therefore the process launch boundary. Edit-mode history is filtered to
|
|
10474
|
+
-- that boundary so startup errors from this launch are recovered without
|
|
10475
|
+
-- importing history left by an earlier Studio process.
|
|
10476
|
+
local processStartedAt = nowSec() - os.clock()
|
|
10461
10477
|
for _, entry in history do
|
|
10462
10478
|
local _message = entry.message
|
|
10463
10479
|
if not (type(_message) == "string") then
|
|
10464
10480
|
continue
|
|
10465
10481
|
end
|
|
10466
|
-
local _exp = entry.message
|
|
10467
|
-
local _exp_1 = entry.messageType
|
|
10468
10482
|
local _timestamp = entry.timestamp
|
|
10469
|
-
|
|
10483
|
+
local timestamp = if type(_timestamp) == "number" then entry.timestamp else nil
|
|
10484
|
+
if isEdit and (timestamp == nil or timestamp < processStartedAt - 1) then
|
|
10485
|
+
continue
|
|
10486
|
+
end
|
|
10487
|
+
pushEntry(entry.message, entry.messageType, timestamp)
|
|
10470
10488
|
end
|
|
10471
10489
|
end
|
|
10472
10490
|
local function install()
|
|
@@ -10477,9 +10495,9 @@ local function install()
|
|
|
10477
10495
|
return nil
|
|
10478
10496
|
end
|
|
10479
10497
|
installed = true
|
|
10480
|
-
--
|
|
10481
|
-
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
10482
|
-
--
|
|
10498
|
+
-- Every peer can emit startup logs before the plugin finishes loading.
|
|
10499
|
+
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
10500
|
+
-- edit history is bounded to the current Studio process above.
|
|
10483
10501
|
seedRuntimeHistory()
|
|
10484
10502
|
LogService.MessageOut:Connect(function(msg, t)
|
|
10485
10503
|
pushEntry(msg, t)
|
|
@@ -10745,7 +10763,7 @@ return {
|
|
|
10745
10763
|
<Properties>
|
|
10746
10764
|
<string name="Name">State</string>
|
|
10747
10765
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10748
|
-
local CURRENT_VERSION = "2.22.
|
|
10766
|
+
local CURRENT_VERSION = "2.22.1"
|
|
10749
10767
|
local PLUGIN_VARIANT = "inspector"
|
|
10750
10768
|
local BASE_PORT = 58741
|
|
10751
10769
|
local function createConnection(port)
|
|
@@ -712,11 +712,11 @@ local function computeInstanceId()
|
|
|
712
712
|
return `anon:{fresh}`
|
|
713
713
|
end
|
|
714
714
|
local assignedRole
|
|
715
|
-
local duplicateInstanceRole = false
|
|
716
715
|
local hasVersionMismatch = false
|
|
717
716
|
local lastVersionMismatchWarningKey
|
|
718
717
|
local lastReadyInstanceId
|
|
719
718
|
local readyFailureLogKeys = {}
|
|
719
|
+
local retryingDuplicateReady = false
|
|
720
720
|
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
721
721
|
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
722
722
|
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
@@ -906,11 +906,12 @@ local function ensureIdentityWatcher(conn)
|
|
|
906
906
|
end
|
|
907
907
|
end
|
|
908
908
|
function sendReady(conn)
|
|
909
|
-
if duplicateInstanceRole then
|
|
910
|
-
return nil
|
|
911
|
-
end
|
|
912
909
|
local now = tick()
|
|
913
|
-
|
|
910
|
+
-- Normal identity refreshes stay conservatively throttled. Once a stale
|
|
911
|
+
-- predecessor causes 409, retry once per second so takeover follows the
|
|
912
|
+
-- server's short inactivity lease without another two seconds of jitter.
|
|
913
|
+
local readyInterval = if retryingDuplicateReady then 1 else 2
|
|
914
|
+
if now - lastReadyPostAt < readyInterval then
|
|
914
915
|
return nil
|
|
915
916
|
end
|
|
916
917
|
lastReadyPostAt = now
|
|
@@ -942,28 +943,39 @@ function sendReady(conn)
|
|
|
942
943
|
local readyRole = detectRole()
|
|
943
944
|
local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
|
|
944
945
|
if not readyOk then
|
|
946
|
+
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
945
947
|
readyFailureLogKeys[readyLogKey] = true
|
|
946
|
-
|
|
948
|
+
if shouldLog then
|
|
949
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
|
|
950
|
+
end
|
|
947
951
|
return nil
|
|
948
952
|
end
|
|
949
953
|
if not readyResult.Success then
|
|
950
954
|
local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
955
|
+
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
951
956
|
readyFailureLogKeys[readyLogKey] = true
|
|
952
|
-
--
|
|
957
|
+
-- A predecessor can remain registered briefly when Studio exits before
|
|
958
|
+
-- its asynchronous /disconnect completes. Keep polling and retrying
|
|
959
|
+
-- /ready: the server will take us over once the predecessor has stopped
|
|
960
|
+
-- polling, while a genuinely active duplicate continues to hold routing.
|
|
953
961
|
if readyResult.StatusCode == 409 then
|
|
954
|
-
|
|
955
|
-
conn.isActive = false
|
|
962
|
+
retryingDuplicateReady = true
|
|
956
963
|
local ui = UI.getElements()
|
|
957
|
-
ui.statusLabel.Text = "
|
|
958
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
964
|
+
ui.statusLabel.Text = "Waiting for previous instance"
|
|
965
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
959
966
|
ui.detailStatusLabel.Text = reason
|
|
960
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(
|
|
961
|
-
|
|
967
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
968
|
+
if shouldLog then
|
|
969
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
970
|
+
end
|
|
962
971
|
return nil
|
|
963
972
|
end
|
|
964
|
-
|
|
973
|
+
if shouldLog then
|
|
974
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
975
|
+
end
|
|
965
976
|
return nil
|
|
966
977
|
end
|
|
978
|
+
retryingDuplicateReady = false
|
|
967
979
|
local parseOk, readyData = pcall(function()
|
|
968
980
|
return HttpService:JSONDecode(readyResult.Body)
|
|
969
981
|
end)
|
|
@@ -1413,9 +1425,9 @@ local function computeBridgeStamp()
|
|
|
1413
1425
|
for i = 1, #combined do
|
|
1414
1426
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1415
1427
|
end
|
|
1416
|
-
-- "2.22.
|
|
1428
|
+
-- "2.22.1" is replaced with the package version at package time
|
|
1417
1429
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1418
|
-
return `{tostring(h)}-2.22.
|
|
1430
|
+
return `{tostring(h)}-2.22.1`
|
|
1419
1431
|
end
|
|
1420
1432
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1421
1433
|
local function setSource(scriptInst, source)
|
|
@@ -10449,24 +10461,30 @@ local function pushEntry(msg, t, ts)
|
|
|
10449
10461
|
totalBytes += bytes
|
|
10450
10462
|
end
|
|
10451
10463
|
local function seedRuntimeHistory()
|
|
10452
|
-
if not RunService:IsRunning() then
|
|
10453
|
-
return nil
|
|
10454
|
-
end
|
|
10455
10464
|
local ok, history = pcall(function()
|
|
10456
10465
|
return LogService:GetLogHistory()
|
|
10457
10466
|
end)
|
|
10458
10467
|
if not ok then
|
|
10459
10468
|
return nil
|
|
10460
10469
|
end
|
|
10470
|
+
local isEdit = not RunService:IsRunning()
|
|
10471
|
+
-- GetLogHistory timestamps and DateTime.now() share Unix time, while
|
|
10472
|
+
-- os.clock() is elapsed time for this Studio process. Their difference is
|
|
10473
|
+
-- therefore the process launch boundary. Edit-mode history is filtered to
|
|
10474
|
+
-- that boundary so startup errors from this launch are recovered without
|
|
10475
|
+
-- importing history left by an earlier Studio process.
|
|
10476
|
+
local processStartedAt = nowSec() - os.clock()
|
|
10461
10477
|
for _, entry in history do
|
|
10462
10478
|
local _message = entry.message
|
|
10463
10479
|
if not (type(_message) == "string") then
|
|
10464
10480
|
continue
|
|
10465
10481
|
end
|
|
10466
|
-
local _exp = entry.message
|
|
10467
|
-
local _exp_1 = entry.messageType
|
|
10468
10482
|
local _timestamp = entry.timestamp
|
|
10469
|
-
|
|
10483
|
+
local timestamp = if type(_timestamp) == "number" then entry.timestamp else nil
|
|
10484
|
+
if isEdit and (timestamp == nil or timestamp < processStartedAt - 1) then
|
|
10485
|
+
continue
|
|
10486
|
+
end
|
|
10487
|
+
pushEntry(entry.message, entry.messageType, timestamp)
|
|
10470
10488
|
end
|
|
10471
10489
|
end
|
|
10472
10490
|
local function install()
|
|
@@ -10477,9 +10495,9 @@ local function install()
|
|
|
10477
10495
|
return nil
|
|
10478
10496
|
end
|
|
10479
10497
|
installed = true
|
|
10480
|
-
--
|
|
10481
|
-
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
10482
|
-
--
|
|
10498
|
+
-- Every peer can emit startup logs before the plugin finishes loading.
|
|
10499
|
+
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
10500
|
+
-- edit history is bounded to the current Studio process above.
|
|
10483
10501
|
seedRuntimeHistory()
|
|
10484
10502
|
LogService.MessageOut:Connect(function(msg, t)
|
|
10485
10503
|
pushEntry(msg, t)
|
|
@@ -10745,7 +10763,7 @@ return {
|
|
|
10745
10763
|
<Properties>
|
|
10746
10764
|
<string name="Name">State</string>
|
|
10747
10765
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10748
|
-
local CURRENT_VERSION = "2.22.
|
|
10766
|
+
local CURRENT_VERSION = "2.22.1"
|
|
10749
10767
|
local PLUGIN_VARIANT = "main"
|
|
10750
10768
|
local BASE_PORT = 58741
|
|
10751
10769
|
local function createConnection(port)
|
|
@@ -53,11 +53,11 @@ function computeInstanceId(): string {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let assignedRole: string | undefined;
|
|
56
|
-
let duplicateInstanceRole = false;
|
|
57
56
|
let hasVersionMismatch = false;
|
|
58
57
|
let lastVersionMismatchWarningKey: string | undefined;
|
|
59
58
|
let lastReadyInstanceId: string | undefined;
|
|
60
59
|
const readyFailureLogKeys = new Set<string>();
|
|
60
|
+
let retryingDuplicateReady = false;
|
|
61
61
|
|
|
62
62
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
63
63
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
@@ -248,9 +248,12 @@ function ensureIdentityWatcher(conn: Connection): void {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
function sendReady(conn: Connection): void {
|
|
251
|
-
if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
|
|
252
251
|
const now = tick();
|
|
253
|
-
|
|
252
|
+
// Normal identity refreshes stay conservatively throttled. Once a stale
|
|
253
|
+
// predecessor causes 409, retry once per second so takeover follows the
|
|
254
|
+
// server's short inactivity lease without another two seconds of jitter.
|
|
255
|
+
const readyInterval = retryingDuplicateReady ? 1 : 2;
|
|
256
|
+
if (now - lastReadyPostAt < readyInterval) return;
|
|
254
257
|
lastReadyPostAt = now;
|
|
255
258
|
const instanceId = computeInstanceId();
|
|
256
259
|
task.spawn(() => {
|
|
@@ -278,28 +281,39 @@ function sendReady(conn: Connection): void {
|
|
|
278
281
|
const readyRole = detectRole();
|
|
279
282
|
const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
|
|
280
283
|
if (!readyOk) {
|
|
284
|
+
const shouldLog = !readyFailureLogKeys.has(readyLogKey);
|
|
281
285
|
readyFailureLogKeys.add(readyLogKey);
|
|
282
|
-
|
|
286
|
+
if (shouldLog) {
|
|
287
|
+
warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
|
|
288
|
+
}
|
|
283
289
|
return;
|
|
284
290
|
}
|
|
285
291
|
if (!readyResult.Success) {
|
|
286
292
|
const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
|
|
293
|
+
const shouldLog = !readyFailureLogKeys.has(readyLogKey);
|
|
287
294
|
readyFailureLogKeys.add(readyLogKey);
|
|
288
|
-
//
|
|
295
|
+
// A predecessor can remain registered briefly when Studio exits before
|
|
296
|
+
// its asynchronous /disconnect completes. Keep polling and retrying
|
|
297
|
+
// /ready: the server will take us over once the predecessor has stopped
|
|
298
|
+
// polling, while a genuinely active duplicate continues to hold routing.
|
|
289
299
|
if (readyResult.StatusCode === 409) {
|
|
290
|
-
|
|
291
|
-
conn.isActive = false;
|
|
300
|
+
retryingDuplicateReady = true;
|
|
292
301
|
const ui = UI.getElements();
|
|
293
|
-
ui.statusLabel.Text = "
|
|
294
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
302
|
+
ui.statusLabel.Text = "Waiting for previous instance";
|
|
303
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
295
304
|
ui.detailStatusLabel.Text = reason;
|
|
296
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(
|
|
297
|
-
|
|
305
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
306
|
+
if (shouldLog) {
|
|
307
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
308
|
+
}
|
|
298
309
|
return;
|
|
299
310
|
}
|
|
300
|
-
|
|
311
|
+
if (shouldLog) {
|
|
312
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
313
|
+
}
|
|
301
314
|
return;
|
|
302
315
|
}
|
|
316
|
+
retryingDuplicateReady = false;
|
|
303
317
|
const [parseOk, readyData] = pcall(
|
|
304
318
|
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
305
319
|
);
|
|
@@ -77,14 +77,21 @@ interface LogHistoryEntry {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function seedRuntimeHistory(): void {
|
|
80
|
-
if (!RunService.IsRunning()) return;
|
|
81
|
-
|
|
82
80
|
const [ok, history] = pcall(() => LogService.GetLogHistory() as LogHistoryEntry[]);
|
|
83
81
|
if (!ok) return;
|
|
82
|
+
const isEdit = !RunService.IsRunning();
|
|
83
|
+
// GetLogHistory timestamps and DateTime.now() share Unix time, while
|
|
84
|
+
// os.clock() is elapsed time for this Studio process. Their difference is
|
|
85
|
+
// therefore the process launch boundary. Edit-mode history is filtered to
|
|
86
|
+
// that boundary so startup errors from this launch are recovered without
|
|
87
|
+
// importing history left by an earlier Studio process.
|
|
88
|
+
const processStartedAt = nowSec() - os.clock();
|
|
84
89
|
|
|
85
90
|
for (const entry of history) {
|
|
86
91
|
if (!typeIs(entry.message, "string")) continue;
|
|
87
|
-
|
|
92
|
+
const timestamp = typeIs(entry.timestamp, "number") ? entry.timestamp : undefined;
|
|
93
|
+
if (isEdit && (timestamp === undefined || timestamp < processStartedAt - 1)) continue;
|
|
94
|
+
pushEntry(entry.message, entry.messageType, timestamp);
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
|
|
@@ -92,9 +99,9 @@ function install(): void {
|
|
|
92
99
|
if (installed) return;
|
|
93
100
|
if (!RunService.IsStudio()) return;
|
|
94
101
|
installed = true;
|
|
95
|
-
//
|
|
96
|
-
// Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
97
|
-
//
|
|
102
|
+
// Every peer can emit startup logs before the plugin finishes loading.
|
|
103
|
+
// Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
104
|
+
// edit history is bounded to the current Studio process above.
|
|
98
105
|
seedRuntimeHistory();
|
|
99
106
|
LogService.MessageOut.Connect((msg, t) => {
|
|
100
107
|
pushEntry(msg, t);
|