@adiholick/nvstudio-mcp 2.2.2 → 2.3.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.
@@ -4,7 +4,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.bridgeReadyPromise = exports.isBridgeHosting = void 0;
7
+ exports.removePendingStreamTask = removePendingStreamTask;
8
+ exports.checkPendingTasksOnStudioDisconnect = checkPendingTasksOnStudioDisconnect;
7
9
  exports.isStudioConnected = isStudioConnected;
10
+ exports.getConnectedStudios = getConnectedStudios;
11
+ exports.getConnectedStudioCount = getConnectedStudioCount;
8
12
  exports.setMcpConnected = setMcpConnected;
9
13
  exports.startBridgeServer = startBridgeServer;
10
14
  const express_1 = __importDefault(require("express"));
@@ -13,6 +17,8 @@ const socket_io_1 = require("socket.io");
13
17
  const path_1 = __importDefault(require("path"));
14
18
  const child_process_1 = require("child_process");
15
19
  const task_queue_1 = require("./task-queue");
20
+ const ws_gateway_1 = require("./ws-gateway");
21
+ const roblox_docs_1 = require("./roblox-docs");
16
22
  const { version: SERVER_VERSION } = require('../package.json');
17
23
  exports.isBridgeHosting = false;
18
24
  let resolveBridgeReady;
@@ -34,14 +40,43 @@ const sessionStart = new Date().toISOString();
34
40
  // Per-studio pending events queue: events yang belum diambil oleh Studio
35
41
  const studioEventQueues = new Map();
36
42
  const studioLastSeen = new Map();
43
+ const studioPlaces = new Map();
37
44
  const activeStudioPollers = new Map();
45
+ // Antrean global untuk task request yang menunggu poller long-polling (/api/stream)
46
+ const pendingStreamTasks = [];
47
+ function removePendingStreamTask(taskId) {
48
+ const idx = pendingStreamTasks.findIndex(t => t.requestId === taskId || t.taskId === taskId);
49
+ if (idx !== -1) {
50
+ pendingStreamTasks.splice(idx, 1);
51
+ }
52
+ }
53
+ let disconnectCheckTimer = null;
54
+ function checkPendingTasksOnStudioDisconnect() {
55
+ if (disconnectCheckTimer)
56
+ clearTimeout(disconnectCheckTimer);
57
+ disconnectCheckTimer = setTimeout(() => {
58
+ disconnectCheckTimer = null;
59
+ if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
60
+ pendingStreamTasks.length = 0;
61
+ (0, task_queue_1.rejectAllPendingTasks)('Roblox Studio terputus sebelum merespons tugas.');
62
+ }
63
+ }, 2000);
64
+ }
38
65
  setInterval(() => {
39
66
  const now = Date.now();
40
67
  let changed = false;
41
68
  for (const [sid, lastSeen] of studioLastSeen.entries()) {
42
- if (now - lastSeen > 35000) {
69
+ const isPollerActive = activeStudioPollers.has(sid);
70
+ if (!isPollerActive && now - lastSeen > 10000) {
43
71
  studioEventQueues.delete(sid);
44
72
  studioLastSeen.delete(sid);
73
+ studioPlaces.delete(sid);
74
+ changed = true;
75
+ }
76
+ else if (now - lastSeen > 25000) {
77
+ studioEventQueues.delete(sid);
78
+ studioLastSeen.delete(sid);
79
+ studioPlaces.delete(sid);
45
80
  const poller = activeStudioPollers.get(sid);
46
81
  if (poller) {
47
82
  clearTimeout(poller.timer);
@@ -56,10 +91,84 @@ setInterval(() => {
56
91
  }
57
92
  if (changed) {
58
93
  broadcastStatus();
94
+ if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
95
+ checkPendingTasksOnStudioDisconnect();
96
+ }
97
+ }
98
+ }, 2000);
99
+ function isStudioConnected(filter) {
100
+ if (filter?.studioId) {
101
+ if ((0, ws_gateway_1.hasActiveWsPeer)({ studioId: filter.studioId }))
102
+ return true;
103
+ if (activeStudioPollers.has(filter.studioId))
104
+ return true;
105
+ const seen = studioLastSeen.get(filter.studioId);
106
+ if (seen && Date.now() - seen <= 5000)
107
+ return true;
108
+ return false;
59
109
  }
60
- }, 5000);
61
- function isStudioConnected() {
62
- return studioEventQueues.size > 0 || activeStudioPollers.size > 0;
110
+ if (filter?.placeId !== undefined && filter?.placeId !== null) {
111
+ if ((0, ws_gateway_1.hasActiveWsPeer)({ placeId: filter.placeId }))
112
+ return true;
113
+ for (const [sid, pid] of studioPlaces.entries()) {
114
+ if (pid === filter.placeId) {
115
+ if (activeStudioPollers.has(sid))
116
+ return true;
117
+ const seen = studioLastSeen.get(sid);
118
+ if (seen && Date.now() - seen <= 5000)
119
+ return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+ if ((0, ws_gateway_1.hasActiveWsPeer)())
125
+ return true;
126
+ if (activeStudioPollers.size > 0)
127
+ return true;
128
+ const now = Date.now();
129
+ for (const lastSeen of studioLastSeen.values()) {
130
+ if (now - lastSeen <= 3000)
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+ function getConnectedStudios() {
136
+ const list = [];
137
+ const wsDetails = (0, ws_gateway_1.getWsPeersDetails)();
138
+ const wsStudioIds = new Set();
139
+ for (const p of wsDetails) {
140
+ wsStudioIds.add(p.studioId);
141
+ list.push({
142
+ studioId: p.studioId,
143
+ placeId: p.placeId,
144
+ transport: 'websocket',
145
+ lastSeen: p.lastSeen,
146
+ isAlive: p.isAlive && !p.unresponsive,
147
+ isBusy: p.isBusy,
148
+ });
149
+ }
150
+ const now = Date.now();
151
+ for (const [sid, lastSeen] of studioLastSeen.entries()) {
152
+ if (!wsStudioIds.has(sid) && (activeStudioPollers.has(sid) || now - lastSeen <= 5000)) {
153
+ list.push({
154
+ studioId: sid,
155
+ placeId: studioPlaces.get(sid),
156
+ transport: 'long-polling',
157
+ lastSeen,
158
+ isAlive: true,
159
+ isBusy: false,
160
+ });
161
+ }
162
+ }
163
+ return list;
164
+ }
165
+ function getConnectedStudioCount() {
166
+ const ids = new Set();
167
+ for (const sid of studioEventQueues.keys())
168
+ ids.add(sid);
169
+ for (const sid of (0, ws_gateway_1.getWsStudioIds)())
170
+ ids.add(sid);
171
+ return Math.max(ids.size, isStudioConnected() ? 1 : 0);
63
172
  }
64
173
  function flushEventsToStudio(sid) {
65
174
  const poller = activeStudioPollers.get(sid);
@@ -71,9 +180,10 @@ function flushEventsToStudio(sid) {
71
180
  studioEventQueues.set(sid, []); // kosongkan antrian
72
181
  clearTimeout(poller.timer);
73
182
  activeStudioPollers.delete(sid);
183
+ const nonStatusEvents = queue.filter(ev => ev.kind !== 'status');
74
184
  const events = [
75
185
  { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
76
- ...queue
186
+ ...nonStatusEvents
77
187
  ];
78
188
  try {
79
189
  poller.res.setHeader('Content-Type', 'text/plain');
@@ -98,7 +208,7 @@ function enqueueForStudio(studioId, event) {
98
208
  flushEventsToStudio(studioId);
99
209
  }
100
210
  else {
101
- // Broadcast ke semua studio yang terdaftar
211
+ // Broadcast non-task (status, heartbeat) ke semua studio yang terdaftar
102
212
  for (const [sid, q] of studioEventQueues.entries()) {
103
213
  q.push(event);
104
214
  if (q.length > 100)
@@ -140,26 +250,91 @@ function sendEvent(peer, kind, payload) {
140
250
  * Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
141
251
  */
142
252
  function broadcastStatus() {
253
+ const studioCount = getConnectedStudioCount();
143
254
  const statusEvent = {
144
255
  mcpConnected,
145
- studioCount: studioEventQueues.size,
256
+ studioCount,
146
257
  serverVersion: SERVER_VERSION,
147
258
  };
148
259
  broadcastEvent('status', statusEvent);
260
+ (0, ws_gateway_1.broadcastWsEvent)('status', statusEvent);
149
261
  }
150
262
  /**
151
- * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
152
- * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
263
+ * Push task ke Studio peer (dengan routing studioId/placeId).
264
+ * Mengembalikan true jika berhasil dikirim langsung ke poller aktif, false jika diantrekan (Bug F).
153
265
  */
154
266
  function broadcastTask(task) {
267
+ const totalBudget = task.remainingMs ?? 30000;
268
+ const elapsed = task.createdAt ? (Date.now() - task.createdAt) : 0;
269
+ const remainingMs = Math.max(1000, totalBudget - elapsed);
270
+ // Bug A & E: Stamp attempt counter dan channel pengiriman
271
+ const attempt = (task.attempt || 0) + 1;
272
+ task.attempt = attempt;
273
+ task.dispatchedAt = Date.now();
274
+ task.deliveredVia = 'stream';
155
275
  const taskEvent = {
276
+ kind: 'request',
156
277
  requestId: task.id,
278
+ taskId: task.id,
279
+ attempt,
157
280
  command: task.command,
158
281
  target: task.target,
159
282
  data: task.data ?? null,
160
- remainingMs: 29000,
283
+ studioId: task.studioId,
284
+ placeId: task.placeId,
285
+ remainingMs,
161
286
  };
162
- enqueueForStudio(null, { kind: 'request', ...taskEvent });
287
+ // 1. Coba kirim langsung ke poller aktif yang cocok (hanging GET)
288
+ let matchingSid = null;
289
+ if (task.studioId && activeStudioPollers.has(task.studioId)) {
290
+ matchingSid = task.studioId;
291
+ }
292
+ else if (task.placeId !== undefined && task.placeId !== null) {
293
+ for (const sid of activeStudioPollers.keys()) {
294
+ if (studioPlaces.get(sid) === task.placeId) {
295
+ matchingSid = sid;
296
+ break;
297
+ }
298
+ }
299
+ }
300
+ else if (!task.studioId && task.placeId === undefined && activeStudioPollers.size > 0) {
301
+ let bestSid = null;
302
+ let latestTime = -1;
303
+ for (const sid of activeStudioPollers.keys()) {
304
+ const seen = studioLastSeen.get(sid) ?? 0;
305
+ if (seen > latestTime) {
306
+ latestTime = seen;
307
+ bestSid = sid;
308
+ }
309
+ }
310
+ matchingSid = bestSid || activeStudioPollers.keys().next().value;
311
+ }
312
+ if (matchingSid && activeStudioPollers.has(matchingSid)) {
313
+ const poller = activeStudioPollers.get(matchingSid);
314
+ clearTimeout(poller.timer);
315
+ activeStudioPollers.delete(matchingSid);
316
+ studioLastSeen.set(matchingSid, Date.now());
317
+ const events = [
318
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
319
+ taskEvent
320
+ ];
321
+ try {
322
+ poller.res.setHeader('Content-Type', 'text/plain');
323
+ poller.res.setHeader('Cache-Control', 'no-cache, no-store');
324
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
325
+ poller.res.send(body);
326
+ return true;
327
+ }
328
+ catch {
329
+ // Jika pengiriman langsung gagal, masukkan ke antrean pending
330
+ }
331
+ }
332
+ // 2. Jika tidak ada poller yang sedang hanging (jeda antar-request), masukkan ke antrean
333
+ pendingStreamTasks.push(taskEvent);
334
+ if (pendingStreamTasks.length > 50) {
335
+ pendingStreamTasks.shift();
336
+ }
337
+ return false;
163
338
  }
164
339
  // ── Logging ───────────────────────────────────────────────────────────────────
165
340
  let logCounter = 0;
@@ -173,8 +348,20 @@ function addLog(io, type, message) {
173
348
  activityLogs.unshift(log);
174
349
  if (activityLogs.length > 200)
175
350
  activityLogs.pop();
176
- io.emit('log', log);
177
- io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
351
+ if (io.engine && io.engine.clientsCount > 0) {
352
+ io.emit('log', log);
353
+ io.emit('stats', {
354
+ totalTasks,
355
+ successCount,
356
+ errorCount,
357
+ sessionStart,
358
+ studioConnected: isStudioConnected(),
359
+ studioCount: getConnectedStudioCount(),
360
+ wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
361
+ connectedStudios: getConnectedStudios(),
362
+ version: SERVER_VERSION
363
+ });
364
+ }
178
365
  }
179
366
  /**
180
367
  * Membunuh proses zombie yang masih menyandera port tertentu.
@@ -222,6 +409,62 @@ function startBridgeServer(port, isDaemon = false) {
222
409
  const app = (0, express_1.default)();
223
410
  const httpServer = (0, http_1.createServer)(app);
224
411
  const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
412
+ function handleTaskResponse(taskId, status, result, error) {
413
+ if (status === 'success') {
414
+ successCount++;
415
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
416
+ }
417
+ else {
418
+ errorCount++;
419
+ addLog(io, 'error', `Gagal: ${error}`);
420
+ }
421
+ removePendingStreamTask(taskId);
422
+ (0, ws_gateway_1.cancelTaskDeadline)(taskId);
423
+ (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
424
+ }
425
+ // Pasang WebSocket Gateway untuk duplex communication instan (/ws)
426
+ (0, ws_gateway_1.attachWsGateway)(httpServer, {
427
+ onTaskResponse: (taskId, status, result, error) => {
428
+ handleTaskResponse(taskId, status, result, error);
429
+ },
430
+ onLog: (type, message) => {
431
+ addLog(io, type, message);
432
+ },
433
+ onStudioStatusChange: () => {
434
+ const connected = isStudioConnected();
435
+ const studioCount = getConnectedStudioCount();
436
+ io.emit('studio-status', {
437
+ connected,
438
+ studioCount,
439
+ connectedStudios: getConnectedStudios(),
440
+ version: SERVER_VERSION
441
+ });
442
+ broadcastStatus();
443
+ if (!connected && (0, task_queue_1.hasPendingTasks)()) {
444
+ checkPendingTasksOnStudioDisconnect();
445
+ }
446
+ },
447
+ getServerVersion: () => SERVER_VERSION,
448
+ isMcpConnected: () => mcpConnected,
449
+ onRedispatchTask: (task, failedStudioId) => {
450
+ // Coba re-dispatch ke peer WS lain jika ada yang cocok
451
+ const nextPeer = (0, ws_gateway_1.selectWsPeer)({
452
+ placeId: task.placeId,
453
+ });
454
+ if (nextPeer && nextPeer.studioId !== failedStudioId) {
455
+ const ok = (0, ws_gateway_1.sendTaskViaWs)(task, nextPeer.studioId);
456
+ if (ok)
457
+ return true;
458
+ }
459
+ // Fallback: hanya ke long-polling jika ada poller yang sedang hanging aktif (Bug F)
460
+ if (activeStudioPollers.size > 0) {
461
+ const delivered = broadcastTask(task);
462
+ if (delivered)
463
+ return true;
464
+ }
465
+ return false;
466
+ },
467
+ });
225
468
  app.use(express_1.default.json({ limit: '50mb' }));
226
469
  app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
227
470
  let dashboardOpened = false;
@@ -229,30 +472,64 @@ function startBridgeServer(port, isDaemon = false) {
229
472
  io.on('connection', (socket) => {
230
473
  socket.emit('init', {
231
474
  logs: activityLogs,
232
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: studioEventQueues.size > 0, port },
475
+ stats: {
476
+ totalTasks,
477
+ successCount,
478
+ errorCount,
479
+ sessionStart,
480
+ studioConnected: isStudioConnected(),
481
+ studioCount: getConnectedStudioCount(),
482
+ wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
483
+ port,
484
+ version: SERVER_VERSION,
485
+ connectedStudios: getConnectedStudios(),
486
+ },
233
487
  });
234
488
  });
489
+ // ── /api/docs (Endpoint pencarian dokumentasi engine untuk web dashboard) ──
490
+ app.get('/api/docs', async (req, res) => {
491
+ try {
492
+ const query = String(req.query.query || '').trim();
493
+ const member = req.query.member ? String(req.query.member).trim() : undefined;
494
+ const category = req.query.category ? String(req.query.category).trim() : 'auto';
495
+ if (!query) {
496
+ return res.status(400).json({ error: "Parameter 'query' wajib diisi." });
497
+ }
498
+ const result = await (0, roblox_docs_1.getRobloxDocs)(query, member, category);
499
+ res.json({ success: true, result });
500
+ }
501
+ catch (err) {
502
+ res.status(500).json({ error: err.message });
503
+ }
504
+ });
235
505
  // ── /api/stats ─────────────────────────────────────────────────────────────
236
506
  app.get('/api/stats', (req, res) => {
237
- const activeStudios = studioEventQueues.size;
507
+ const activeStudios = getConnectedStudioCount();
508
+ const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
238
509
  res.json({
239
510
  totalTasks, successCount, errorCount, sessionStart,
240
- studioConnected: activeStudios > 0,
511
+ studioConnected: isStudioConnected(),
241
512
  studioCount: activeStudios,
513
+ wsConnectedCount: wsStudios,
514
+ transport: wsStudios > 0 ? 'websocket' : (activeStudioPollers.size > 0 ? 'long-polling' : 'none'),
242
515
  mcpConnected,
243
516
  port,
517
+ connectedStudios: getConnectedStudios(),
244
518
  });
245
519
  });
246
520
  // ── /api/ping ─────────────────────────────────────────────────────────────
247
521
  app.get('/api/ping', (req, res) => {
248
522
  const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
249
- const activeStudios = studioEventQueues.size;
523
+ const activeStudios = getConnectedStudioCount();
524
+ const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
250
525
  res.json({
251
526
  status: 'ok',
252
527
  version: SERVER_VERSION,
253
528
  uptimeSeconds: uptime,
254
529
  totalTasksProcessed: totalTasks,
255
- activeStudios: activeStudios
530
+ activeStudios: activeStudios,
531
+ wsStudios: wsStudios,
532
+ dualMode: true,
256
533
  });
257
534
  });
258
535
  // ── /api/stream (True Long-Polling endpoint untuk Roblox GetAsync) ─────
@@ -262,12 +539,23 @@ function startBridgeServer(port, isDaemon = false) {
262
539
  // atau timeout 15 detik (heartbeat) untuk menjaga koneksi tetap segar.
263
540
  app.get('/api/stream', (req, res) => {
264
541
  const studioId = req.query.studioId || `anon-${Date.now()}`;
542
+ const placeIdStr = req.query.placeId;
543
+ const placeId = placeIdStr !== undefined && placeIdStr !== '' ? parseInt(placeIdStr, 10) : undefined;
544
+ if (placeId !== undefined && !isNaN(placeId)) {
545
+ studioPlaces.set(studioId, placeId);
546
+ }
265
547
  studioLastSeen.set(studioId, Date.now());
266
548
  // Daftarkan studio jika belum ada
267
549
  if (!studioEventQueues.has(studioId)) {
268
550
  studioEventQueues.set(studioId, []);
269
- io.emit('studio-status', { connected: true, studioCount: Math.max(studioEventQueues.size, 1), studioId });
270
- addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
551
+ io.emit('studio-status', {
552
+ connected: true,
553
+ studioCount: Math.max(studioEventQueues.size, 1),
554
+ studioId,
555
+ connectedStudios: getConnectedStudios(),
556
+ version: SERVER_VERSION
557
+ });
558
+ addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Place: ${placeId ?? 'Local'}, Total: ${studioEventQueues.size})`);
271
559
  if (!isDaemon && !dashboardOpened) {
272
560
  dashboardOpened = true;
273
561
  const url = `http://localhost:${port}`;
@@ -288,29 +576,45 @@ function startBridgeServer(port, isDaemon = false) {
288
576
  catch { }
289
577
  activeStudioPollers.delete(studioId);
290
578
  }
291
- // Kumpulkan semua events yang belum dikirim ke studio ini
579
+ // 1. Prioritas Utama: Cek apakah ada task AI yang cocok sedang menunggu poller Studio ini (Bug #3 Routing)
580
+ const matchIdx = pendingStreamTasks.findIndex((t) => {
581
+ if (t.studioId && t.studioId !== studioId)
582
+ return false;
583
+ if (t.placeId !== undefined && t.placeId !== null && placeId !== undefined && t.placeId !== placeId)
584
+ return false;
585
+ return true;
586
+ });
587
+ if (matchIdx !== -1) {
588
+ const taskEvent = pendingStreamTasks.splice(matchIdx, 1)[0];
589
+ const events = [
590
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
591
+ taskEvent
592
+ ];
593
+ res.setHeader('Content-Type', 'text/plain');
594
+ res.setHeader('Cache-Control', 'no-cache, no-store');
595
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
596
+ return res.send(body);
597
+ }
598
+ // 2. Kumpulkan semua events non-task yang belum dikirim ke studio ini
292
599
  const queue = studioEventQueues.get(studioId) ?? [];
293
600
  if (queue.length > 0) {
294
601
  studioEventQueues.set(studioId, []); // kosongkan antrian
295
602
  const events = [
296
- { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
603
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
604
+ ...queue.filter(ev => ev.kind !== 'status')
297
605
  ];
298
- for (const ev of queue) {
299
- if (ev.kind !== 'status')
300
- events.push(ev);
301
- }
302
606
  res.setHeader('Content-Type', 'text/plain');
303
607
  res.setHeader('Cache-Control', 'no-cache, no-store');
304
608
  const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
305
609
  return res.send(body);
306
610
  }
307
- // Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
611
+ // 3. Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
308
612
  const timer = setTimeout(() => {
309
613
  activeStudioPollers.delete(studioId);
310
614
  try {
311
615
  const events = [
312
616
  { kind: 'heartbeat', timestamp: Date.now() },
313
- { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION }
617
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION }
314
618
  ];
315
619
  res.setHeader('Content-Type', 'text/plain');
316
620
  res.setHeader('Cache-Control', 'no-cache, no-store');
@@ -320,12 +624,21 @@ function startBridgeServer(port, isDaemon = false) {
320
624
  catch { }
321
625
  }, 15000);
322
626
  activeStudioPollers.set(studioId, { res, timer });
323
- req.on('close', () => {
627
+ res.on('close', () => {
324
628
  const current = activeStudioPollers.get(studioId);
325
629
  if (current && current.res === res) {
326
630
  clearTimeout(current.timer);
327
631
  activeStudioPollers.delete(studioId);
328
632
  }
633
+ if (!res.writableEnded) {
634
+ // Client menutup koneksi HTTP sebelum respons dikirim (Studio ditutup/crash)
635
+ studioLastSeen.delete(studioId);
636
+ studioEventQueues.delete(studioId);
637
+ broadcastStatus();
638
+ if ((0, task_queue_1.hasPendingTasks)()) {
639
+ checkPendingTasksOnStudioDisconnect();
640
+ }
641
+ }
329
642
  });
330
643
  });
331
644
  // ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
@@ -365,98 +678,103 @@ function startBridgeServer(port, isDaemon = false) {
365
678
  }
366
679
  catch { /* ignore */ }
367
680
  }
368
- req.on('close', cleanup);
369
- req.on('error', cleanup);
681
+ res.on('close', cleanup);
682
+ res.on('error', cleanup);
370
683
  });
371
- // ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
684
+ // ── /api/response/:taskId (Studio POSTs hasil eksekusi) (Bug H) ─────────
372
685
  app.post('/api/response/:taskId', (req, res) => {
373
- const { taskId } = req.params;
686
+ const taskId = req.params.taskId || req.body?.taskId || req.body?.id;
374
687
  const { status, result, error } = req.body;
375
- if (status === 'success') {
376
- successCount++;
377
- addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
378
- }
379
- else {
380
- errorCount++;
381
- addLog(io, 'error', `Gagal: ${error}`);
382
- }
383
- (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
688
+ handleTaskResponse(taskId, status, result, error);
384
689
  res.json({ ok: true });
385
690
  });
386
- // ── /api/response (legacy — lama, tetap didukung) ────────────────────────
691
+ // ── /api/response (legacy & fallback) (Bug H) ───────────────────────────
387
692
  app.post('/api/response', (req, res) => {
388
- const { id, status, result, error } = req.body;
389
- if (status === 'success') {
390
- successCount++;
391
- addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
392
- }
393
- else {
394
- errorCount++;
395
- addLog(io, 'error', `Gagal: ${error}`);
396
- }
397
- (0, task_queue_1.resolvePendingTask)(id, status, result, error);
693
+ const taskId = req.body?.taskId || req.body?.id || req.query?.taskId;
694
+ const { status, result, error } = req.body;
695
+ handleTaskResponse(taskId, status, result, error);
398
696
  res.json({ ok: true });
399
697
  });
400
- // ── Notifikasi task baru ke Dashboard ────────────────────────────────────
401
- // Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
402
- // Di sini kita hanya log ke dashboard browser.
698
+ // ── Notifikasi task baru ke Dashboard & Dispatch ke Studio ────────────────
403
699
  task_queue_1.taskEmitter.on('new_task', (task) => {
404
700
  totalTasks++;
405
701
  addLog(io, 'task', `Task dikirim ke Studio: '${task.command}'${task.target ? ` → ${task.target}` : ''}`);
702
+ // Prioritas 1: Push via WebSocket instan duplex jika Studio aktif terhubung via WS
703
+ const sentWs = (0, ws_gateway_1.sendTaskViaWs)(task);
704
+ if (sentWs) {
705
+ task.deliveredVia = 'ws';
706
+ return; // Berhasil terkirim instan via WebSocket! Tidak perlu enqueue ke long-polling.
707
+ }
708
+ // Prioritas 2 (Fallback): Enqueue ke Long-Polling /api/stream
406
709
  broadcastTask(task);
407
710
  });
408
711
  // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
409
712
  app.post('/api/tasks/enqueue', async (req, res) => {
410
- const { command, target, data } = req.body;
411
- // Fast-check: pastikan setidaknya 1 Studio aktif polling
412
- if (!isStudioConnected()) {
413
- for (let i = 0; i < 6; i++) {
713
+ const { command, target, data, placeId, studioId, remainingMs } = req.body;
714
+ // Fast-check: pastikan Studio yang cocok aktif polling / websocket (Bug #3)
715
+ if (!isStudioConnected({ placeId, studioId })) {
716
+ for (let i = 0; i < 10; i++) {
414
717
  await new Promise(r => setTimeout(r, 50));
415
- if (isStudioConnected())
718
+ if (isStudioConnected({ placeId, studioId }))
416
719
  break;
417
720
  }
418
- if (!isStudioConnected()) {
721
+ if (!isStudioConnected({ placeId, studioId })) {
419
722
  return res.json({
420
723
  status: 'error',
421
- error: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
724
+ error: placeId !== undefined
725
+ ? `Roblox Studio dengan Place ID ${placeId} tidak terdeteksi aktif.`
726
+ : 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
422
727
  });
423
728
  }
424
729
  }
425
730
  try {
426
- const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
731
+ const result = await (0, task_queue_1.addTaskToQueue)(command, target, data, { placeId, studioId, remainingMs });
427
732
  res.json({ status: 'success', result });
428
733
  }
429
734
  catch (err) {
430
- res.json({ status: 'error', error: err.message });
735
+ res.json({ status: 'error', error: err?.message || String(err) });
431
736
  }
432
737
  });
433
738
  // ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
434
739
  // Studio lama yang belum update plugin masih bisa bekerja via polling.
740
+ // Menggunakan claimTask secara atomik untuk mencegah perebutan task (Bug D & E).
435
741
  const waitingPollers = [];
436
742
  task_queue_1.taskEmitter.on('new_task', (task) => {
743
+ if (task.deliveredVia)
744
+ return; // Abaikan jika task sudah terkirim via WS atau Stream
437
745
  if (waitingPollers.length > 0) {
746
+ const claimed = (0, task_queue_1.claimTask)(task.id, 'legacy');
747
+ if (!claimed)
748
+ return;
438
749
  const pollRes = waitingPollers.shift();
439
750
  const index = task_queue_1.taskQueue.findIndex(t => t.id === task.id);
440
751
  if (index !== -1)
441
752
  task_queue_1.taskQueue.splice(index, 1);
753
+ removePendingStreamTask(task.id);
442
754
  for (const queue of studioEventQueues.values()) {
443
755
  const qIndex = queue.findIndex((e) => e.requestId === task.id);
444
756
  if (qIndex !== -1)
445
757
  queue.splice(qIndex, 1);
446
758
  }
447
759
  addLog(io, 'task', `[Legacy] Mengirim perintah '${task.command}' ke Studio.`);
448
- pollRes?.json(task);
760
+ pollRes?.json(claimed);
449
761
  }
450
762
  });
451
763
  app.get('/api/tasks', (req, res) => {
452
- if (task_queue_1.taskQueue.length > 0) {
453
- const task = task_queue_1.taskQueue.shift();
454
- addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${task?.command}' ke Studio.`);
455
- res.json(task);
456
- return;
764
+ // Cari task pertama yang belum diklaim oleh transport mana pun
765
+ const unclaimedIdx = task_queue_1.taskQueue.findIndex(t => !t.deliveredVia);
766
+ if (unclaimedIdx !== -1) {
767
+ const candidate = task_queue_1.taskQueue[unclaimedIdx];
768
+ const claimed = (0, task_queue_1.claimTask)(candidate.id, 'legacy');
769
+ if (claimed) {
770
+ task_queue_1.taskQueue.splice(unclaimedIdx, 1);
771
+ removePendingStreamTask(claimed.id);
772
+ addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${claimed.command}' ke Studio.`);
773
+ return res.json(claimed);
774
+ }
457
775
  }
458
776
  waitingPollers.push(res);
459
- req.on('close', () => {
777
+ res.on('close', () => {
460
778
  const index = waitingPollers.indexOf(res);
461
779
  if (index !== -1)
462
780
  waitingPollers.splice(index, 1);