@adiholick/nvstudio-mcp 2.2.1 → 2.2.3

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.
@@ -407,9 +407,32 @@ end
407
407
  tryProp(prop)
408
408
  end
409
409
 
410
- -- Attributes & Tags
410
+ -- Attributes & Tags (sanitasi tipe non-JSON agar tidak throw error di JSONEncode)
411
411
  pcall(function()
412
- props.Attributes = targetInstance:GetAttributes()
412
+ local rawAttrs = targetInstance:GetAttributes()
413
+ local safeAttrs = {}
414
+ for k, val in pairs(rawAttrs) do
415
+ if typeof(val) == "Vector3" then
416
+ safeAttrs[k] = {math.round(val.X*100)/100, math.round(val.Y*100)/100, math.round(val.Z*100)/100}
417
+ elseif typeof(val) == "Color3" then
418
+ safeAttrs[k] = {math.round(val.R * 255), math.round(val.G * 255), math.round(val.B * 255)}
419
+ elseif typeof(val) == "CFrame" then
420
+ safeAttrs[k] = tostring(val)
421
+ elseif typeof(val) == "EnumItem" then
422
+ safeAttrs[k] = val.Name
423
+ elseif typeof(val) == "UDim2" then
424
+ safeAttrs[k] = {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
425
+ elseif typeof(val) == "Instance" then
426
+ safeAttrs[k] = val:GetFullName()
427
+ elseif typeof(val) == "DateTime" then
428
+ safeAttrs[k] = val:ToIsoDate()
429
+ elseif typeof(val) == "string" or typeof(val) == "number" or typeof(val) == "boolean" then
430
+ safeAttrs[k] = val
431
+ else
432
+ safeAttrs[k] = tostring(val)
433
+ end
434
+ end
435
+ props.Attributes = safeAttrs
413
436
  end)
414
437
  pcall(function()
415
438
  props.Tags = CollectionService:GetTags(targetInstance)
@@ -488,10 +511,10 @@ end
488
511
  <BinaryString name="Tags"></BinaryString>
489
512
  <token name="RunContext">0</token>
490
513
  <ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
491
- -- NVStudio MCP Plugin v2.1.8
492
- -- Arsitektur: Polling Stream / Local HTTP Bridge (Port 3055)
493
- -- Plugin auto-connect saat dimuat, polling /api/ping lalu /api/stream
494
- -- untuk mendapatkan events (status, request) dari server Node.js.
514
+ -- NVStudio MCP Plugin v2.2.2
515
+ -- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
516
+ -- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
517
+ -- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
495
518
  -- ══════════════════════════════════════════════════════════════════════
496
519
 
497
520
  local HttpService = game:GetService("HttpService")
@@ -502,6 +525,8 @@ local BASE_URL = "http://localhost:3055"
502
525
  local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
503
526
  local RESPONSE_URL = BASE_URL .. "/api/response"
504
527
  local PING_URL = BASE_URL .. "/api/ping"
528
+ local WS_URL = "ws://localhost:3055/ws"
529
+ local WS_URL_IP = "ws://127.0.0.1:3055/ws"
505
530
 
506
531
  -- ID unik per sesi Studio
507
532
  local STUDIO_ID = HttpService:GenerateGUID(false)
@@ -523,18 +548,55 @@ local ctx = {
523
548
  getInstanceFromPath = nil -- akan di-bind di bawah
524
549
  }
525
550
  local MAX_LOGS = 50
526
- local isRunning = false -- apakah loop aktif
551
+ local isRunning = false -- apakah loop/koneksi aktif
527
552
  local isManualDisconnect = false
528
553
  local isClickDebounce = false
529
554
  local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
530
555
  local serverReachable = false -- apakah server Node.js reachable
556
+ local activeWsWrapper = nil
557
+ local currentTransport = "none" -- "websocket" | "longpoll" | "none"
531
558
 
532
- -- Reconnect config
559
+ -- Reconnect config untuk Long-Polling
533
560
  local RECONNECT_DELAY_INITIAL = 1.5
534
561
  local RECONNECT_DELAY_MAX = 1.5
535
562
  local reconnectDelay = RECONNECT_DELAY_INITIAL
536
563
  local currentLoopId = nil
537
564
 
565
+ -- Task deduplication state (Bug G)
566
+ local runningTasks = {} -- taskId -> true
567
+ local completedTasks = {} -- taskId -> os.clock()
568
+
569
+ local function shouldProcessTask(taskId)
570
+ if not taskId or taskId == "" then return false end
571
+ if runningTasks[taskId] then
572
+ return false
573
+ end
574
+ local completedAt = completedTasks[taskId]
575
+ if completedAt and (os.clock() - completedAt < 60) then
576
+ return false
577
+ end
578
+ runningTasks[taskId] = true
579
+ return true
580
+ end
581
+
582
+ local function markTaskDone(taskId)
583
+ if not taskId then return end
584
+ runningTasks[taskId] = nil
585
+ completedTasks[taskId] = os.clock()
586
+
587
+ -- Bersihkan entri kedaluwarsa (>60 detik)
588
+ local now = os.clock()
589
+ for id, t in pairs(completedTasks) do
590
+ if now - t > 60 then
591
+ completedTasks[id] = nil
592
+ end
593
+ end
594
+ end
595
+
596
+ -- Forward declaration
597
+ local startConnectionManager = nil
598
+ local startLongPollingLoop = nil
599
+
538
600
  -- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
539
601
  local skills = {}
540
602
  for _, module in ipairs(script.Parent:GetChildren()) do
@@ -577,12 +639,17 @@ if uiModule then
577
639
  reconnectDelay = RECONNECT_DELAY_INITIAL
578
640
  ui:setStatus("waiting")
579
641
  ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
580
- task.spawn(startConnectionLoop)
642
+ task.spawn(startConnectionManager)
581
643
  else
582
644
  isManualDisconnect = true
583
645
  isRunning = false
584
646
  serverReachable = false
585
647
  mcpAgentActive = false
648
+ if activeWsWrapper then
649
+ activeWsWrapper:Close()
650
+ activeWsWrapper = nil
651
+ end
652
+ currentTransport = "none"
586
653
  ui:setStatus("disconnected")
587
654
  ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
588
655
  end
@@ -689,31 +756,325 @@ local function processTask(taskData)
689
756
  return result
690
757
  end
691
758
 
692
- -- ── Kirim Respons ke Server ───────────────────────────────────────────────────
693
- local function sendResponse(taskId, resultData)
759
+ -- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
760
+ local function sanitizeForJson(val, depth)
761
+ depth = depth or 0
762
+ if depth > 5 then return tostring(val) end
763
+ local t = typeof(val)
764
+ if t == "string" or t == "number" or t == "boolean" then
765
+ return val
766
+ elseif t == "Instance" then
767
+ return val:GetFullName()
768
+ elseif t == "Vector3" then
769
+ return { X = val.X, Y = val.Y, Z = val.Z }
770
+ elseif t == "CFrame" then
771
+ return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
772
+ elseif t == "Color3" then
773
+ return { R = val.R, G = val.G, B = val.B }
774
+ elseif t == "table" then
775
+ local sanitized = {}
776
+ for k, v in pairs(val) do
777
+ sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
778
+ end
779
+ return sanitized
780
+ else
781
+ return tostring(val)
782
+ end
783
+ end
784
+
785
+ -- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
786
+ local function sendResponse(taskId, resultData, attempt)
694
787
  task.spawn(function()
695
- local ok, err = pcall(function()
696
- local json = HttpService:JSONEncode(resultData)
697
- local success = pcall(function()
698
- HttpService:PostAsync(
699
- RESPONSE_URL .. "/" .. taskId,
700
- json,
701
- Enum.HttpContentType.ApplicationJson
702
- )
703
- end)
704
- if not success then
788
+ local payload = {
789
+ id = taskId,
790
+ taskId = taskId,
791
+ attempt = attempt,
792
+ status = resultData.status or "success",
793
+ result = sanitizeForJson(resultData.result),
794
+ error = resultData.error and tostring(resultData.error) or nil,
795
+ }
796
+
797
+ local encOk, json = pcall(function()
798
+ return HttpService:JSONEncode(payload)
799
+ end)
800
+
801
+ if not encOk then
802
+ payload.result = tostring(payload.result)
803
+ pcall(function() json = HttpService:JSONEncode(payload) end)
804
+ end
805
+
806
+ if not json then
807
+ json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
808
+ end
809
+
810
+ local postOk = pcall(function()
811
+ HttpService:PostAsync(
812
+ RESPONSE_URL .. "/" .. taskId,
813
+ json,
814
+ Enum.HttpContentType.ApplicationJson
815
+ )
816
+ end)
817
+
818
+ if not postOk then
819
+ -- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
820
+ task.wait(0.2)
821
+ local fallbackOk = pcall(function()
705
822
  HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
823
+ end)
824
+ if not fallbackOk and ui then
825
+ ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
706
826
  end
707
- end)
708
- if not ok and ui then
709
- ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
710
827
  end
711
828
  end)
712
829
  end
713
830
 
714
- -- ── Proses satu event dari SSE stream ────────────────────────────────────────
831
+ -- ── WebSocket API Abstraction ────────────────────────────────────────────────
832
+ local function detectWebSocketCapability()
833
+ -- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
834
+ local hasWebStream = false
835
+ pcall(function()
836
+ hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
837
+ and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
838
+ end)
839
+ if hasWebStream then
840
+ return "WebStreamClient"
841
+ end
842
+
843
+ -- 2. Check global WebSocket API jika tersedia
844
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
845
+ if not wsGlobal then
846
+ pcall(function() wsGlobal = WebSocket end)
847
+ end
848
+ if wsGlobal and typeof(wsGlobal.connect) == "function" then
849
+ return "WebSocketGlobal"
850
+ end
851
+
852
+ return nil
853
+ end
854
+
855
+ local function createWsClient(url)
856
+ local cap = detectWebSocketCapability()
857
+ if not cap then
858
+ return nil, "WebSocket API not available"
859
+ end
860
+
861
+ if cap == "WebStreamClient" then
862
+ local ok, client = pcall(function()
863
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
864
+ Url = url
865
+ })
866
+ end)
867
+ if not ok or not client then
868
+ return nil, tostring(client or "CreateWebStreamClient failed")
869
+ end
870
+
871
+ local wrapper = {
872
+ raw = client,
873
+ cap = "WebStreamClient",
874
+ closed = false,
875
+ connections = {},
876
+ }
877
+
878
+ function wrapper:Send(data)
879
+ if self.closed then return false, "Closed" end
880
+ return pcall(function()
881
+ self.raw:Send(data)
882
+ end)
883
+ end
884
+
885
+ function wrapper:Close()
886
+ if self.closed then return end
887
+ self.closed = true
888
+ pcall(function() self.raw:Close() end)
889
+ for _, conn in ipairs(self.connections) do
890
+ pcall(function() conn:Disconnect() end)
891
+ end
892
+ table.clear(self.connections)
893
+ end
894
+
895
+ function wrapper:BindEvents(onMessage, onClose)
896
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
897
+ local ok, conn = pcall(function()
898
+ return self.raw.MessageReceived:Connect(onMessage)
899
+ end)
900
+ if ok and conn then table.insert(self.connections, conn) end
901
+ end
902
+
903
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
904
+ local ok, conn = pcall(function()
905
+ return self.raw.Closed:Connect(onClose)
906
+ end)
907
+ if ok and conn then table.insert(self.connections, conn) end
908
+ end
909
+ end
910
+
911
+ return wrapper, nil
912
+
913
+ elseif cap == "WebSocketGlobal" then
914
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
915
+ local ok, client = pcall(function()
916
+ return wsGlobal.connect(url)
917
+ end)
918
+ if not ok or not client then
919
+ return nil, tostring(client or "WebSocket.connect failed")
920
+ end
921
+
922
+ local wrapper = {
923
+ raw = client,
924
+ cap = "WebSocketGlobal",
925
+ closed = false,
926
+ connections = {},
927
+ }
928
+
929
+ function wrapper:Send(data)
930
+ if self.closed then return false, "Closed" end
931
+ return pcall(function()
932
+ if typeof(self.raw.Send) == "function" then
933
+ self.raw:Send(data)
934
+ elseif typeof(self.raw.send) == "function" then
935
+ self.raw:send(data)
936
+ end
937
+ end)
938
+ end
939
+
940
+ function wrapper:Close()
941
+ if self.closed then return end
942
+ self.closed = true
943
+ pcall(function()
944
+ if typeof(self.raw.Close) == "function" then
945
+ self.raw:Close()
946
+ elseif typeof(self.raw.close) == "function" then
947
+ self.raw:close()
948
+ end
949
+ end)
950
+ for _, conn in ipairs(self.connections) do
951
+ pcall(function() conn:Disconnect() end)
952
+ end
953
+ table.clear(self.connections)
954
+ end
955
+
956
+ function wrapper:BindEvents(onMessage, onClose)
957
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
958
+ local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
959
+ if ok and conn then table.insert(self.connections, conn) end
960
+ elseif self.raw.OnMessage then
961
+ if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
962
+ local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
963
+ if ok and conn then table.insert(self.connections, conn) end
964
+ else
965
+ pcall(function() self.raw.OnMessage = onMessage end)
966
+ end
967
+ end
968
+
969
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
970
+ local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
971
+ if ok and conn then table.insert(self.connections, conn) end
972
+ elseif self.raw.OnClose then
973
+ if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
974
+ local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
975
+ if ok and conn then table.insert(self.connections, conn) end
976
+ else
977
+ pcall(function() self.raw.OnClose = onClose end)
978
+ end
979
+ end
980
+ end
981
+
982
+ return wrapper, nil
983
+ end
984
+
985
+ return nil, "Unsupported"
986
+ end
987
+
988
+ -- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
989
+ local function handleWsMessage(wrapper, rawMessage)
990
+ if type(rawMessage) ~= "string" or rawMessage == "" then return end
991
+ local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
992
+ if not ok or type(decoded) ~= "table" then return end
993
+
994
+ local kind = decoded.kind
995
+
996
+ -- Heartbeat ping -> balas pong
997
+ if kind == "ping" then
998
+ pcall(function()
999
+ wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
1000
+ end)
1001
+ return
1002
+ end
1003
+
1004
+ if kind == "pong" then
1005
+ return
1006
+ end
1007
+
1008
+ -- Update status koneksi MCP / Studio
1009
+ if kind == "status" then
1010
+ local newActive = decoded.mcpConnected == true
1011
+ local changed = (mcpAgentActive ~= newActive)
1012
+ mcpAgentActive = newActive
1013
+ if ui then
1014
+ if mcpAgentActive then
1015
+ ui:setStatus("connected")
1016
+ else
1017
+ ui:setStatus("server_connected")
1018
+ end
1019
+ if newActive and changed then
1020
+ ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
1021
+ elseif not newActive and changed then
1022
+ ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
1023
+ end
1024
+ end
1025
+ return
1026
+ end
1027
+
1028
+ -- Eksekusi perintah AI
1029
+ if kind == "request" then
1030
+ local taskId = decoded.taskId or decoded.requestId
1031
+ local attempt = decoded.attempt
1032
+ local command = decoded.command
1033
+ local target = decoded.target
1034
+ local data = decoded.data
1035
+
1036
+ if not taskId or not command then return end
1037
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
1038
+
1039
+ task.spawn(function()
1040
+ local resultData = processTask({
1041
+ id = taskId,
1042
+ command = command,
1043
+ target = target or "",
1044
+ data = data,
1045
+ })
1046
+ markTaskDone(taskId) -- Bug G: Tandai selesai
1047
+ if resultData then
1048
+ -- Push respons instan kembali via WebSocket (Zero-Delay)
1049
+ local responseEnvelope = {
1050
+ kind = "response",
1051
+ taskId = taskId,
1052
+ id = taskId,
1053
+ attempt = attempt,
1054
+ status = resultData.status,
1055
+ result = sanitizeForJson(resultData.result),
1056
+ error = resultData.error and tostring(resultData.error) or nil,
1057
+ }
1058
+ local encOk, encoded = pcall(function()
1059
+ return HttpService:JSONEncode(responseEnvelope)
1060
+ end)
1061
+ local sent = false
1062
+ if encOk then
1063
+ local sendOk, _ = wrapper:Send(encoded)
1064
+ sent = (sendOk == true)
1065
+ end
1066
+ if not sent then
1067
+ -- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
1068
+ sendResponse(taskId, resultData, attempt)
1069
+ end
1070
+ end
1071
+ end)
1072
+ return
1073
+ end
1074
+ end
1075
+
1076
+ -- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
715
1077
  local function handleSSELine(line)
716
- -- Format SSE: "data: {...json...}"
717
1078
  local payload = line
718
1079
  if line:sub(1, 6) == "event:" then return end
719
1080
  if line:sub(1, 5) == "data:" then
@@ -726,12 +1087,10 @@ local function handleSSELine(line)
726
1087
 
727
1088
  local kind = decoded.kind
728
1089
 
729
- -- heartbeat: server masih hidup
730
1090
  if kind == "heartbeat" then
731
1091
  return
732
1092
  end
733
1093
 
734
- -- status: update mcpConnected
735
1094
  if kind == "status" then
736
1095
  local newActive = decoded.mcpConnected == true
737
1096
  local changed = (mcpAgentActive ~= newActive)
@@ -752,14 +1111,15 @@ local function handleSSELine(line)
752
1111
  return
753
1112
  end
754
1113
 
755
- -- request: ada task dari AI agent
756
1114
  if kind == "request" then
757
- local taskId = decoded.requestId
1115
+ local taskId = decoded.requestId or decoded.taskId
1116
+ local attempt = decoded.attempt
758
1117
  local command = decoded.command
759
1118
  local target = decoded.target
760
1119
  local data = decoded.data
761
1120
 
762
1121
  if not taskId or not command then return end
1122
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
763
1123
 
764
1124
  task.spawn(function()
765
1125
  local resultData = processTask({
@@ -768,39 +1128,34 @@ local function handleSSELine(line)
768
1128
  target = target or "",
769
1129
  data = data,
770
1130
  })
1131
+ markTaskDone(taskId) -- Bug G: Tandai selesai
771
1132
  if resultData then
772
- sendResponse(taskId, resultData)
1133
+ sendResponse(taskId, resultData, attempt)
773
1134
  end
774
1135
  end)
775
1136
  return
776
1137
  end
777
1138
  end
778
1139
 
779
- -- ── Loop Utama: Ping → Stream → Parse SSE lines ───────────────────────────────
780
- -- Roblox HttpService:GetAsync() tidak mendukung streaming, jadi kita
781
- -- menggunakan strategi: GET /api/stream dengan waktu hold singkat (5 detik).
782
- -- Server mengirim semua events yang tertunda lalu tutup koneksi.
783
- -- Plugin reconnect langsung untuk sesi berikutnya.
784
- function startConnectionLoop()
785
- if isRunning then return end
786
- isRunning = true
787
- isManualDisconnect = false
1140
+ -- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
1141
+ function startLongPollingLoop()
1142
+ if not isRunning or isManualDisconnect then return end
1143
+ currentTransport = "longpoll"
788
1144
  reconnectDelay = RECONNECT_DELAY_INITIAL
789
1145
 
790
1146
  local loopId = HttpService:GenerateGUID(false)
791
1147
  currentLoopId = loopId
792
1148
 
793
1149
  if ui then
794
- ui:addLog("[System] NVStudio MCP v2.1.8 — Menghubungkan ke server...", Color3.fromRGB(200, 200, 200))
1150
+ ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
795
1151
  end
796
1152
 
797
1153
  local consecutiveErrors = 0
798
1154
 
799
- while isRunning and not isManualDisconnect and currentLoopId == loopId do
800
- -- Stream / polling snapshot dari server
1155
+ while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
801
1156
  local streamOk, streamRaw = pcall(function()
802
1157
  return HttpService:GetAsync(
803
- STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
1158
+ STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
804
1159
  false
805
1160
  )
806
1161
  end)
@@ -813,28 +1168,26 @@ function startConnectionLoop()
813
1168
  serverReachable = true
814
1169
  if ui then
815
1170
  ui:setStatus(mcpAgentActive and "connected" or "server_connected")
816
- ui:addLog("[System] ✅ Server MCP terhubung & siap.", Color3.fromRGB(100, 255, 150))
1171
+ ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
817
1172
  end
818
1173
  end
819
1174
 
820
- -- Parse baris-baris SSE
821
1175
  for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
822
1176
  line = line:gsub("\r", "")
823
1177
  if line ~= "" then
824
1178
  handleSSELine(line)
825
1179
  end
826
1180
  end
827
- task.wait(0.8)
1181
+ task.wait()
828
1182
  else
829
1183
  consecutiveErrors = consecutiveErrors + 1
830
- -- Toleransi 2x gagal sebelum menandai server unreachable
831
1184
  if consecutiveErrors >= 2 then
832
1185
  if serverReachable then
833
1186
  serverReachable = false
834
1187
  mcpAgentActive = false
835
1188
  if ui then
836
1189
  ui:setStatus("waiting")
837
- ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Mencoba menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
1190
+ ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
838
1191
  end
839
1192
  end
840
1193
  end
@@ -844,24 +1197,125 @@ function startConnectionLoop()
844
1197
  end
845
1198
  end
846
1199
 
847
- isRunning = false
848
- serverReachable = false
849
- mcpAgentActive = false
850
- if ui then ui:setStatus("disconnected") end
1200
+ if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
1201
+ isRunning = false
1202
+ serverReachable = false
1203
+ mcpAgentActive = false
1204
+ if ui then ui:setStatus("disconnected") end
1205
+ end
1206
+ end
1207
+
1208
+ -- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
1209
+ function startConnectionManager()
1210
+ if isRunning then return end
1211
+ isRunning = true
1212
+ isManualDisconnect = false
1213
+
1214
+ local cap = detectWebSocketCapability()
1215
+ if cap then
1216
+ task.spawn(function()
1217
+ local wsFailures = 0
1218
+ local backoff = 0.5
1219
+
1220
+ while isRunning and not isManualDisconnect and wsFailures < 3 do
1221
+ currentTransport = "websocket"
1222
+ if ui then
1223
+ ui:setStatus("waiting")
1224
+ ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
1225
+ end
1226
+
1227
+ local wrapper, err = createWsClient(WS_URL)
1228
+ if not wrapper then
1229
+ -- Coba fallback ke IP 127.0.0.1
1230
+ wrapper, err = createWsClient(WS_URL_IP)
1231
+ end
1232
+
1233
+ if wrapper then
1234
+ activeWsWrapper = wrapper
1235
+ serverReachable = true
1236
+ if ui then
1237
+ ui:setStatus(mcpAgentActive and "connected" or "server_connected")
1238
+ ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
1239
+ end
1240
+
1241
+ -- Kirim pesan registrasi awal
1242
+ local regOk = wrapper:Send(HttpService:JSONEncode({
1243
+ kind = "register",
1244
+ studioId = STUDIO_ID,
1245
+ placeId = game.PlaceId,
1246
+ pluginsVersion = 2,
1247
+ }))
1248
+
1249
+ if regOk then
1250
+ wsFailures = 0
1251
+ backoff = 0.5
1252
+
1253
+ local closedSignal = Instance.new("BindableEvent")
1254
+ wrapper:BindEvents(
1255
+ function(msg)
1256
+ handleWsMessage(wrapper, msg)
1257
+ end,
1258
+ function()
1259
+ closedSignal:Fire()
1260
+ end
1261
+ )
1262
+
1263
+ closedSignal.Event:Wait()
1264
+ closedSignal:Destroy()
1265
+ end
1266
+
1267
+ if activeWsWrapper == wrapper then
1268
+ activeWsWrapper = nil
1269
+ end
1270
+ wrapper:Close()
1271
+ end
1272
+
1273
+ if isManualDisconnect or not isRunning then break end
1274
+
1275
+ wsFailures = wsFailures + 1
1276
+ if wsFailures < 3 then
1277
+ if ui then
1278
+ ui:setStatus("waiting")
1279
+ ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
1280
+ end
1281
+ task.wait(backoff)
1282
+ backoff = math.min(backoff * 2, 5.0)
1283
+ end
1284
+ end
1285
+
1286
+ -- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
1287
+ if isRunning and not isManualDisconnect then
1288
+ if ui then
1289
+ ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
1290
+ end
1291
+ startLongPollingLoop()
1292
+ end
1293
+ end)
1294
+ else
1295
+ -- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
1296
+ if ui then
1297
+ ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
1298
+ end
1299
+ startLongPollingLoop()
1300
+ end
851
1301
  end
852
1302
 
853
1303
  -- ── Plugin Unloading ──────────────────────────────────────────────────────────
854
1304
  plugin.Unloading:Connect(function()
855
1305
  isManualDisconnect = true
856
1306
  isRunning = false
1307
+ if activeWsWrapper then
1308
+ activeWsWrapper:Close()
1309
+ activeWsWrapper = nil
1310
+ end
857
1311
  end)
858
1312
 
859
1313
  -- ══════════════════════════════════════════════════════════════════════
860
- -- STARTUP: Langsung mulai connection loop
1314
+ -- STARTUP: Langsung mulai connection manager
861
1315
  -- ══════════════════════════════════════════════════════════════════════
862
1316
  task.spawn(function()
863
1317
  task.wait(0.5) -- Tunggu UI siap
864
- startConnectionLoop()
1318
+ startConnectionManager()
865
1319
  end)
866
1320
  ]]></ProtectedString>
867
1321
  </Properties>
@@ -1088,7 +1542,8 @@ end
1088
1542
  if targetInstance and targetInstance ~= game then
1089
1543
  local descendants = {}
1090
1544
  pcall(function() descendants = targetInstance:GetDescendants() end)
1091
- for _, desc in ipairs(descendants) do
1545
+ for i, desc in ipairs(descendants) do
1546
+ if i % 300 == 0 then task.wait() end
1092
1547
  if matchCount >= MAX_MATCHES then break end
1093
1548
  if desc:IsA("LuaSourceContainer") then
1094
1549
  if searchInScript(desc) then
@@ -1100,7 +1555,8 @@ end
1100
1555
  for _, service in ipairs(searchableServices) do
1101
1556
  local descendants = {}
1102
1557
  pcall(function() descendants = service:GetDescendants() end)
1103
- for _, desc in ipairs(descendants) do
1558
+ for i, desc in ipairs(descendants) do
1559
+ if i % 300 == 0 then task.wait() end
1104
1560
  if matchCount >= MAX_MATCHES then break end
1105
1561
  if desc:IsA("LuaSourceContainer") then
1106
1562
  if searchInScript(desc) then