@adiholick/nvstudio-mcp 2.0.4 → 2.1.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.
@@ -1,333 +1,381 @@
1
- local HttpService = game:GetService("HttpService")
2
- local LogService = game:GetService("LogService")
1
+ -- ══════════════════════════════════════════════════════════════════════
2
+ -- NVStudio MCP Plugin v2.1.0
3
+ -- Arsitektur: SSE via Long-Poll (Roblox tidak punya native SSE client)
4
+ -- Plugin auto-connect saat dimuat, polling /api/ping lalu /api/stream
5
+ -- untuk mendapatkan events (status, request) dari server Node.js.
6
+ -- ══════════════════════════════════════════════════════════════════════
3
7
 
4
- local PING_URL = "http://localhost:3055/api/ping"
5
- local TASK_URL = "http://localhost:3055/api/tasks"
6
- local RESPONSE_URL = "http://localhost:3055/api/response"
8
+ local HttpService = game:GetService("HttpService")
9
+ local LogService = game:GetService("LogService")
7
10
 
8
- -- Generate Unique Studio ID (menghindari bentrok jika 2 Studio terbuka)
9
- local STUDIO_ID = HttpService:GenerateGUID(false)
11
+ -- URL Endpoints
12
+ local BASE_URL = "http://localhost:3055"
13
+ local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
14
+ local RESPONSE_URL = BASE_URL .. "/api/response"
15
+ local PING_URL = BASE_URL .. "/api/ping"
10
16
 
11
- -- Import UI Manager (Procedural Dashboard)
17
+ -- ID unik per sesi Studio
18
+ local STUDIO_ID = HttpService:GenerateGUID(false)
19
+
20
+ -- ── UI Module ──────────────────────────────────────────────────────────────────
12
21
  local uiModule = script.Parent:FindFirstChild("ui_manager")
13
22
  local ui = nil
14
23
 
15
- -- Membangun Tombol Toolbar (Toggle)
16
- local toolbar = plugin:CreateToolbar("NVStudio MCP")
24
+ -- ── Toolbar ───────────────────────────────────────────────────────────────────
25
+ local toolbar = plugin:CreateToolbar("NVStudio MCP")
17
26
  local toggleBtn = toolbar:CreateButton("Dashboard", "Buka Dashboard NVStudio MCP", "rbxassetid://82696893597722", "NVStudio")
18
27
 
19
- -- Global State & Context AI
28
+ -- ── State ─────────────────────────────────────────────────────────────────────
20
29
  local ctx = {
21
- scriptHistory = {},
22
- recentLogs = {},
23
- HttpService = HttpService,
24
- studioId = STUDIO_ID
30
+ scriptHistory = {},
31
+ recentLogs = {},
32
+ HttpService = HttpService,
33
+ studioId = STUDIO_ID,
25
34
  }
26
- local MAX_LOGS = 50
27
- local isConnected = false
28
- local savedAutoConnect = plugin:GetSetting("AutoConnect")
29
- local autoConnect = (savedAutoConnect == nil) and true or savedAutoConnect
30
- local isPolling = false
31
- local isManualDisconnect = false -- Mencegah auto-reconnect saat user sengaja disconnect
32
- local isClickDebounce = false -- Mencegah rapid double-click
33
-
34
- -- Deklarasi fungsi polling di awal agar bisa dipanggil oleh UI
35
- local startPolling
36
- local stopPolling
37
-
38
- -- Inisialisasi UI jika modul ditemukan
35
+ local MAX_LOGS = 50
36
+ local isRunning = false -- apakah loop aktif
37
+ local isManualDisconnect = false
38
+ local isClickDebounce = false
39
+ local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
40
+ local serverReachable = false -- apakah server Node.js reachable
41
+
42
+ -- Reconnect config
43
+ local RECONNECT_DELAY_INITIAL = 1
44
+ local RECONNECT_DELAY_MAX = 15
45
+ local reconnectDelay = RECONNECT_DELAY_INITIAL
46
+
47
+ -- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
48
+ local skills = {}
49
+ for _, module in ipairs(script.Parent:GetChildren()) do
50
+ if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
51
+ local ok, result = pcall(require, module)
52
+ if ok then
53
+ skills[module.Name] = result
54
+ else
55
+ warn("[NVStudio] Gagal load module '" .. module.Name .. "': " .. tostring(result))
56
+ end
57
+ end
58
+ end
59
+
60
+ -- ── Inisialisasi UI ──────────────────────────────────────────────────────────
39
61
  if uiModule then
40
- local UIManager = require(uiModule)
41
- ui = UIManager.init(plugin, STUDIO_ID)
42
-
43
- -- Sync Initial State ke UI
44
- ui:setAutoConnectState(autoConnect)
45
- if autoConnect then
46
- ui:setStatus("waiting")
47
- else
48
- ui:setStatus("disconnected")
49
- end
50
-
51
- -- Sync state tombol toolbar dengan status widget
52
- if ui.widget then
53
- toggleBtn:SetActive(ui.widget.Enabled)
54
- ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
55
- toggleBtn:SetActive(ui.widget.Enabled)
56
- end)
57
- end
58
-
59
- -- Event Toggle GUI Panel
60
- toggleBtn.Click:Connect(function()
61
- ui:toggleVisibility()
62
- end)
63
-
64
- -- Event Tombol Connect (dengan debounce untuk mencegah double-click)
65
- ui.connectBtn.MouseButton1Click:Connect(function()
66
- if isClickDebounce then return end
67
- isClickDebounce = true
68
-
69
- if not isConnected then
70
- -- CONNECT (manual) — reset manual disconnect flag agar auto-detect bisa bekerja
71
- isManualDisconnect = false
72
- ui:setStatus("waiting")
73
- ui:addLog("[System] Menghubungkan ke Server MCP...", Color3.fromRGB(255, 220, 100))
74
-
75
- task.spawn(function()
76
- local cacheBuster = "?t=" .. tostring(os.clock())
77
- local success, _ = pcall(function()
78
- return HttpService:GetAsync(PING_URL .. cacheBuster)
79
- end)
80
-
81
- isConnected = true
82
- if success then
83
- if ui then
84
- ui:setStatus("connected")
85
- ui:addLog("[System] Berhasil terhubung ke Server MCP!", Color3.fromRGB(100, 255, 100))
86
- end
87
- else
88
- if ui then
89
- ui:setStatus("waiting")
90
- ui:addLog("[System] Server MCP belum aktif di port 3055. Menunggu koneksi...", Color3.fromRGB(255, 200, 100))
91
- end
92
- end
93
- startPolling()
94
- end)
95
- else
96
- -- DISCONNECT — hentikan polling dan tandai manual disconnect
97
- stopPolling()
98
- ui:setStatus("disconnected")
99
- ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
100
- end
101
-
102
- task.delay(0.3, function()
103
- isClickDebounce = false
104
- end)
105
- end)
106
-
107
- -- Event Custom Checkbox (Auto-Connect)
108
- ui.autoConnectBtn.MouseButton1Click:Connect(function()
109
- autoConnect = not autoConnect
110
- plugin:SetSetting("AutoConnect", autoConnect)
111
- ui:setAutoConnectState(autoConnect)
112
- ui:addLog("[System] Preferensi Auto-Connect disimpan: " .. tostring(autoConnect), Color3.fromRGB(200, 200, 255))
113
- end)
62
+ local UIManager = require(uiModule)
63
+ ui = UIManager.init(plugin, STUDIO_ID)
64
+
65
+ ui:setAutoConnectState(true)
66
+ ui:setStatus("waiting")
67
+
68
+ if ui.widget then
69
+ toggleBtn:SetActive(ui.widget.Enabled)
70
+ ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
71
+ toggleBtn:SetActive(ui.widget.Enabled)
72
+ end)
73
+ end
74
+
75
+ toggleBtn.Click:Connect(function()
76
+ ui:toggleVisibility()
77
+ end)
78
+
79
+ -- Tombol Connect / Disconnect
80
+ ui.connectBtn.MouseButton1Click:Connect(function()
81
+ if isClickDebounce then return end
82
+ isClickDebounce = true
83
+
84
+ if not isRunning then
85
+ isManualDisconnect = false
86
+ reconnectDelay = RECONNECT_DELAY_INITIAL
87
+ ui:setStatus("waiting")
88
+ ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
89
+ task.spawn(startConnectionLoop)
90
+ else
91
+ isManualDisconnect = true
92
+ isRunning = false
93
+ serverReachable = false
94
+ mcpAgentActive = false
95
+ ui:setStatus("disconnected")
96
+ ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
97
+ end
98
+
99
+ task.delay(0.3, function() isClickDebounce = false end)
100
+ end)
114
101
  else
115
- -- Fallback aman jika pengguna lupa meng-copy ui_manager.lua
116
- toggleBtn.Click:Connect(function()
117
- print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan di dalam Folder. GUI Gagal dimuat!")
118
- end)
102
+ toggleBtn.Click:Connect(function()
103
+ print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
104
+ end)
119
105
  end
120
106
 
121
- -- Handler Output Studio (Untuk AI dan GUI)
107
+ -- ── Log Capture ──────────────────────────────────────────────────────────────
122
108
  local function addSystemLog(msg, msgType)
123
- local color = Color3.fromRGB(200, 200, 200)
124
- local prefix = "[INFO] "
125
- if msgType == Enum.MessageType.MessageError then
126
- prefix = "[ERROR] "
127
- color = Color3.fromRGB(255, 100, 100)
128
- elseif msgType == Enum.MessageType.MessageWarning then
129
- prefix = "[WARN] "
130
- color = Color3.fromRGB(255, 200, 100)
131
- end
132
-
133
- local fullMsg = prefix .. msg
134
-
135
- -- 1. Simpan di memori untuk disedot AI
136
- table.insert(ctx.recentLogs, fullMsg)
137
- if #ctx.recentLogs > MAX_LOGS then
138
- table.remove(ctx.recentLogs, 1)
139
- end
140
-
141
- -- 2. Cetak ke Terminal GUI Plugin
142
- if ui then
143
- ui:addLog(fullMsg, color)
144
- end
109
+ local color = Color3.fromRGB(200, 200, 200)
110
+ local prefix = "[INFO] "
111
+ if msgType == Enum.MessageType.MessageError then
112
+ prefix = "[ERROR] "; color = Color3.fromRGB(255, 100, 100)
113
+ elseif msgType == Enum.MessageType.MessageWarning then
114
+ prefix = "[WARN] "; color = Color3.fromRGB(255, 200, 100)
115
+ end
116
+ local fullMsg = prefix .. msg
117
+ table.insert(ctx.recentLogs, fullMsg)
118
+ if #ctx.recentLogs > MAX_LOGS then table.remove(ctx.recentLogs, 1) end
119
+ if ui then ui:addLog(fullMsg, color) end
145
120
  end
146
-
147
- -- Ikat listener output
148
121
  LogService.MessageOut:Connect(addSystemLog)
149
122
 
150
- -- Memuat semua module skills secara dinamis
151
- local skills = {}
152
- for _, module in ipairs(script.Parent:GetChildren()) do
153
- if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
154
- skills[module.Name] = require(module)
155
- end
156
- end
157
-
158
- -- Fungsi Helper Pathing
123
+ -- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
159
124
  local function getInstanceFromPath(pathString)
160
- local parts = string.split(pathString, ".")
161
- local current = game
162
-
163
- if parts[1] == "game" then table.remove(parts, 1) end
164
-
165
- -- Handle common Services directly to ensure reliability
166
- pcall(function()
167
- local svc = game:GetService(parts[1])
168
- if svc then
169
- current = svc
170
- table.remove(parts, 1)
171
- end
172
- end)
173
-
174
- for _, part in ipairs(parts) do
175
- local found = current:FindFirstChild(part)
176
- if not found then return nil end
177
- current = found
178
- end
179
- return current
125
+ local parts = string.split(pathString, ".")
126
+ local current = game
127
+ if parts[1] == "game" then table.remove(parts, 1) end
128
+ pcall(function()
129
+ local svc = game:GetService(parts[1])
130
+ if svc then current = svc; table.remove(parts, 1) end
131
+ end)
132
+ for _, part in ipairs(parts) do
133
+ local found = current:FindFirstChild(part)
134
+ if not found then return nil end
135
+ current = found
136
+ end
137
+ return current
180
138
  end
181
139
 
182
- -- Memproses Task Eksekusi dari AI
140
+ -- ── Proses Task dari Server ───────────────────────────────────────────────────
183
141
  local function processTask(taskData)
184
- if not taskData or not taskData.id or not taskData.command then return nil end
185
-
186
- if ui then
187
- ui:addLog("[AI Command] Menerima instruksi: " .. taskData.command, Color3.fromRGB(150, 200, 255))
188
- end
189
-
190
- local targetInstance = getInstanceFromPath(taskData.target)
191
- if not targetInstance then
192
- return {
193
- id = taskData.id,
194
- status = "error",
195
- error = "Target path tidak ditemukan: " .. taskData.target
196
- }
197
- end
198
-
199
- local skillFunc = skills[taskData.command]
200
- if not skillFunc then
201
- return {
202
- id = taskData.id,
203
- status = "error",
204
- error = "Command tidak dikenali oleh Studio: " .. taskData.command
205
- }
206
- end
207
-
208
- -- Eksekusi skill AI
209
- local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
210
- result.id = taskData.id
211
-
212
- -- Cetak hasil eksekusi ke Terminal GUI
213
- if ui then
214
- local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
215
- ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
216
- end
217
-
218
- return result
142
+ if not taskData or not taskData.id or not taskData.command then return nil end
143
+
144
+ if ui then
145
+ ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
146
+ end
147
+
148
+ local targetInstance = getInstanceFromPath(taskData.target)
149
+ if not targetInstance then
150
+ return { id = taskData.id, status = "error", error = "Target path tidak ditemukan: " .. tostring(taskData.target) }
151
+ end
152
+
153
+ local skillFunc = skills[taskData.command]
154
+ if not skillFunc then
155
+ return { id = taskData.id, status = "error", error = "Command tidak dikenali: " .. taskData.command }
156
+ end
157
+
158
+ local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
159
+ result.id = taskData.id
160
+
161
+ if ui then
162
+ local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
163
+ ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
164
+ end
165
+
166
+ return result
219
167
  end
220
168
 
221
- -- Menghentikan polling dengan bersih (dipanggil oleh tombol Disconnect)
222
- function stopPolling()
223
- isConnected = false
224
- isManualDisconnect = true
225
- -- isPolling akan menjadi false secara otomatis saat loop keluar
169
+ -- ── Kirim Respons ke Server ───────────────────────────────────────────────────
170
+ local function sendResponse(taskId, resultData)
171
+ task.spawn(function()
172
+ local ok, err = pcall(function()
173
+ local json = HttpService:JSONEncode(resultData)
174
+ local success = pcall(function()
175
+ HttpService:PostAsync(
176
+ RESPONSE_URL .. "/" .. taskId,
177
+ json,
178
+ Enum.HttpContentType.ApplicationJson
179
+ )
180
+ end)
181
+ if not success then
182
+ HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
183
+ end
184
+ end)
185
+ if not ok and ui then
186
+ ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
187
+ end
188
+ end)
226
189
  end
227
190
 
228
- -- Background Polling Loop (Dapat dihentikan oleh user)
229
- function startPolling()
230
- if isPolling then return end
231
- isPolling = true
232
- if ui and isConnected then ui:setStatus("connected") end
233
-
234
- task.spawn(function()
235
- while isConnected do
236
- local success, response = pcall(function()
237
- -- Tambahkan cache-buster agar Studio tidak me-cache response HTTP
238
- local cacheBuster = "?t=" .. tostring(os.clock())
239
- return HttpService:GetAsync(TASK_URL .. cacheBuster)
240
- end)
241
-
242
- -- PENTING: Cek ulang isConnected SETELAH HTTP request selesai.
243
- if not isConnected then
244
- break
245
- end
246
-
247
- if success and response then
248
- -- JIKA PING/POLL SUKSES, Node.js menyala (IDE / Agen Sedang Terbuka!)
249
- if ui then ui:setStatus("connected") end
250
-
251
- local decodeSuccess, taskData = pcall(function()
252
- return HttpService:JSONDecode(response)
253
- end)
254
-
255
- if decodeSuccess and taskData and taskData.id then
256
- local resultData = processTask(taskData)
257
- if resultData then
258
- pcall(function()
259
- local jsonResult = HttpService:JSONEncode(resultData)
260
- HttpService:PostAsync(RESPONSE_URL, jsonResult, Enum.HttpContentType.ApplicationJson)
261
- end)
262
- end
263
- end
264
-
265
- -- Fast-polling (long-polling yield di server Node.js)
266
- -- 50ms untuk safety mencegah Studio Engine freeze jika Node.js membalas instant
267
- task.wait(0.05)
268
- else
269
- -- Fetch gagal (Node.js server mati karena IDE/Antigravity/Cursor ditutup)
270
- if ui then ui:setStatus("waiting") end
271
- task.wait(1.5)
272
- end
273
-
274
- -- Cek lagi sebelum sleep (agar disconnect lebih responsif)
275
- if not isConnected then
276
- break
277
- end
278
- end
279
- isPolling = false
280
- end)
191
+ -- ── Update UI berdasarkan state ───────────────────────────────────────────────
192
+ local function updateUIStatus()
193
+ if not ui then return end
194
+ if not serverReachable then
195
+ ui:setStatus("waiting")
196
+ elseif mcpAgentActive then
197
+ ui:setStatus("connected")
198
+ else
199
+ -- Server reachable tapi tidak ada AI agent → "server connected, waiting for agent"
200
+ -- Gunakan status "waiting" dengan pesan yang berbeda
201
+ ui:setStatus("waiting")
202
+ end
281
203
  end
282
204
 
205
+ -- ── Proses satu event dari SSE stream ────────────────────────────────────────
206
+ local function handleSSELine(line)
207
+ -- Format SSE: "data: {...json...}"
208
+ local payload = line
209
+ if line:sub(1, 5) == "data:" then
210
+ payload = line:sub(6):gsub("^%s+", ""):gsub("%s+$", "")
211
+ end
212
+ if payload == "" then return end
213
+
214
+ local ok, decoded = pcall(function() return HttpService:JSONDecode(payload) end)
215
+ if not ok or type(decoded) ~= "table" then return end
216
+
217
+ local kind = decoded.kind
218
+
219
+ -- heartbeat: server masih hidup
220
+ if kind == "heartbeat" then
221
+ return
222
+ end
223
+
224
+ -- status: update mcpConnected
225
+ if kind == "status" then
226
+ local newActive = decoded.mcpConnected == true
227
+ if newActive ~= mcpAgentActive then
228
+ mcpAgentActive = newActive
229
+ if ui then
230
+ if mcpAgentActive then
231
+ ui:setStatus("connected")
232
+ ui:addLog("[System] ✅ IDE Agent (Antigravity) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
233
+ else
234
+ ui:setStatus("waiting")
235
+ ui:addLog("[System] IDE Agent terputus dari MCP. Menunggu...", Color3.fromRGB(255, 200, 100))
236
+ end
237
+ end
238
+ end
239
+ return
240
+ end
241
+
242
+ -- request: ada task dari AI agent
243
+ if kind == "request" then
244
+ local taskId = decoded.requestId
245
+ local command = decoded.command
246
+ local target = decoded.target
247
+ local data = decoded.data
248
+
249
+ if not taskId or not command then return end
250
+
251
+ task.spawn(function()
252
+ local resultData = processTask({
253
+ id = taskId,
254
+ command = command,
255
+ target = target or "",
256
+ data = data,
257
+ })
258
+ if resultData then
259
+ sendResponse(taskId, resultData)
260
+ end
261
+ end)
262
+ return
263
+ end
264
+ end
265
+
266
+ -- ── Loop Utama: Ping → Stream → Parse SSE lines ───────────────────────────────
267
+ -- Roblox HttpService:GetAsync() tidak mendukung streaming, jadi kita
268
+ -- menggunakan strategi: GET /api/stream dengan waktu hold singkat (5 detik).
269
+ -- Server mengirim semua events yang tertunda lalu tutup koneksi.
270
+ -- Plugin reconnect langsung untuk sesi berikutnya.
271
+ function startConnectionLoop()
272
+ if isRunning then return end
273
+ isRunning = true
274
+ reconnectDelay = RECONNECT_DELAY_INITIAL
275
+
276
+ if ui then
277
+ ui:addLog("[System] NVStudio MCP v2.1.0 — Menghubungkan ke server...", Color3.fromRGB(200, 200, 200))
278
+ end
279
+
280
+ while isRunning and not isManualDisconnect do
281
+ -- Langkah 1: Ping untuk cek apakah server hidup
282
+ local pingOk, pingRaw = pcall(function()
283
+ return HttpService:GetAsync(
284
+ PING_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&t=" .. tostring(os.clock()),
285
+ false
286
+ )
287
+ end)
288
+
289
+ if not pingOk then
290
+ -- Server tidak reachable
291
+ if serverReachable then
292
+ serverReachable = false
293
+ mcpAgentActive = false
294
+ if ui then
295
+ ui:setStatus("waiting")
296
+ ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Retry dalam " .. reconnectDelay .. "s...", Color3.fromRGB(255, 150, 50))
297
+ end
298
+ end
299
+ task.wait(reconnectDelay)
300
+ reconnectDelay = math.min(reconnectDelay * 2, RECONNECT_DELAY_MAX)
301
+ else
302
+ -- Server reachable → parse ping response
303
+ reconnectDelay = RECONNECT_DELAY_INITIAL
304
+
305
+ local pingData = nil
306
+ pcall(function()
307
+ pingData = HttpService:JSONDecode(pingRaw)
308
+ end)
309
+
310
+ local wasReachable = serverReachable
311
+ serverReachable = true
312
+
313
+ if not wasReachable and ui then
314
+ ui:addLog("[System] ✅ Server MCP terhubung. Membuka event stream...", Color3.fromRGB(100, 255, 150))
315
+ end
316
+
317
+ -- Update mcpConnected dari ping response
318
+ if pingData then
319
+ local newActive = pingData.mcpConnected == true
320
+ if newActive ~= mcpAgentActive then
321
+ mcpAgentActive = newActive
322
+ if ui then
323
+ if mcpAgentActive then
324
+ ui:setStatus("connected")
325
+ ui:addLog("[System] ✅ IDE Agent (Antigravity) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
326
+ else
327
+ ui:setStatus("server_connected")
328
+ ui:addLog("[System] Menunggu IDE Agent (Antigravity/Cursor)...", Color3.fromRGB(100, 180, 255))
329
+ end
330
+ end
331
+ elseif serverReachable and not mcpAgentActive then
332
+ -- Tidak ada perubahan tapi pastikan status benar
333
+ if ui then ui:setStatus("server_connected") end
334
+ end
335
+ end
336
+
337
+ -- Langkah 2: Buka SSE stream (blocking hingga server menutup koneksi / ~10 detik)
338
+ -- Server akan mengirim events yang tertunda, heartbeat, lalu tutup.
339
+ if isRunning and not isManualDisconnect then
340
+ local streamOk, streamRaw = pcall(function()
341
+ return HttpService:GetAsync(
342
+ STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
343
+ false
344
+ )
345
+ end)
346
+
347
+ if streamOk and streamRaw and streamRaw ~= "" then
348
+ -- Parse baris-baris SSE dari response
349
+ for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
350
+ line = line:gsub("\r", "")
351
+ if line ~= "" then
352
+ handleSSELine(line)
353
+ end
354
+ end
355
+ end
356
+ end
357
+
358
+ -- Jeda singkat sebelum cycle berikutnya
359
+ task.wait(0.5)
360
+ end
361
+ end
362
+
363
+ isRunning = false
364
+ serverReachable = false
365
+ mcpAgentActive = false
366
+ if ui then ui:setStatus("disconnected") end
367
+ end
368
+
369
+ -- ── Plugin Unloading ──────────────────────────────────────────────────────────
370
+ plugin.Unloading:Connect(function()
371
+ isManualDisconnect = true
372
+ isRunning = false
373
+ end)
374
+
283
375
  -- ══════════════════════════════════════════════════════════════════════
284
- -- AUTO-DETECT: Background heartbeat yang SELALU berjalan.
285
- -- Mendeteksi apakah server MCP aktif setiap 1.5 detik via PING_URL.
286
- -- Menggunakan /api/ping agar instan (tidak tertahan 20 detik oleh long-poll).
376
+ -- STARTUP: Langsung mulai connection loop
287
377
  -- ══════════════════════════════════════════════════════════════════════
288
378
  task.spawn(function()
289
- task.wait(0.5) -- Tunggu sebentar agar UI siap
290
-
291
- if ui then
292
- ui:addLog("[System] Plugin termuat. Auto-detect server MCP aktif...", Color3.fromRGB(200, 200, 200))
293
- end
294
-
295
- while true do
296
- -- Hanya jalankan auto-detect jika user tidak sengaja menekan Disconnect
297
- if not isManualDisconnect and (autoConnect or isConnected) then
298
- if not isPolling then
299
- local success, _ = pcall(function()
300
- local cacheBuster = "?t=" .. tostring(os.clock())
301
- return HttpService:GetAsync(PING_URL .. cacheBuster)
302
- end)
303
-
304
- if success then
305
- -- Server terdeteksi aktif via Ping!
306
- if not isConnected then
307
- isConnected = true
308
- if ui then
309
- ui:setStatus("connected")
310
- ui:addLog("[System] Server MCP terdeteksi! Terhubung otomatis.", Color3.fromRGB(100, 255, 100))
311
- end
312
- startPolling()
313
- else
314
- startPolling()
315
- end
316
- else
317
- -- Server tidak aktif / belum menyala
318
- if isConnected then
319
- isConnected = false
320
- if ui then
321
- ui:setStatus("waiting")
322
- ui:addLog("[System] Server MCP terputus. Menunggu reconnect...", Color3.fromRGB(255, 200, 100))
323
- end
324
- elseif ui and ui.status ~= "waiting" and not isManualDisconnect then
325
- ui:setStatus("waiting")
326
- end
327
- end
328
- end
329
- end
330
-
331
- task.wait(1.5)
332
- end
379
+ task.wait(0.5) -- Tunggu UI siap
380
+ startConnectionLoop()
333
381
  end)