@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.
- package/dist/roblox-bridge.js +96 -65
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +307 -317
- package/studio-plugin/nvstudio_mcp.rbxmx +321 -322
- package/studio-plugin/ui_manager.lua +14 -5
package/dist/roblox-bridge.js
CHANGED
|
@@ -18,6 +18,22 @@ let totalTasks = 0;
|
|
|
18
18
|
let successCount = 0;
|
|
19
19
|
let errorCount = 0;
|
|
20
20
|
const sessionStart = new Date().toISOString();
|
|
21
|
+
// Per-studio pending events queue: events yang belum diambil oleh Studio
|
|
22
|
+
const studioEventQueues = new Map();
|
|
23
|
+
function enqueueForStudio(studioId, event) {
|
|
24
|
+
if (studioId) {
|
|
25
|
+
// Event untuk studio tertentu
|
|
26
|
+
if (!studioEventQueues.has(studioId))
|
|
27
|
+
studioEventQueues.set(studioId, []);
|
|
28
|
+
studioEventQueues.get(studioId).push(event);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Broadcast ke semua studio yang terdaftar
|
|
32
|
+
for (const [sid] of studioEventQueues) {
|
|
33
|
+
studioEventQueues.get(sid).push(event);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
const ssePeers = new Map();
|
|
22
38
|
// Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
|
|
23
39
|
let mcpConnected = false;
|
|
@@ -51,24 +67,30 @@ function sendEvent(peer, kind, payload) {
|
|
|
51
67
|
* Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
|
|
52
68
|
*/
|
|
53
69
|
function broadcastStatus() {
|
|
54
|
-
|
|
70
|
+
const statusEvent = {
|
|
55
71
|
mcpConnected,
|
|
56
72
|
studioCount: ssePeers.size,
|
|
57
73
|
serverVersion: '2.1.0',
|
|
58
|
-
}
|
|
74
|
+
};
|
|
75
|
+
broadcastEvent('status', statusEvent);
|
|
76
|
+
// Juga enqueue untuk Studio yang belum polling saat ini
|
|
77
|
+
enqueueForStudio(null, { kind: 'status', ...statusEvent });
|
|
59
78
|
}
|
|
60
79
|
/**
|
|
61
80
|
* Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
|
|
62
81
|
* Hanya satu yang akan memprosesnya (first-come first-served via requestId).
|
|
63
82
|
*/
|
|
64
83
|
function broadcastTask(task) {
|
|
65
|
-
|
|
84
|
+
const taskEvent = {
|
|
66
85
|
requestId: task.id,
|
|
67
86
|
command: task.command,
|
|
68
87
|
target: task.target,
|
|
69
88
|
data: task.data ?? null,
|
|
70
89
|
remainingMs: 29000,
|
|
71
|
-
}
|
|
90
|
+
};
|
|
91
|
+
broadcastEvent('request', taskEvent);
|
|
92
|
+
// Juga enqueue untuk Studio yang sedang tidak polling
|
|
93
|
+
enqueueForStudio(null, { kind: 'request', ...taskEvent });
|
|
72
94
|
}
|
|
73
95
|
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
74
96
|
function addLog(io, type, message) {
|
|
@@ -152,16 +174,74 @@ function startBridgeServer(port = 3055) {
|
|
|
152
174
|
app.get('/api/ping', (req, res) => {
|
|
153
175
|
res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
|
|
154
176
|
});
|
|
155
|
-
// ── /api/stream (
|
|
177
|
+
// ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
|
|
178
|
+
// Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
|
|
179
|
+
// koneksi ditutup. Endpoint ini: daftarkan studio, kirim semua events
|
|
180
|
+
// yang tertunda (status + queued tasks), lalu TUTUP koneksi.
|
|
181
|
+
// Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
|
|
156
182
|
app.get('/api/stream', (req, res) => {
|
|
157
183
|
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
158
|
-
//
|
|
184
|
+
// Daftarkan studio jika belum ada
|
|
185
|
+
if (!studioEventQueues.has(studioId)) {
|
|
186
|
+
studioEventQueues.set(studioId, []);
|
|
187
|
+
io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
|
|
188
|
+
addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
|
|
189
|
+
if (!dashboardOpened) {
|
|
190
|
+
dashboardOpened = true;
|
|
191
|
+
const url = `http://localhost:${port}`;
|
|
192
|
+
const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
193
|
+
const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
|
|
194
|
+
if (!isDaemon) {
|
|
195
|
+
(0, child_process_1.exec)(startCmd, (error) => {
|
|
196
|
+
if (error)
|
|
197
|
+
console.error(`[Bridge] Gagal membuka browser:`, error);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Kumpulkan semua events yang belum dikirim ke studio ini
|
|
203
|
+
const queue = studioEventQueues.get(studioId) ?? [];
|
|
204
|
+
studioEventQueues.set(studioId, []); // kosongkan antrian
|
|
205
|
+
// Selalu sertakan status terkini
|
|
206
|
+
const events = [
|
|
207
|
+
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' },
|
|
208
|
+
];
|
|
209
|
+
// Tambahkan events tertunda (misalnya task requests)
|
|
210
|
+
for (const ev of queue) {
|
|
211
|
+
// Hindari duplikat status
|
|
212
|
+
if (ev.kind !== 'status')
|
|
213
|
+
events.push(ev);
|
|
214
|
+
}
|
|
215
|
+
// Jika ada task di queue global, sertakan juga
|
|
216
|
+
if (task_queue_1.taskQueue.length > 0) {
|
|
217
|
+
const task = task_queue_1.taskQueue.shift();
|
|
218
|
+
totalTasks++;
|
|
219
|
+
addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio (ID: ${studioId.substring(0, 8)}…).`);
|
|
220
|
+
events.push({
|
|
221
|
+
kind: 'request',
|
|
222
|
+
requestId: task.id,
|
|
223
|
+
command: task.command,
|
|
224
|
+
target: task.target,
|
|
225
|
+
data: task.data ?? null,
|
|
226
|
+
remainingMs: 29000,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
|
|
230
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
231
|
+
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
232
|
+
const body = events
|
|
233
|
+
.map(ev => `data: ${JSON.stringify(ev)}`)
|
|
234
|
+
.join('\n');
|
|
235
|
+
res.send(body);
|
|
236
|
+
});
|
|
237
|
+
// ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
|
|
238
|
+
app.get('/api/events', (req, res) => {
|
|
239
|
+
const studioId = req.query.studioId || `browser-${Date.now()}`;
|
|
159
240
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
160
241
|
res.setHeader('Cache-Control', 'no-cache');
|
|
161
242
|
res.setHeader('Connection', 'keep-alive');
|
|
162
|
-
res.setHeader('X-Accel-Buffering', 'no');
|
|
243
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
163
244
|
res.flushHeaders();
|
|
164
|
-
// Heartbeat setiap 5 detik agar koneksi tidak timeout
|
|
165
245
|
const heartbeatTimer = setInterval(() => {
|
|
166
246
|
try {
|
|
167
247
|
res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
|
|
@@ -170,66 +250,21 @@ function startBridgeServer(port = 3055) {
|
|
|
170
250
|
cleanup();
|
|
171
251
|
}
|
|
172
252
|
}, 5000);
|
|
173
|
-
|
|
174
|
-
let silenceTimer = setTimeout(() => cleanup(), 30000);
|
|
175
|
-
const resetSilence = () => {
|
|
176
|
-
clearTimeout(silenceTimer);
|
|
177
|
-
silenceTimer = setTimeout(() => cleanup(), 30000);
|
|
178
|
-
};
|
|
253
|
+
const silenceTimer = setTimeout(() => cleanup(), 60000);
|
|
179
254
|
const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
|
|
180
255
|
ssePeers.set(studioId, peer);
|
|
181
|
-
|
|
182
|
-
sendEvent(peer, 'status', {
|
|
183
|
-
mcpConnected,
|
|
184
|
-
studioCount: ssePeers.size,
|
|
185
|
-
serverVersion: '2.1.0',
|
|
186
|
-
});
|
|
187
|
-
// Jika ada task yang sudah antri sebelum Studio terhubung, kirim sekarang
|
|
188
|
-
if (task_queue_1.taskQueue.length > 0) {
|
|
189
|
-
const task = task_queue_1.taskQueue[0]; // preview saja, jangan shift di sini (broadcastTask akan handle)
|
|
190
|
-
sendEvent(peer, 'request', {
|
|
191
|
-
requestId: task.id,
|
|
192
|
-
command: task.command,
|
|
193
|
-
target: task.target,
|
|
194
|
-
data: task.data ?? null,
|
|
195
|
-
remainingMs: 29000,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
io.emit('studio-status', { connected: true, studioCount: ssePeers.size, studioId });
|
|
199
|
-
addLog(io, 'system', `Roblox Studio terhubung via SSE. (ID: ${studioId.substring(0, 8)}…, Peer aktif: ${ssePeers.size})`);
|
|
200
|
-
if (!dashboardOpened) {
|
|
201
|
-
dashboardOpened = true;
|
|
202
|
-
const url = `http://localhost:${port}`;
|
|
203
|
-
const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
204
|
-
const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
|
|
205
|
-
if (!isDaemon) {
|
|
206
|
-
(0, child_process_1.exec)(startCmd, (error) => {
|
|
207
|
-
if (error)
|
|
208
|
-
console.error(`[Bridge] Gagal membuka browser:`, error);
|
|
209
|
-
});
|
|
210
|
-
console.error(`[Bridge] Studio terhubung pertama kali! Membuka Dashboard...`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
256
|
+
sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' });
|
|
213
257
|
function cleanup() {
|
|
214
258
|
clearInterval(heartbeatTimer);
|
|
215
259
|
clearTimeout(silenceTimer);
|
|
216
260
|
ssePeers.delete(studioId);
|
|
217
|
-
io.emit('studio-status', { connected: ssePeers.size > 0, studioCount: ssePeers.size, studioId });
|
|
218
|
-
addLog(io, 'system', `Roblox Studio terputus. (ID: ${studioId.substring(0, 8)}…, Peer tersisa: ${ssePeers.size})`);
|
|
219
261
|
try {
|
|
220
262
|
res.end();
|
|
221
263
|
}
|
|
222
|
-
catch { /*
|
|
264
|
+
catch { /* ignore */ }
|
|
223
265
|
}
|
|
224
266
|
req.on('close', cleanup);
|
|
225
267
|
req.on('error', cleanup);
|
|
226
|
-
// Semua write berhasil → reset silence timer
|
|
227
|
-
const origWrite = res.write.bind(res);
|
|
228
|
-
res.write = (...args) => {
|
|
229
|
-
const result = origWrite(...args);
|
|
230
|
-
resetSilence();
|
|
231
|
-
return result;
|
|
232
|
-
};
|
|
233
268
|
});
|
|
234
269
|
// ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
|
|
235
270
|
app.post('/api/response/:taskId', (req, res) => {
|
|
@@ -260,18 +295,14 @@ function startBridgeServer(port = 3055) {
|
|
|
260
295
|
(0, task_queue_1.resolvePendingTask)(id, status, result, error);
|
|
261
296
|
res.json({ ok: true });
|
|
262
297
|
});
|
|
263
|
-
// ──
|
|
298
|
+
// ── Notifikasi task baru ke Dashboard ────────────────────────────────────
|
|
299
|
+
// Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
|
|
300
|
+
// Di sini kita hanya log ke dashboard browser.
|
|
264
301
|
task_queue_1.taskEmitter.on('new_task', () => {
|
|
265
302
|
if (task_queue_1.taskQueue.length === 0)
|
|
266
303
|
return;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
const task = task_queue_1.taskQueue.shift();
|
|
272
|
-
totalTasks++;
|
|
273
|
-
addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio${task.target ? ` → ${task.target}` : ''}.`);
|
|
274
|
-
broadcastTask(task);
|
|
304
|
+
const task = task_queue_1.taskQueue[0]; // preview saja, tidak shift
|
|
305
|
+
addLog(io, 'task', `Task baru antri: '${task.command}'${task.target ? ` → ${task.target}` : ''}. Menunggu Studio polling...`);
|
|
275
306
|
});
|
|
276
307
|
// ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
|
|
277
308
|
app.post('/api/tasks/enqueue', async (req, res) => {
|