@adiholick/nvstudio-mcp 2.1.8 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,8 +32,8 @@ curl -fsSL https://raw.githubusercontent.com/Adiholick/nvstudio-mcp/main/install
32
32
  **Apa saja yang dilakukan installer ini secara otomatis?**
33
33
  1. 📦 Mengunduh source code, memasang dependensi Node.js, dan mengkompilasi TypeScript (*Bridge Server*).
34
34
  2. 🧩 **Memasang Plugin Roblox Studio (`nvstudio_mcp.rbxmx`) otomatis** ke folder sistem Roblox Studio Anda (`%LOCALAPPDATA%\Roblox\Plugins` di Windows atau `~/Documents/ROBLOX/Plugins` di macOS).
35
- 3. 🤖 Mengintegrasikan *AI Agent Skills* anti-halusinasi langsung ke **Antigravity IDE** (`~/.gemini/config/plugins/nvstudio-mcp`).
36
- 4. ⚙️ Melakukan auto-konfigurasi jika mendeteksi editor **Cursor** (`.cursor/mcp.json`).
35
+ 3. 🧠 **Universal AI Skill Installer**: Secara cerdas memindai dan mengunduh library skill 29+ *roblox-brain* dan *nvstudio-mcp-guide*, lalu menginjeksinya langsung ke dalam struktur `skills/` dari **AI Agent apapun** yang Anda gunakan (mendukung **Antigravity IDE**, **Cursor**, **Claude Code**, **Codex**, **OpenCode**, dan **Roo Code**).
36
+ 4. ⚙️ Melakukan auto-konfigurasi jika mendeteksi editor **Cursor** (`.cursor/mcp.json`) atau **Antigravity** (`mcp_config.json`).
37
37
 
38
38
  ---
39
39
 
@@ -60,8 +60,7 @@ Jika Anda **tidak** menggunakan skrip installer otomatis di atas (misalnya hanya
60
60
 
61
61
  ## 🔌 Cara Menggunakan (Konfigurasi MCP Klien AI)
62
62
 
63
- Jika Anda menggunakan AI Client lain seperti **Claude Desktop**, **Windsurf**, atau ingin menambahkan konfigurasi secara manual:
64
-
63
+ ### Klien Berbasis NPM (Cursor, Claude Desktop, Windsurf)
65
64
  Tambahkan konfigurasi berikut ke file `mcp.json` Anda:
66
65
  ```json
67
66
  {
@@ -78,13 +77,30 @@ Tambahkan konfigurasi berikut ke file `mcp.json` Anda:
78
77
  ```
79
78
  *(AI Anda akan selalu menjalankan versi terbaru dari server Bridge langsung dari NPM!)*
80
79
 
80
+ ### Klien Berbasis Instalasi Lokal (Antigravity IDE)
81
+ Jika Anda menggunakan instalasi lokal via installer di `%USERPROFILE%\.nvstudio-mcp`, tambahkan ke `~/.gemini/config/mcp_config.json`:
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "nvstudio-mcp": {
86
+ "command": "node",
87
+ "args": [
88
+ "C:/Users/<Username>/.nvstudio-mcp/dist/index.js"
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
81
95
  ---
82
96
 
83
- ## 🖥️ Dashboard Visual & Fitur Pintar v2.0
97
+ ## 🖥️ Dashboard Visual & Fitur Unggulan v2.1
84
98
 
99
+ - **High-Performance Polling Stream & SSE:** Komunikasi antara Roblox Studio dan Bridge Server menggunakan arsitektur snapshot stream sub-detik yang sangat responsif, stabil, dan bebas latensi.
100
+ - **Clean Log Engine:** Log status pada Roblox Studio hanya diperbarui saat ada perubahan status koneksi sebenarnya (bebas dari log spam berulang).
85
101
  - **Visual Dashboard Real-time:** Buka [http://localhost:3055](http://localhost:3055) di browser Anda untuk melihat monitor antrean perintah, log aktivitas AI, status koneksi Studio, dan statistik eksekusi secara visual.
86
- - **Auto-Detect & Auto-Connect:** Plugin Roblox Studio otomatis menyambung ke server saat aktif dan kembali menunggu (*Waiting*) saat server mati tanpa interupsi manual.
87
- - **Anti-Zombie Port Handling:** Tidak perlu khawatir error `EADDRINUSE`. Jika port `3055` tertahan oleh proses zombie akibat restart editor/IDE, server akan otomatis membersihkan port tersebut secara aman.
102
+ - **Auto-Detect & Auto-Connect:** Plugin Roblox Studio otomatis menyambung ke server saat aktif dan kembali menunggu (*Waiting*) saat server mati tanpa perlu campur tangan manual.
103
+ - **Anti-Zombie Port Handling:** Port `3055` dibersihkan secara otomatis jika terjadi penutupan atau restart IDE secara paksa.
88
104
 
89
105
  ---
90
106
 
@@ -110,9 +126,15 @@ Installer `install.sh` sebelumnya mengkloning repositori dan menginstal Node.js
110
126
  rm -rf ~/.nvstudio-mcp
111
127
  ```
112
128
 
113
- ### 4. Hapus Konfigurasi dari Editor AI Anda
129
+ ### 4. Hapus Konfigurasi dan Skills dari Editor AI Anda
114
130
  Jika Anda memasukkan konfigurasi MCP secara otomatis ke Cursor atau secara manual ke Claude Desktop, buka file pengaturan `mcp.json` tersebut dan **hapus blok `"nvstudio-mcp"`** dari dalam objek `mcpServers`.
115
131
 
132
+ **Untuk membersihkan skill bawaan (Universal Skill Installer):**
133
+ Anda bisa menghapus manual symlink/folder shortcut (yang bernama awalan `roblox-`) dari dalam folder konfigurasi agen Anda:
134
+ - **Cursor / Workspace Lokal**: Hapus isi `.cursor/skills` dan `.cursor/roblox-brain.md`
135
+ - **Claude Code**: Hapus dari `~/.claude/skills`
136
+ - **Codex / OpenCode**: Hapus dari direktori lokal atau global (`~/.codex/skills`, `~/.opencode/skills`).
137
+
116
138
  ---
117
139
 
118
140
  **Selamat Menciptakan Game Impian Anda dengan AI! 🎮🤖**
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ const task_queue_1 = require("./task-queue");
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const luau_validator_1 = require("./luau-validator");
15
- const HISTORY_DIR = path_1.default.join(process.cwd(), '.history');
15
+ const HISTORY_DIR = path_1.default.join(__dirname, '..', '.history');
16
16
  if (!fs_1.default.existsSync(HISTORY_DIR)) {
17
17
  fs_1.default.mkdirSync(HISTORY_DIR, { recursive: true });
18
18
  }
@@ -24,11 +24,12 @@ function backupScript(target, code) {
24
24
  }
25
25
  const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
26
26
  // 1. Impor dan jalankan server Express lokal di port 3055
27
- (0, roblox_bridge_1.startBridgeServer)(3055);
27
+ (0, roblox_bridge_1.startBridgeServer)(3055, isDaemon);
28
28
  // 2. Inisialisasi server MCP
29
+ const pkg = require('../package.json');
29
30
  const server = new index_js_1.Server({
30
31
  name: "nvstudio-mcp",
31
- version: "2.1.8",
32
+ version: pkg.version,
32
33
  }, {
33
34
  capabilities: {
34
35
  tools: {},
@@ -40,13 +41,13 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
40
41
  tools: [
41
42
  {
42
43
  name: "call_mcp_tool",
43
- 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.",
44
+ description: "ALAT MUTLAK UNTUK ROBLOX. Command: patch_script_source, get_children, get_full_tree, get_script_source, update_script_source, rollback_script, get_logs, create_instance, search_instance, generate_terrain, insert_asset, delete_instance, get_properties, set_properties, script_grep, execute_luau, search_asset.",
44
45
  inputSchema: {
45
46
  type: "object",
46
47
  properties: {
47
48
  command: {
48
49
  type: "string",
49
- description: "Perintah yang dieksekusi (contoh: 'patch_script_source', 'get_script_source', 'update_script_source').",
50
+ description: "Perintah yang dieksekusi (contoh: 'set_properties', 'get_full_tree', 'patch_script_source').",
50
51
  },
51
52
  target: {
52
53
  type: "string",
@@ -54,10 +55,10 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
54
55
  },
55
56
  data: {
56
57
  type: "string",
57
- description: "Data tambahan (misal payload JSON untuk patch_script_source, atau source code penuh untuk update_script_source).",
58
+ description: "Data tambahan. Untuk set_properties: JSON string dari properti. Untuk delete_instance: kirim 'force' jika descendants > 10. Untuk patch/update: source code.",
58
59
  },
59
60
  },
60
- required: ["command", "target"],
61
+ required: ["command"],
61
62
  },
62
63
  },
63
64
  ],
@@ -70,14 +71,14 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
70
71
  }
71
72
  const args = request.params.arguments;
72
73
  const { command, target, data } = args;
73
- if (!command || !target) {
74
+ if (!command) {
74
75
  return {
75
76
  content: [
76
77
  {
77
78
  type: "text",
78
79
  text: JSON.stringify({
79
80
  status: "error",
80
- error: "Parameter 'command' dan 'target' wajib diisi.",
81
+ error: "Parameter 'command' wajib diisi.",
81
82
  }),
82
83
  },
83
84
  ],
@@ -99,7 +100,6 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
99
100
  isError: true
100
101
  };
101
102
  }
102
- backupScript(target, String(data));
103
103
  }
104
104
  // Intercept command yang dieksekusi di server lokal (Node.js) alih-alih di Studio
105
105
  if (command === "search_asset" && data) {
@@ -137,12 +137,26 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
137
137
  }
138
138
  // 8. Teruskan ke antrean tugas
139
139
  try {
140
- if (command === "update_script_source" && data) {
141
- backupScript(target, String(data));
142
- }
143
140
  let result;
144
141
  if (roblox_bridge_1.isBridgeHosting) {
145
142
  // Instance ini sendiri yang meng-host bridge server
143
+ if (!(0, roblox_bridge_1.isStudioConnected)()) {
144
+ await new Promise(r => setTimeout(r, 2000));
145
+ if (!(0, roblox_bridge_1.isStudioConnected)()) {
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: JSON.stringify({
151
+ status: "error",
152
+ error: "Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.",
153
+ }),
154
+ },
155
+ ],
156
+ isError: true,
157
+ };
158
+ }
159
+ }
146
160
  result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
147
161
  }
148
162
  else {
@@ -166,13 +180,15 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
166
180
  }
167
181
  result = bridgeJson.result;
168
182
  }
183
+ else {
184
+ throw new Error(`Bridge server returned HTTP ${bridgeRes.status}`);
185
+ }
169
186
  }
170
187
  catch (err) {
171
188
  if (forwardedToBridge) {
172
189
  throw err;
173
190
  }
174
- // Fallback in-process jika HTTP gagal dihubungi
175
- result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
191
+ throw new Error("Gagal menghubungi server Bridge. Pastikan server daemon berjalan di port 3055.");
176
192
  }
177
193
  }
178
194
  return {
@@ -6,9 +6,6 @@ function validateLuauSyntax(source) {
6
6
  // Stack for blocks: function, do, if, for, while, repeat
7
7
  // We'll keep it simple: just count block openers vs block closers (end, until)
8
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
9
  // Bracket stacks
13
10
  let parenDepth = 0; // ()
14
11
  let braceDepth = 0; // {}
@@ -84,6 +81,10 @@ function validateLuauSyntax(source) {
84
81
  j = k + 1;
85
82
  continue;
86
83
  }
84
+ // If it ended at end of line, we can't reliably detect it. Let's just avoid bracket counting
85
+ if (line[k] === undefined) {
86
+ break;
87
+ }
87
88
  }
88
89
  }
89
90
  if (inString) {
@@ -116,61 +117,34 @@ function validateLuauSyntax(source) {
116
117
  }
117
118
  // At this point, we are outside strings and comments.
118
119
  // 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 };
120
+ if (!inString && !inMultilineString && !inMultilineComment) {
121
+ if (char === '(')
122
+ parenDepth++;
123
+ else if (char === ')') {
124
+ parenDepth--;
125
+ if (parenDepth < 0)
126
+ return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
127
+ }
128
+ else if (char === '{')
129
+ braceDepth++;
130
+ else if (char === '}') {
131
+ braceDepth--;
132
+ if (braceDepth < 0)
133
+ return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
134
+ }
135
+ else if (char === '[')
136
+ bracketDepth++;
137
+ else if (char === ']') {
138
+ bracketDepth--;
139
+ if (bracketDepth < 0)
140
+ return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
141
+ }
139
142
  }
140
143
  j++;
141
144
  }
142
145
  if (inString) {
143
146
  return { valid: false, error: 'String tidak ditutup (unclosed string literal)', line: i + 1 };
144
147
  }
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
148
  }
175
149
  if (inMultilineString)
176
150
  return { valid: false, error: 'Multiline string tidak ditutup (unclosed [[ ]])' };
@@ -182,10 +156,5 @@ function validateLuauSyntax(source) {
182
156
  return { valid: false, error: 'Kekurangan tutup kurawal "}"' };
183
157
  if (bracketDepth > 0)
184
158
  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
159
  return { valid: true };
191
160
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isBridgeHosting = void 0;
7
+ exports.isStudioConnected = isStudioConnected;
7
8
  exports.setMcpConnected = setMcpConnected;
8
9
  exports.startBridgeServer = startBridgeServer;
9
10
  const express_1 = __importDefault(require("express"));
@@ -12,6 +13,7 @@ const socket_io_1 = require("socket.io");
12
13
  const path_1 = __importDefault(require("path"));
13
14
  const child_process_1 = require("child_process");
14
15
  const task_queue_1 = require("./task-queue");
16
+ const { version: SERVER_VERSION } = require('../package.json');
15
17
  exports.isBridgeHosting = false;
16
18
  // Global process error handlers agar server bridge tidak mati mendadak
17
19
  process.on('uncaughtException', (err) => {
@@ -27,17 +29,40 @@ let errorCount = 0;
27
29
  const sessionStart = new Date().toISOString();
28
30
  // Per-studio pending events queue: events yang belum diambil oleh Studio
29
31
  const studioEventQueues = new Map();
32
+ const studioLastSeen = new Map();
33
+ setInterval(() => {
34
+ const now = Date.now();
35
+ let changed = false;
36
+ for (const [sid, lastSeen] of studioLastSeen.entries()) {
37
+ if (now - lastSeen > 6000) {
38
+ studioEventQueues.delete(sid);
39
+ studioLastSeen.delete(sid);
40
+ changed = true;
41
+ }
42
+ }
43
+ if (changed) {
44
+ broadcastStatus();
45
+ }
46
+ }, 3000);
47
+ function isStudioConnected() {
48
+ return studioEventQueues.size > 0;
49
+ }
30
50
  function enqueueForStudio(studioId, event) {
31
51
  if (studioId) {
32
52
  // Event untuk studio tertentu
33
53
  if (!studioEventQueues.has(studioId))
34
54
  studioEventQueues.set(studioId, []);
35
- studioEventQueues.get(studioId).push(event);
55
+ const q = studioEventQueues.get(studioId);
56
+ q.push(event);
57
+ if (q.length > 100)
58
+ q.shift();
36
59
  }
37
60
  else {
38
61
  // Broadcast ke semua studio yang terdaftar
39
- for (const [sid] of studioEventQueues) {
40
- studioEventQueues.get(sid).push(event);
62
+ for (const [sid, q] of studioEventQueues.entries()) {
63
+ q.push(event);
64
+ if (q.length > 100)
65
+ q.shift();
41
66
  }
42
67
  }
43
68
  }
@@ -76,12 +101,10 @@ function sendEvent(peer, kind, payload) {
76
101
  function broadcastStatus() {
77
102
  const statusEvent = {
78
103
  mcpConnected,
79
- studioCount: Math.max(ssePeers.size, studioEventQueues.size),
80
- serverVersion: '2.1.8',
104
+ studioCount: studioEventQueues.size,
105
+ serverVersion: SERVER_VERSION,
81
106
  };
82
107
  broadcastEvent('status', statusEvent);
83
- // Juga enqueue untuk Studio yang belum polling saat ini
84
- enqueueForStudio(null, { kind: 'status', ...statusEvent });
85
108
  }
86
109
  /**
87
110
  * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
@@ -95,14 +118,13 @@ function broadcastTask(task) {
95
118
  data: task.data ?? null,
96
119
  remainingMs: 29000,
97
120
  };
98
- broadcastEvent('request', taskEvent);
99
- // Juga enqueue untuk Studio yang sedang tidak polling
100
121
  enqueueForStudio(null, { kind: 'request', ...taskEvent });
101
122
  }
102
123
  // ── Logging ───────────────────────────────────────────────────────────────────
124
+ let logCounter = 0;
103
125
  function addLog(io, type, message) {
104
126
  const log = {
105
- id: Date.now().toString(),
127
+ id: `${Date.now()}-${++logCounter}`,
106
128
  time: new Date().toLocaleTimeString('id-ID'),
107
129
  type,
108
130
  message,
@@ -123,6 +145,9 @@ function killZombieOnPort(port) {
123
145
  const lines = output.trim().split('\n');
124
146
  for (const line of lines) {
125
147
  const parts = line.trim().split(/\s+/);
148
+ const localAddress = parts[1];
149
+ if (!localAddress || !localAddress.endsWith(`:${port}`))
150
+ continue;
126
151
  const pid = parts[parts.length - 1];
127
152
  if (pid && pid !== '0' && pid !== String(process.pid)) {
128
153
  try {
@@ -152,7 +177,7 @@ function killZombieOnPort(port) {
152
177
  return false;
153
178
  }
154
179
  }
155
- function startBridgeServer(port = 3055) {
180
+ function startBridgeServer(port, isDaemon = false) {
156
181
  const app = (0, express_1.default)();
157
182
  const httpServer = (0, http_1.createServer)(app);
158
183
  const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
@@ -163,12 +188,12 @@ function startBridgeServer(port = 3055) {
163
188
  io.on('connection', (socket) => {
164
189
  socket.emit('init', {
165
190
  logs: activityLogs,
166
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: Math.max(ssePeers.size, studioEventQueues.size) > 0, port },
191
+ stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: studioEventQueues.size > 0, port },
167
192
  });
168
193
  });
169
194
  // ── /api/stats ─────────────────────────────────────────────────────────────
170
195
  app.get('/api/stats', (req, res) => {
171
- const activeStudios = Math.max(ssePeers.size, studioEventQueues.size);
196
+ const activeStudios = studioEventQueues.size;
172
197
  res.json({
173
198
  totalTasks, successCount, errorCount, sessionStart,
174
199
  studioConnected: activeStudios > 0,
@@ -178,10 +203,16 @@ function startBridgeServer(port = 3055) {
178
203
  });
179
204
  });
180
205
  // ── /api/ping ─────────────────────────────────────────────────────────────
181
- // Dipertahankan untuk backward-compat (installer check, health check).
182
206
  app.get('/api/ping', (req, res) => {
183
- const activeStudios = Math.max(ssePeers.size, studioEventQueues.size);
184
- res.json({ status: 'ok', studioCount: activeStudios, mcpConnected, timestamp: Date.now() });
207
+ const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
208
+ const activeStudios = studioEventQueues.size;
209
+ res.json({
210
+ status: 'ok',
211
+ version: SERVER_VERSION,
212
+ uptimeSeconds: uptime,
213
+ totalTasksProcessed: totalTasks,
214
+ activeStudios: activeStudios
215
+ });
185
216
  });
186
217
  // ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
187
218
  // Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
@@ -190,12 +221,13 @@ function startBridgeServer(port = 3055) {
190
221
  // Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
191
222
  app.get('/api/stream', (req, res) => {
192
223
  const studioId = req.query.studioId || `anon-${Date.now()}`;
224
+ studioLastSeen.set(studioId, Date.now());
193
225
  // Daftarkan studio jika belum ada
194
226
  if (!studioEventQueues.has(studioId)) {
195
227
  studioEventQueues.set(studioId, []);
196
228
  io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
197
229
  addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
198
- if (!dashboardOpened) {
230
+ if (!isDaemon && !dashboardOpened) {
199
231
  dashboardOpened = true;
200
232
  const url = `http://localhost:${port}`;
201
233
  const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
@@ -210,7 +242,7 @@ function startBridgeServer(port = 3055) {
210
242
  studioEventQueues.set(studioId, []); // kosongkan antrian
211
243
  // Selalu sertakan status terkini
212
244
  const events = [
213
- { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.8' },
245
+ { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
214
246
  ];
215
247
  // Tambahkan events tertunda (misalnya task requests)
216
248
  for (const ev of queue) {
@@ -218,20 +250,6 @@ function startBridgeServer(port = 3055) {
218
250
  if (ev.kind !== 'status')
219
251
  events.push(ev);
220
252
  }
221
- // Jika ada task di queue global, sertakan juga
222
- if (task_queue_1.taskQueue.length > 0) {
223
- const task = task_queue_1.taskQueue.shift();
224
- totalTasks++;
225
- addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio (ID: ${studioId.substring(0, 8)}…).`);
226
- events.push({
227
- kind: 'request',
228
- requestId: task.id,
229
- command: task.command,
230
- target: task.target,
231
- data: task.data ?? null,
232
- remainingMs: 29000,
233
- });
234
- }
235
253
  // Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
236
254
  res.setHeader('Content-Type', 'text/plain');
237
255
  res.setHeader('Cache-Control', 'no-cache, no-store');
@@ -248,18 +266,26 @@ function startBridgeServer(port = 3055) {
248
266
  res.setHeader('Connection', 'keep-alive');
249
267
  res.setHeader('X-Accel-Buffering', 'no');
250
268
  res.flushHeaders();
269
+ let silenceTimer;
251
270
  const heartbeatTimer = setInterval(() => {
252
271
  try {
253
- res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
272
+ const payload = JSON.stringify({
273
+ kind: 'heartbeat',
274
+ timestamp: Date.now(),
275
+ mcpConnected,
276
+ serverVersion: SERVER_VERSION,
277
+ });
278
+ res.write(`data: ${payload}\n\n`);
279
+ silenceTimer.refresh();
254
280
  }
255
281
  catch {
256
282
  cleanup();
257
283
  }
258
284
  }, 5000);
259
- const silenceTimer = setTimeout(() => cleanup(), 60000);
285
+ silenceTimer = setTimeout(() => cleanup(), 60000);
260
286
  const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
261
287
  ssePeers.set(studioId, peer);
262
- sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.8' });
288
+ sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION });
263
289
  function cleanup() {
264
290
  clearInterval(heartbeatTimer);
265
291
  clearTimeout(silenceTimer);
@@ -304,15 +330,24 @@ function startBridgeServer(port = 3055) {
304
330
  // ── Notifikasi task baru ke Dashboard ────────────────────────────────────
305
331
  // Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
306
332
  // Di sini kita hanya log ke dashboard browser.
307
- task_queue_1.taskEmitter.on('new_task', () => {
308
- if (task_queue_1.taskQueue.length === 0)
309
- return;
310
- const task = task_queue_1.taskQueue[0]; // preview saja, tidak shift
311
- addLog(io, 'task', `Task baru antri: '${task.command}'${task.target ? ` → ${task.target}` : ''}. Menunggu Studio polling...`);
333
+ task_queue_1.taskEmitter.on('new_task', (task) => {
334
+ totalTasks++;
335
+ addLog(io, 'task', `Task dikirim ke Studio: '${task.command}'${task.target ? ` → ${task.target}` : ''}`);
336
+ broadcastTask(task);
312
337
  });
313
338
  // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
314
339
  app.post('/api/tasks/enqueue', async (req, res) => {
315
340
  const { command, target, data } = req.body;
341
+ // Fast-check: pastikan setidaknya 1 Studio aktif polling
342
+ if (studioEventQueues.size === 0) {
343
+ await new Promise(r => setTimeout(r, 2000));
344
+ if (studioEventQueues.size === 0) {
345
+ return res.json({
346
+ status: 'error',
347
+ error: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
348
+ });
349
+ }
350
+ }
316
351
  try {
317
352
  const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
318
353
  res.json({ status: 'success', result });
@@ -324,32 +359,30 @@ function startBridgeServer(port = 3055) {
324
359
  // ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
325
360
  // Studio lama yang belum update plugin masih bisa bekerja via polling.
326
361
  const waitingPollers = [];
327
- task_queue_1.taskEmitter.on('new_task_legacy', () => {
328
- if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
362
+ task_queue_1.taskEmitter.on('new_task', (task) => {
363
+ if (waitingPollers.length > 0) {
329
364
  const pollRes = waitingPollers.shift();
330
- const task = task_queue_1.taskQueue.shift();
331
- totalTasks++;
332
- addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
365
+ const index = task_queue_1.taskQueue.findIndex(t => t.id === task.id);
366
+ if (index !== -1)
367
+ task_queue_1.taskQueue.splice(index, 1);
368
+ for (const queue of studioEventQueues.values()) {
369
+ const qIndex = queue.findIndex((e) => e.requestId === task.id);
370
+ if (qIndex !== -1)
371
+ queue.splice(qIndex, 1);
372
+ }
373
+ addLog(io, 'task', `[Legacy] Mengirim perintah '${task.command}' ke Studio.`);
333
374
  pollRes?.json(task);
334
375
  }
335
376
  });
336
377
  app.get('/api/tasks', (req, res) => {
337
378
  if (task_queue_1.taskQueue.length > 0) {
338
379
  const task = task_queue_1.taskQueue.shift();
339
- totalTasks++;
340
- addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
341
- return res.json(task);
380
+ addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${task?.command}' ke Studio.`);
381
+ res.json(task);
382
+ return;
342
383
  }
343
- const timeoutId = setTimeout(() => {
344
- const index = waitingPollers.indexOf(res);
345
- if (index !== -1) {
346
- waitingPollers.splice(index, 1);
347
- res.json({ id: null });
348
- }
349
- }, 20000);
350
384
  waitingPollers.push(res);
351
385
  req.on('close', () => {
352
- clearTimeout(timeoutId);
353
386
  const index = waitingPollers.indexOf(res);
354
387
  if (index !== -1)
355
388
  waitingPollers.splice(index, 1);
@@ -26,7 +26,18 @@ function addTaskToQueue(command, target, data) {
26
26
  }, 30000);
27
27
  exports.pendingTasks.set(id, { resolve, reject, timer });
28
28
  exports.taskQueue.push(task);
29
- exports.taskEmitter.emit('new_task');
29
+ if (exports.taskQueue.length > 50) {
30
+ const droppedTask = exports.taskQueue.shift();
31
+ if (droppedTask) {
32
+ const pending = exports.pendingTasks.get(droppedTask.id);
33
+ if (pending) {
34
+ clearTimeout(pending.timer);
35
+ pending.reject(new Error("Timeout: Antrean Studio penuh, task dibuang."));
36
+ exports.pendingTasks.delete(droppedTask.id);
37
+ }
38
+ }
39
+ }
40
+ exports.taskEmitter.emit('new_task', task);
30
41
  });
31
42
  }
32
43
  /**
@@ -39,6 +50,9 @@ function resolvePendingTask(id, status, result, error) {
39
50
  }
40
51
  clearTimeout(pending.timer);
41
52
  exports.pendingTasks.delete(id);
53
+ const index = exports.taskQueue.findIndex(t => t.id === id);
54
+ if (index !== -1)
55
+ exports.taskQueue.splice(index, 1);
42
56
  if (status === "success") {
43
57
  pending.resolve(result);
44
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.1.8",
3
+ "version": "2.2.1",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {