@adiholick/nvstudio-mcp 2.1.0 → 2.1.2

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,8 +1,8 @@
1
1
  -- ══════════════════════════════════════════════════════════════════════
2
2
  -- NVStudio MCP Plugin v2.1.0
3
- -- Arsitektur: SSE (Server-Sent Events) via WebStreamClient
4
- -- Plugin membuka koneksi stream persisten ke server Node.js.
5
- -- Server PUSH task langsung — tidak ada polling loop.
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
6
  -- ══════════════════════════════════════════════════════════════════════
7
7
 
8
8
  local HttpService = game:GetService("HttpService")
@@ -10,11 +10,11 @@ local LogService = game:GetService("LogService")
10
10
 
11
11
  -- URL Endpoints
12
12
  local BASE_URL = "http://localhost:3055"
13
- local STREAM_URL = BASE_URL .. "/api/stream"
14
- local RESPONSE_URL = BASE_URL .. "/api/response" -- /api/response/:taskId via POST
13
+ local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
14
+ local RESPONSE_URL = BASE_URL .. "/api/response"
15
15
  local PING_URL = BASE_URL .. "/api/ping"
16
16
 
17
- -- ID unik per sesi Studio (jika dua Studio terbuka, keduanya didaftarkan terpisah)
17
+ -- ID unik per sesi Studio
18
18
  local STUDIO_ID = HttpService:GenerateGUID(false)
19
19
 
20
20
  -- ── UI Module ──────────────────────────────────────────────────────────────────
@@ -27,366 +27,356 @@ local toggleBtn = toolbar:CreateButton("Dashboard", "Buka Dashboard NVStudio MCP
27
27
 
28
28
  -- ── State ─────────────────────────────────────────────────────────────────────
29
29
  local ctx = {
30
- scriptHistory = {},
31
- recentLogs = {},
32
- HttpService = HttpService,
33
- studioId = STUDIO_ID,
30
+ scriptHistory = {},
31
+ recentLogs = {},
32
+ HttpService = HttpService,
33
+ studioId = STUDIO_ID,
34
34
  }
35
- local MAX_LOGS = 50
36
- local isConnected = false -- apakah SSE stream aktif
37
- local mcpAgentActive = false -- apakah Antigravity/Cursor sedang konek ke MCP
35
+ local MAX_LOGS = 50
36
+ local isRunning = false -- apakah loop aktif
38
37
  local isManualDisconnect = false
39
- local isClickDebounce = false
40
- local currentStream = nil -- WebStreamClient aktif
41
- local streamConnections = {} -- RBXScriptConnection SSE
38
+ local isClickDebounce = false
39
+ local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
40
+ local serverReachable = false -- apakah server Node.js reachable
42
41
 
43
- -- Reconnect config (exponential backoff)
44
- local RECONNECT_DELAY_INITIAL = 0.5
45
- local RECONNECT_DELAY_MAX = 10
42
+ -- Reconnect config
43
+ local RECONNECT_DELAY_INITIAL = 1
44
+ local RECONNECT_DELAY_MAX = 15
46
45
  local reconnectDelay = RECONNECT_DELAY_INITIAL
47
46
 
48
47
  -- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
49
48
  local skills = {}
50
49
  for _, module in ipairs(script.Parent:GetChildren()) do
51
- if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
52
- local ok, result = pcall(require, module)
53
- if ok then
54
- skills[module.Name] = result
55
- else
56
- warn("[NVStudio] Gagal load module '" .. module.Name .. "': " .. tostring(result))
57
- end
58
- end
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
59
58
  end
60
59
 
61
60
  -- ── Inisialisasi UI ──────────────────────────────────────────────────────────
62
61
  if uiModule then
63
- local UIManager = require(uiModule)
64
- ui = UIManager.init(plugin, STUDIO_ID)
65
-
66
- ui:setAutoConnectState(true)
67
- ui:setStatus("waiting")
68
-
69
- if ui.widget then
70
- toggleBtn:SetActive(ui.widget.Enabled)
71
- ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
72
- toggleBtn:SetActive(ui.widget.Enabled)
73
- end)
74
- end
75
-
76
- toggleBtn.Click:Connect(function()
77
- ui:toggleVisibility()
78
- end)
79
-
80
- -- Tombol Connect / Disconnect
81
- ui.connectBtn.MouseButton1Click:Connect(function()
82
- if isClickDebounce then return end
83
- isClickDebounce = true
84
-
85
- if not isConnected then
86
- isManualDisconnect = false
87
- reconnectDelay = RECONNECT_DELAY_INITIAL
88
- ui:setStatus("waiting")
89
- ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
90
- task.spawn(connectSSE)
91
- else
92
- isManualDisconnect = true
93
- disconnectSSE()
94
- ui:setStatus("disconnected")
95
- ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
96
- end
97
-
98
- task.delay(0.3, function() isClickDebounce = false end)
99
- 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)
100
101
  else
101
- toggleBtn.Click:Connect(function()
102
- print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
103
- end)
102
+ toggleBtn.Click:Connect(function()
103
+ print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
104
+ end)
104
105
  end
105
106
 
106
107
  -- ── Log Capture ──────────────────────────────────────────────────────────────
107
108
  local function addSystemLog(msg, msgType)
108
- local color = Color3.fromRGB(200, 200, 200)
109
- local prefix = "[INFO] "
110
- if msgType == Enum.MessageType.MessageError then
111
- prefix = "[ERROR] "; color = Color3.fromRGB(255, 100, 100)
112
- elseif msgType == Enum.MessageType.MessageWarning then
113
- prefix = "[WARN] "; color = Color3.fromRGB(255, 200, 100)
114
- end
115
- local fullMsg = prefix .. msg
116
- table.insert(ctx.recentLogs, fullMsg)
117
- if #ctx.recentLogs > MAX_LOGS then table.remove(ctx.recentLogs, 1) end
118
- if ui then ui:addLog(fullMsg, color) 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
119
120
  end
120
121
  LogService.MessageOut:Connect(addSystemLog)
121
122
 
122
123
  -- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
123
124
  local function getInstanceFromPath(pathString)
124
- local parts = string.split(pathString, ".")
125
- local current = game
126
- if parts[1] == "game" then table.remove(parts, 1) end
127
- pcall(function()
128
- local svc = game:GetService(parts[1])
129
- if svc then current = svc; table.remove(parts, 1) end
130
- end)
131
- for _, part in ipairs(parts) do
132
- local found = current:FindFirstChild(part)
133
- if not found then return nil end
134
- current = found
135
- end
136
- 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
137
138
  end
138
139
 
139
140
  -- ── Proses Task dari Server ───────────────────────────────────────────────────
140
141
  local function processTask(taskData)
141
- if not taskData or not taskData.id or not taskData.command then return nil end
142
+ if not taskData or not taskData.id or not taskData.command then return nil end
142
143
 
143
- if ui then
144
- ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
145
- end
144
+ if ui then
145
+ ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
146
+ end
146
147
 
147
- local targetInstance = getInstanceFromPath(taskData.target)
148
- if not targetInstance then
149
- return { id = taskData.id, status = "error", error = "Target path tidak ditemukan: " .. tostring(taskData.target) }
150
- end
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
151
152
 
152
- local skillFunc = skills[taskData.command]
153
- if not skillFunc then
154
- return { id = taskData.id, status = "error", error = "Command tidak dikenali: " .. taskData.command }
155
- end
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
156
157
 
157
- local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
158
- result.id = taskData.id
158
+ local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
159
+ result.id = taskData.id
159
160
 
160
- if ui then
161
- local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
162
- ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
163
- end
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
164
165
 
165
- return result
166
+ return result
166
167
  end
167
168
 
168
169
  -- ── Kirim Respons ke Server ───────────────────────────────────────────────────
169
170
  local function sendResponse(taskId, resultData)
170
- task.spawn(function()
171
- local ok, err = pcall(function()
172
- local json = HttpService:JSONEncode(resultData)
173
- -- Coba endpoint baru dulu (/api/response/:taskId), fallback ke lama
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)
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)
189
189
  end
190
190
 
191
- -- ── Proses satu baris data SSE ────────────────────────────────────────────────
192
- local function handleSSEMessage(rawMessage)
193
- -- Strip prefix "data: " jika ada (beberapa Studio version memberikannya)
194
- local payload = rawMessage
195
- local normalized = rawMessage:gsub("\r\n", "\n"):gsub("\r", "\n")
196
- if normalized:sub(1, 5) == "data:" then
197
- payload = normalized:sub(6):gsub("^%s+", ""):gsub("%s+$", "")
198
- end
199
- if payload == "" then return end
200
-
201
- local ok, decoded = pcall(function() return HttpService:JSONDecode(payload) end)
202
- if not ok or type(decoded) ~= "table" then return end
203
-
204
- local kind = decoded.kind
205
-
206
- -- ── heartbeat ────────────────────────────────────────────────────
207
- if kind == "heartbeat" then
208
- -- Server masih hidup, tidak perlu tindakan khusus
209
- return
210
- end
211
-
212
- -- ── status ───────────────────────────────────────────────────────
213
- if kind == "status" then
214
- local newMcpActive = decoded.mcpConnected == true
215
- if newMcpActive ~= mcpAgentActive then
216
- mcpAgentActive = newMcpActive
217
- if ui then
218
- if mcpAgentActive then
219
- ui:setStatus("connected")
220
- ui:addLog("[System] IDE Agent (Antigravity/Cursor) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
221
- else
222
- ui:setStatus("waiting")
223
- ui:addLog("[System] IDE Agent terputus dari MCP.", Color3.fromRGB(255, 200, 100))
224
- end
225
- end
226
- end
227
- return
228
- end
229
-
230
- -- ── request (task dari AI agent) ─────────────────────────────────
231
- if kind == "request" then
232
- local taskId = decoded.requestId
233
- local command = decoded.command
234
- local target = decoded.target
235
- local data = decoded.data
236
-
237
- if not taskId or not command then return end
238
-
239
- -- Proses task di coroutine terpisah agar tidak memblokir stream
240
- task.spawn(function()
241
- local resultData = processTask({
242
- id = taskId,
243
- command = command,
244
- target = target or "",
245
- data = data,
246
- })
247
- if resultData then
248
- sendResponse(taskId, resultData)
249
- end
250
- end)
251
- return
252
- 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
200
+ -- Tampilkan sebagai connected (karena server sse bridge aktif)
201
+ ui:setStatus("server_connected")
202
+ end
253
203
  end
254
204
 
255
- -- ── Tutup SSE stream yang aktif ───────────────────────────────────────────────
256
- function disconnectSSE()
257
- isConnected = false
258
- for _, conn in ipairs(streamConnections) do
259
- pcall(function() conn:Disconnect() end)
260
- end
261
- streamConnections = {}
262
- if currentStream then
263
- pcall(function() currentStream:Close() end)
264
- currentStream = nil
265
- end
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
266
264
  end
267
265
 
268
- -- ── Buka SSE stream baru ──────────────────────────────────────────────────────
269
- function connectSSE()
270
- if isConnected then return end
271
- disconnectSSE() -- Bersihkan sisa koneksi lama
272
-
273
- local url = STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID)
274
-
275
- local ok, streamOrErr = pcall(function()
276
- return HttpService:GetAsync(url, false) -- Gunakan non-cached request
277
- end)
278
-
279
- -- WebStreamClient hanya tersedia di Studio; GetAsync biasa tidak akan
280
- -- mempertahankan koneksi. Kita butuh WebStreamClient khusus.
281
- -- Coba via WebStreamClient API:
282
- local streamOk, stream = pcall(function()
283
- -- @ts-ignore – WebStreamClient mungkin belum ada di semua versi Studio
284
- return (HttpService :: any):WebStream(url)
285
- end)
286
-
287
- if not streamOk or not stream then
288
- -- Fallback: Studio tidak mendukung WebStreamClient → gunakan polling biasa
289
- if ui then
290
- ui:addLog("[System] WebStreamClient tidak tersedia. Menggunakan polling fallback.", Color3.fromRGB(255, 200, 100))
291
- end
292
- task.spawn(fallbackPolling)
293
- return
294
- end
295
-
296
- currentStream = stream
297
- isConnected = true
298
- reconnectDelay = RECONNECT_DELAY_INITIAL
299
-
300
- if ui then
301
- ui:setStatus("waiting") -- Menunggu status mcpConnected dari server
302
- ui:addLog("[System] SSE stream terbuka. Menunggu IDE Agent...", Color3.fromRGB(100, 200, 255))
303
- end
304
-
305
- -- Event: pesan masuk dari SSE stream
306
- local onMessage = stream.OnMessage:Connect(function(message)
307
- handleSSEMessage(message)
308
- end)
309
- table.insert(streamConnections, onMessage)
310
-
311
- -- Event: stream ditutup (server restart / network drop)
312
- local onClose = stream.OnClose:Connect(function()
313
- isConnected = false
314
- mcpAgentActive = false
315
- currentStream = nil
316
- streamConnections = {}
317
- if ui then
318
- ui:setStatus("waiting")
319
- ui:addLog("[System] SSE stream terputus. Reconnect dalam " .. reconnectDelay .. "s...", Color3.fromRGB(255, 200, 100))
320
- end
321
- if not isManualDisconnect then
322
- task.delay(reconnectDelay, connectSSE)
323
- reconnectDelay = math.min(reconnectDelay * 2, RECONNECT_DELAY_MAX)
324
- end
325
- end)
326
- table.insert(streamConnections, onClose)
327
- end
328
-
329
- -- ── Fallback: HTTP Polling (jika WebStreamClient tidak tersedia) ──────────────
330
- -- Digunakan pada versi Studio lama yang belum mendukung WebStreamClient.
331
- local isPollingActive = false
332
- function fallbackPolling()
333
- if isPollingActive then return end
334
- isPollingActive = true
335
- isConnected = true
336
-
337
- if ui then
338
- ui:setStatus("waiting")
339
- ui:addLog("[System] Mode Polling aktif (WebStreamClient tidak didukung).", Color3.fromRGB(200, 200, 100))
340
- end
341
-
342
- while isConnected and not isManualDisconnect do
343
- -- Ping
344
- local pingOk, _ = pcall(function()
345
- return HttpService:GetAsync(PING_URL .. "?studioId=" .. STUDIO_ID .. "&t=" .. tostring(os.clock()))
346
- end)
347
-
348
- if pingOk then
349
- if ui then ui:setStatus("connected") end
350
-
351
- -- Long-poll tasks
352
- local taskOk, taskResponse = pcall(function()
353
- return HttpService:GetAsync(BASE_URL .. "/api/tasks?t=" .. tostring(os.clock()))
354
- end)
355
-
356
- if taskOk and taskResponse then
357
- local decodeOk, taskData = pcall(function() return HttpService:JSONDecode(taskResponse) end)
358
- if decodeOk and taskData and taskData.id then
359
- local resultData = processTask(taskData)
360
- if resultData then
361
- sendResponse(taskData.id, resultData)
362
- end
363
- end
364
- end
365
- task.wait(0.1)
366
- else
367
- if ui then ui:setStatus("waiting") end
368
- task.wait(1.5)
369
- end
370
- end
371
-
372
- isPollingActive = false
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
+ -- No need to explicitly add log for IDE Agent wait every time it connects,
329
+ -- bridge connected log is enough.
330
+ end
331
+ end
332
+ elseif serverReachable and not mcpAgentActive then
333
+ -- Tidak ada perubahan tapi pastikan status benar
334
+ if ui then ui:setStatus("server_connected") end
335
+ end
336
+ end
337
+
338
+ -- Langkah 2: Buka SSE stream (blocking hingga server menutup koneksi / ~10 detik)
339
+ -- Server akan mengirim events yang tertunda, heartbeat, lalu tutup.
340
+ if isRunning and not isManualDisconnect then
341
+ local streamOk, streamRaw = pcall(function()
342
+ return HttpService:GetAsync(
343
+ STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
344
+ false
345
+ )
346
+ end)
347
+
348
+ if streamOk and streamRaw and streamRaw ~= "" then
349
+ -- Parse baris-baris SSE dari response
350
+ for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
351
+ line = line:gsub("\r", "")
352
+ if line ~= "" then
353
+ handleSSELine(line)
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ -- Jeda singkat sebelum cycle berikutnya
360
+ task.wait(0.5)
361
+ end
362
+ end
363
+
364
+ isRunning = false
365
+ serverReachable = false
366
+ mcpAgentActive = false
367
+ if ui then ui:setStatus("disconnected") end
373
368
  end
374
369
 
375
370
  -- ── Plugin Unloading ──────────────────────────────────────────────────────────
376
371
  plugin.Unloading:Connect(function()
377
- isManualDisconnect = true
378
- disconnectSSE()
372
+ isManualDisconnect = true
373
+ isRunning = false
379
374
  end)
380
375
 
381
376
  -- ══════════════════════════════════════════════════════════════════════
382
- -- STARTUP: Langsung buka SSE stream
377
+ -- STARTUP: Langsung mulai connection loop
383
378
  -- ══════════════════════════════════════════════════════════════════════
384
379
  task.spawn(function()
385
- task.wait(0.5) -- Tunggu UI siap
386
-
387
- if ui then
388
- ui:addLog("[System] NVStudio MCP v2.1.0 termuat. Membuka SSE stream...", Color3.fromRGB(200, 200, 200))
389
- end
390
-
391
- connectSSE()
380
+ task.wait(0.5) -- Tunggu UI siap
381
+ startConnectionLoop()
392
382
  end)