@adiholick/nvstudio-mcp 1.1.0 → 2.0.0

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.
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const PLUGIN_DIR = path_1.default.join(process.cwd(), 'studio-plugin');
10
+ const OUTPUT_FILE = path_1.default.join(PLUGIN_DIR, 'nvstudio_mcp.rbxmx');
11
+ // Helper to escape XML special characters
12
+ function escapeXml(unsafe) {
13
+ return unsafe.replace(/[<>&'"]/g, function (c) {
14
+ switch (c) {
15
+ case '<': return '&lt;';
16
+ case '>': return '&gt;';
17
+ case '&': return '&amp;';
18
+ case '\'': return '&apos;';
19
+ case '"': return '&quot;';
20
+ default: return c;
21
+ }
22
+ });
23
+ }
24
+ function buildRbxmx() {
25
+ console.log('[Build] Memulai kompilasi nvstudio_mcp.rbxmx...');
26
+ if (!fs_1.default.existsSync(PLUGIN_DIR)) {
27
+ console.error(`[Build] Folder ${PLUGIN_DIR} tidak ditemukan.`);
28
+ process.exit(1);
29
+ }
30
+ const files = fs_1.default.readdirSync(PLUGIN_DIR).filter(file => file.endsWith('.lua'));
31
+ // RBXMX Header
32
+ let xml = `<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">\n`;
33
+ xml += `\t<External>null</External>\n\t<External>nil</External>\n`;
34
+ // Main Folder
35
+ xml += `\t<Item class="Folder" referent="RBX0497167F99814CA6A2096BB729707905">\n`;
36
+ xml += `\t\t<Properties>\n`;
37
+ xml += `\t\t\t<BinaryString name="AttributesSerialize"></BinaryString>\n`;
38
+ xml += `\t\t\t<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>\n`;
39
+ xml += `\t\t\t<bool name="DefinesCapabilities">false</bool>\n`;
40
+ xml += `\t\t\t<string name="Name">nvstudio_mcp</string>\n`;
41
+ xml += `\t\t\t<int64 name="SourceAssetId">-1</int64>\n`;
42
+ xml += `\t\t\t<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>\n`;
43
+ xml += `\t\t</Properties>\n`;
44
+ // Process files
45
+ for (const file of files) {
46
+ const filePath = path_1.default.join(PLUGIN_DIR, file);
47
+ const source = fs_1.default.readFileSync(filePath, 'utf-8');
48
+ const name = file.replace('.lua', '');
49
+ // Main.server.lua is a Script, others are ModuleScripts
50
+ const isMain = name === 'Main.server';
51
+ const className = isMain ? 'Script' : 'ModuleScript';
52
+ // Use a simple hash-like referent
53
+ const referent = `RBX_${name.replace(/[^a-zA-Z0-9]/g, '_')}`;
54
+ xml += `\t\t<Item class="${className}" referent="${referent}">\n`;
55
+ xml += `\t\t\t<Properties>\n`;
56
+ xml += `\t\t\t\t<BinaryString name="AttributesSerialize"></BinaryString>\n`;
57
+ xml += `\t\t\t\t<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>\n`;
58
+ xml += `\t\t\t\t<bool name="DefinesCapabilities">false</bool>\n`;
59
+ xml += `\t\t\t\t<bool name="Disabled">false</bool>\n`;
60
+ xml += `\t\t\t\t<Content name="LinkedSource"><null></null></Content>\n`;
61
+ xml += `\t\t\t\t<string name="Name">${name}</string>\n`;
62
+ xml += `\t\t\t\t<int64 name="SourceAssetId">-1</int64>\n`;
63
+ xml += `\t\t\t\t<SharedString name="Tags"></SharedString>\n`;
64
+ if (isMain) {
65
+ xml += `\t\t\t\t<token name="RunContext">0</token>\n`;
66
+ }
67
+ // Wrap source in CDATA to avoid XML escaping issues for the code itself
68
+ // Ensure no ]]> appears inside the CDATA
69
+ const safeSource = source.replace(/\]\]>/g, ']]]]><![CDATA[>');
70
+ xml += `\t\t\t\t<ProtectedString name="Source"><![CDATA[${safeSource}]]></ProtectedString>\n`;
71
+ xml += `\t\t\t</Properties>\n`;
72
+ xml += `\t\t</Item>\n`;
73
+ }
74
+ // Close Folder
75
+ xml += `\t</Item>\n`;
76
+ // Shared Strings definition (required by Roblox XML format if Tags is used)
77
+ xml += `\t<SharedStrings>\n`;
78
+ xml += `\t\t<SharedString md5="yuZpQdnvvUBOTYh1jqZ2cA=="></SharedString>\n`;
79
+ xml += `\t</SharedStrings>\n`;
80
+ // Close Roblox
81
+ xml += `</roblox>\n`;
82
+ fs_1.default.writeFileSync(OUTPUT_FILE, xml, 'utf-8');
83
+ console.log(`[Build] Berhasil membuat ${OUTPUT_FILE}`);
84
+ copyToRobloxPlugins();
85
+ }
86
+ function copyToRobloxPlugins() {
87
+ let pluginsDir = '';
88
+ if (process.platform === 'win32') {
89
+ const localAppData = process.env.LOCALAPPDATA;
90
+ if (localAppData) {
91
+ pluginsDir = path_1.default.join(localAppData, 'Roblox', 'Plugins');
92
+ }
93
+ }
94
+ else if (process.platform === 'darwin') {
95
+ pluginsDir = path_1.default.join(os_1.default.homedir(), 'Documents', 'Roblox', 'Plugins');
96
+ }
97
+ if (pluginsDir) {
98
+ if (!fs_1.default.existsSync(pluginsDir)) {
99
+ console.log(`[Build] Folder plugin Roblox tidak ditemukan di ${pluginsDir}. Melewati penyalinan otomatis.`);
100
+ return;
101
+ }
102
+ const targetFile = path_1.default.join(pluginsDir, 'nvstudio_mcp.rbxmx');
103
+ try {
104
+ fs_1.default.copyFileSync(OUTPUT_FILE, targetFile);
105
+ console.log(`[Build] ✅ Berhasil menyalin nvstudio_mcp.rbxmx ke ${targetFile}`);
106
+ }
107
+ catch (err) {
108
+ console.error(`[Build] Gagal menyalin ke ${targetFile}:`, err.message);
109
+ }
110
+ }
111
+ else {
112
+ console.log(`[Build] Platform ${process.platform} tidak didukung untuk auto-copy plugin.`);
113
+ }
114
+ }
115
+ buildRbxmx();
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ const roblox_bridge_1 = require("./roblox-bridge");
11
11
  const task_queue_1 = require("./task-queue");
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
+ const luau_validator_1 = require("./luau-validator");
14
15
  const HISTORY_DIR = path_1.default.join(process.cwd(), '.history');
15
16
  if (!fs_1.default.existsSync(HISTORY_DIR)) {
16
17
  fs_1.default.mkdirSync(HISTORY_DIR, { recursive: true });
@@ -26,7 +27,7 @@ function backupScript(target, code) {
26
27
  // 2. Inisialisasi server MCP
27
28
  const server = new index_js_1.Server({
28
29
  name: "nvstudio-mcp",
29
- version: "1.0.0",
30
+ version: "2.0.0",
30
31
  }, {
31
32
  capabilities: {
32
33
  tools: {},
@@ -38,21 +39,21 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
38
39
  tools: [
39
40
  {
40
41
  name: "call_mcp_tool",
41
- description: "ALAT MUTLAK UNTUK ROBLOX. DILARANG KERAS menggunakan PowerShell atau skrip bypass (fetch_mcp_source.js). WAJIB memanggil 'get_children' terlebih dahulu sebelum 'get_script_source'. Command: get_children, get_script_source, update_script_source, rollback_script, get_logs, create_instance, search_instance, generate_terrain, insert_asset, delete_instance, get_properties, script_grep, execute_luau, search_asset.",
42
+ description: "ALAT MUTLAK UNTUK ROBLOX. Command: patch_script_source, get_children, get_script_source, update_script_source, rollback_script, get_logs, create_instance, search_instance, generate_terrain, insert_asset, delete_instance, get_properties, script_grep, execute_luau, search_asset.",
42
43
  inputSchema: {
43
44
  type: "object",
44
45
  properties: {
45
46
  command: {
46
47
  type: "string",
47
- description: "Perintah yang akan dieksekusi (contoh: 'get_children', 'get_script_source', 'update_script_source', 'rollback_script', 'get_logs').",
48
+ description: "Perintah yang dieksekusi (contoh: 'patch_script_source', 'get_script_source', 'update_script_source').",
48
49
  },
49
50
  target: {
50
51
  type: "string",
51
- description: "Path absolut dari objek target di Roblox Studio (contoh: 'Workspace.Map.Script' atau 'ServerScriptService.MainModule').",
52
+ description: "Path absolut dari objek target di Roblox Studio (contoh: 'Workspace.Map.Script').",
52
53
  },
53
54
  data: {
54
55
  type: "string",
55
- description: "Data tambahan opsional, misalnya isi source code baru saat menjalankan perintah 'update_script_source'.",
56
+ description: "Data tambahan (misal payload JSON untuk patch_script_source, atau source code penuh untuk update_script_source).",
56
57
  },
57
58
  },
58
59
  required: ["command", "target"],
@@ -82,20 +83,24 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
82
83
  isError: true,
83
84
  };
84
85
  }
85
- // 6. 🔴 GUARDRAIL MUTLAK: Cegah tebakan path buta
86
- if (command === "get_script_source") {
87
- const genericTargets = ["Workspace", "ServerScriptService", "ReplicatedStorage", "StarterGui", "StarterPack", "game"];
88
- if (genericTargets.includes(target) || !target.includes(".")) {
86
+ // Pre-commit validation for full source replacement
87
+ if (command === "update_script_source" && data) {
88
+ const validation = (0, luau_validator_1.validateLuauSyntax)(String(data));
89
+ if (!validation.valid) {
89
90
  return {
90
91
  content: [{
91
92
  type: "text",
92
- text: "🔴 SERVER GUARDRAIL BLOCKED: DILARANG mengekstrak kode secara buta dari direktori utama atau target yang tidak spesifik. Anda WAJIB memanggil perintah 'get_children' pada '" + target + "' terlebih dahulu untuk memverifikasi nama dan ClassName (Pastikan target adalah LuaSourceContainer)."
93
+ text: JSON.stringify({
94
+ status: "error",
95
+ error: `Syntax error terdeteksi (Pre-commit check Node.js): ${validation.error} pada baris ${validation.line || 'unknown'}`
96
+ })
93
97
  }],
94
98
  isError: true
95
99
  };
96
100
  }
101
+ backupScript(target, String(data));
97
102
  }
98
- // 7. Intercept command yang dieksekusi di server lokal (Node.js) alih-alih di Studio
103
+ // Intercept command yang dieksekusi di server lokal (Node.js) alih-alih di Studio
99
104
  if (command === "search_asset" && data) {
100
105
  try {
101
106
  const queryParams = typeof data === "string" ? JSON.parse(data) : data;
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateLuauSyntax = validateLuauSyntax;
4
+ function validateLuauSyntax(source) {
5
+ const lines = source.split('\n');
6
+ // Stack for blocks: function, do, if, for, while, repeat
7
+ // We'll keep it simple: just count block openers vs block closers (end, until)
8
+ // Note: This is a heuristic pre-commit check. It doesn't replace a full AST parser,
9
+ // but catches 90% of AI hallucination syntax errors (missing 'end', etc.)
10
+ let blockDepth = 0;
11
+ let repeatDepth = 0;
12
+ // Bracket stacks
13
+ let parenDepth = 0; // ()
14
+ let braceDepth = 0; // {}
15
+ let bracketDepth = 0; // []
16
+ let inString = false;
17
+ let stringChar = '';
18
+ let inMultilineString = false;
19
+ let multilineEqCount = 0;
20
+ let inMultilineComment = false;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ let j = 0;
24
+ while (j < line.length) {
25
+ const char = line[j];
26
+ const nextChar = line[j + 1] || '';
27
+ // Handle multiline comments
28
+ if (!inString && !inMultilineString && !inMultilineComment) {
29
+ if (char === '-' && nextChar === '-') {
30
+ const nextNextChar = line[j + 2] || '';
31
+ if (nextNextChar === '[') {
32
+ let k = j + 3;
33
+ let eq = 0;
34
+ while (line[k] === '=') {
35
+ eq++;
36
+ k++;
37
+ }
38
+ if (line[k] === '[') {
39
+ inMultilineComment = true;
40
+ multilineEqCount = eq;
41
+ j = k + 1;
42
+ continue;
43
+ }
44
+ }
45
+ // Single line comment, skip rest of line
46
+ break;
47
+ }
48
+ }
49
+ if (inMultilineComment) {
50
+ if (char === ']') {
51
+ let k = j + 1;
52
+ let eq = 0;
53
+ while (line[k] === '=') {
54
+ eq++;
55
+ k++;
56
+ }
57
+ if (line[k] === ']' && eq === multilineEqCount) {
58
+ inMultilineComment = false;
59
+ j = k + 1;
60
+ continue;
61
+ }
62
+ }
63
+ j++;
64
+ continue;
65
+ }
66
+ // Handle strings
67
+ if (!inString && !inMultilineString) {
68
+ if (char === '"' || char === "'") {
69
+ inString = true;
70
+ stringChar = char;
71
+ j++;
72
+ continue;
73
+ }
74
+ if (char === '[') {
75
+ let k = j + 1;
76
+ let eq = 0;
77
+ while (line[k] === '=') {
78
+ eq++;
79
+ k++;
80
+ }
81
+ if (line[k] === '[') {
82
+ inMultilineString = true;
83
+ multilineEqCount = eq;
84
+ j = k + 1;
85
+ continue;
86
+ }
87
+ }
88
+ }
89
+ if (inString) {
90
+ if (char === '\\' && (nextChar === '"' || nextChar === "'" || nextChar === '\\')) {
91
+ j += 2;
92
+ continue;
93
+ }
94
+ if (char === stringChar) {
95
+ inString = false;
96
+ }
97
+ j++;
98
+ continue;
99
+ }
100
+ if (inMultilineString) {
101
+ if (char === ']') {
102
+ let k = j + 1;
103
+ let eq = 0;
104
+ while (line[k] === '=') {
105
+ eq++;
106
+ k++;
107
+ }
108
+ if (line[k] === ']' && eq === multilineEqCount) {
109
+ inMultilineString = false;
110
+ j = k + 1;
111
+ continue;
112
+ }
113
+ }
114
+ j++;
115
+ continue;
116
+ }
117
+ // At this point, we are outside strings and comments.
118
+ // Check brackets
119
+ if (char === '(')
120
+ parenDepth++;
121
+ else if (char === ')') {
122
+ parenDepth--;
123
+ if (parenDepth < 0)
124
+ return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
125
+ }
126
+ else if (char === '{')
127
+ braceDepth++;
128
+ else if (char === '}') {
129
+ braceDepth--;
130
+ if (braceDepth < 0)
131
+ return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
132
+ }
133
+ else if (char === '[')
134
+ bracketDepth++;
135
+ else if (char === ']') {
136
+ bracketDepth--;
137
+ if (bracketDepth < 0)
138
+ return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
139
+ }
140
+ j++;
141
+ }
142
+ if (inString) {
143
+ return { valid: false, error: 'String tidak ditutup (unclosed string literal)', line: i + 1 };
144
+ }
145
+ // Tokenize line to check blocks (very simplified heuristic)
146
+ // We only check for keywords bounded by word boundaries
147
+ if (!inMultilineString && !inMultilineComment) {
148
+ // Remove string literals and comments for naive keyword counting
149
+ let cleanLine = line.replace(/(--.*)/, ''); // remove single line comments
150
+ cleanLine = cleanLine.replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, ''); // remove inline strings
151
+ // Avoid false positives like `local func = "do"` by looking at word boundaries
152
+ // This is a naive regex matching.
153
+ // In Luau: 'do', 'if', 'function', 'for', 'while' need an 'end'.
154
+ // 'repeat' needs 'until'.
155
+ const words = cleanLine.split(/[^a-zA-Z0-9_]+/).filter(w => w.length > 0);
156
+ for (let w = 0; w < words.length; w++) {
157
+ const word = words[w];
158
+ if (word === 'if' || word === 'function' || word === 'for' || word === 'while' || word === 'do') {
159
+ // 'elseif' does not create a new block.
160
+ // 'if' does.
161
+ blockDepth++;
162
+ }
163
+ else if (word === 'end') {
164
+ blockDepth--;
165
+ }
166
+ else if (word === 'repeat') {
167
+ repeatDepth++;
168
+ }
169
+ else if (word === 'until') {
170
+ repeatDepth--;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (inMultilineString)
176
+ return { valid: false, error: 'Multiline string tidak ditutup (unclosed [[ ]])' };
177
+ if (inMultilineComment)
178
+ return { valid: false, error: 'Multiline comment tidak ditutup (unclosed --[[ ]])' };
179
+ if (parenDepth > 0)
180
+ return { valid: false, error: 'Kekurangan tutup kurung ")"' };
181
+ if (braceDepth > 0)
182
+ return { valid: false, error: 'Kekurangan tutup kurawal "}"' };
183
+ if (bracketDepth > 0)
184
+ return { valid: false, error: 'Kekurangan tutup siku "]"' };
185
+ // We can't strictly enforce blockDepth == 0 because our heuristic is very naive
186
+ // and might be thrown off by complex inline patterns, but it catches massive missing 'end's.
187
+ // So we'll return a warning instead of blocking if blockDepth != 0,
188
+ // or we can just let it pass to Studio which will output the real error.
189
+ // For now, let's just use it as a basic check.
190
+ return { valid: true };
191
+ }
@@ -89,6 +89,16 @@ function startBridgeServer(port = 3055) {
89
89
  app.get('/api/stats', (req, res) => {
90
90
  res.json({ totalTasks, successCount, errorCount, sessionStart, studioConnected, port });
91
91
  });
92
+ const waitingPollers = [];
93
+ task_queue_1.taskEmitter.on('new_task', () => {
94
+ if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
95
+ const res = waitingPollers.shift();
96
+ const task = task_queue_1.taskQueue.shift();
97
+ totalTasks++;
98
+ addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
99
+ res?.json(task);
100
+ }
101
+ });
92
102
  // Endpoint Studio Polling
93
103
  app.get('/api/tasks', (req, res) => {
94
104
  if (!studioConnected) {
@@ -113,7 +123,23 @@ function startBridgeServer(port = 3055) {
113
123
  res.json(task);
114
124
  }
115
125
  else {
116
- res.json({ id: null });
126
+ // Long-polling: tunggu maksimal 20 detik
127
+ const timeoutId = setTimeout(() => {
128
+ const index = waitingPollers.indexOf(res);
129
+ if (index !== -1) {
130
+ waitingPollers.splice(index, 1);
131
+ res.json({ id: null });
132
+ }
133
+ }, 20000);
134
+ waitingPollers.push(res);
135
+ // Bersihkan jika terputus prematur
136
+ req.on('close', () => {
137
+ clearTimeout(timeoutId);
138
+ const index = waitingPollers.indexOf(res);
139
+ if (index !== -1) {
140
+ waitingPollers.splice(index, 1);
141
+ }
142
+ });
117
143
  }
118
144
  });
119
145
  // Endpoint Studio Response
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pendingTasks = exports.taskQueue = void 0;
3
+ exports.pendingTasks = exports.taskEmitter = exports.taskQueue = void 0;
4
4
  exports.addTaskToQueue = addTaskToQueue;
5
5
  exports.resolvePendingTask = resolvePendingTask;
6
6
  const uuid_1 = require("uuid");
7
+ const events_1 = require("events");
7
8
  // 1. Array untuk menampung antrean tugas (task queue)
8
9
  exports.taskQueue = [];
10
+ exports.taskEmitter = new events_1.EventEmitter();
9
11
  // 2. Map untuk menyimpan Promise dari tugas yang sedang menunggu (pending tasks)
10
12
  exports.pendingTasks = new Map();
11
13
  /**
@@ -24,6 +26,7 @@ function addTaskToQueue(command, target, data) {
24
26
  }, 30000);
25
27
  exports.pendingTasks.set(id, { resolve, reject, timer });
26
28
  exports.taskQueue.push(task);
29
+ exports.taskEmitter.emit('new_task');
27
30
  });
28
31
  }
29
32
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": "dist/index.js",
@@ -10,7 +10,8 @@
10
10
  "studio-plugin"
11
11
  ],
12
12
  "scripts": {
13
- "build": "tsc",
13
+ "build": "tsc && npm run build:plugin",
14
+ "build:plugin": "node --experimental-strip-types src/build-plugin.ts",
14
15
  "start": "node dist/index.js",
15
16
  "dev": "ts-node src/index.ts"
16
17
  },
@@ -128,6 +128,15 @@ local function getInstanceFromPath(pathString)
128
128
 
129
129
  if parts[1] == "game" then table.remove(parts, 1) end
130
130
 
131
+ -- Handle common Services directly to ensure reliability
132
+ pcall(function()
133
+ local svc = game:GetService(parts[1])
134
+ if svc then
135
+ current = svc
136
+ table.remove(parts, 1)
137
+ end
138
+ end)
139
+
131
140
  for _, part in ipairs(parts) do
132
141
  local found = current:FindFirstChild(part)
133
142
  if not found then return nil end
@@ -198,8 +207,6 @@ function startPolling()
198
207
  end)
199
208
 
200
209
  -- PENTING: Cek ulang isConnected SETELAH HTTP request selesai.
201
- -- Jika user menekan Disconnect saat request sedang berjalan,
202
- -- kita TIDAK boleh menimpa status UI "disconnected" yang sudah di-set.
203
210
  if not isConnected then
204
211
  break
205
212
  end
@@ -221,16 +228,20 @@ function startPolling()
221
228
  end)
222
229
  end
223
230
  end
231
+
232
+ -- Fast-polling (long-polling yield di server Node.js)
233
+ -- 50ms untuk safety mencegah Studio Engine freeze jika Node.js membalas instant
234
+ task.wait(0.05)
224
235
  else
225
236
  -- Fetch gagal (Node.js server mati karena IDE/Antigravity/Cursor ditutup)
226
237
  if ui then ui:setStatus("waiting") end
238
+ task.wait(1)
227
239
  end
228
240
 
229
241
  -- Cek lagi sebelum sleep (agar disconnect lebih responsif)
230
242
  if not isConnected then
231
243
  break
232
244
  end
233
- task.wait(1)
234
245
  end
235
246
  isPolling = false
236
247
  end)