@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
- return {
171
- ok: false,
172
- error: {
173
- code: "duplicate_instance_role",
174
- message: `Another plugin is already registered as (${instanceId}, ${assignedRole}).`,
175
- existing: toPublic(existing)
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
- const markdown = await fetchRobloxDoc(category, name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.22.0",
3
+ "version": "2.22.1",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -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
- if now - lastReadyPostAt < 2 then
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
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
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
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
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
- duplicateInstanceRole = true
955
- conn.isActive = false
962
+ retryingDuplicateReady = true
956
963
  local ui = UI.getElements()
957
- ui.statusLabel.Text = "Duplicate instance"
958
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
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(239, 68, 68)
961
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
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
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
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.0" is replaced with the package version at package time
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.0`
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
- pushEntry(_exp, _exp_1, if type(_timestamp) == "number" then entry.timestamp else nil)
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
- -- Play peers can emit startup logs before the plugin finishes loading.
10481
- -- Seed from per-DataModel LogHistory so get_runtime_logs can still see
10482
- -- those early messages; skip edit mode to avoid stale prior-session logs.
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.0"
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
- if now - lastReadyPostAt < 2 then
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
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
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
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
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
- duplicateInstanceRole = true
955
- conn.isActive = false
962
+ retryingDuplicateReady = true
956
963
  local ui = UI.getElements()
957
- ui.statusLabel.Text = "Duplicate instance"
958
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
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(239, 68, 68)
961
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
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
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
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.0" is replaced with the package version at package time
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.0`
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
- pushEntry(_exp, _exp_1, if type(_timestamp) == "number" then entry.timestamp else nil)
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
- -- Play peers can emit startup logs before the plugin finishes loading.
10481
- -- Seed from per-DataModel LogHistory so get_runtime_logs can still see
10482
- -- those early messages; skip edit mode to avoid stale prior-session logs.
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.0"
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
- if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
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
- warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
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
- // 409 = duplicate_instance_role. Surface in UI and stop polling.
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
- duplicateInstanceRole = true;
291
- conn.isActive = false;
300
+ retryingDuplicateReady = true;
292
301
  const ui = UI.getElements();
293
- ui.statusLabel.Text = "Duplicate instance";
294
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
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(239, 68, 68);
297
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
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
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
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
- pushEntry(entry.message, entry.messageType, typeIs(entry.timestamp, "number") ? entry.timestamp : undefined);
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
- // Play peers can emit startup logs before the plugin finishes loading.
96
- // Seed from per-DataModel LogHistory so get_runtime_logs can still see
97
- // those early messages; skip edit mode to avoid stale prior-session logs.
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);