@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.
@@ -0,0 +1,579 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cancelTaskDeadline = cancelTaskDeadline;
4
+ exports.attachWsGateway = attachWsGateway;
5
+ exports.hasActiveWsPeer = hasActiveWsPeer;
6
+ exports.getWsPeerCount = getWsPeerCount;
7
+ exports.getWsStudioIds = getWsStudioIds;
8
+ exports.getWsPeersDetails = getWsPeersDetails;
9
+ exports.selectWsPeer = selectWsPeer;
10
+ exports.sendTaskViaWs = sendTaskViaWs;
11
+ exports.broadcastWsEvent = broadcastWsEvent;
12
+ exports.closeAllWsPeers = closeAllWsPeers;
13
+ const ws_1 = require("ws");
14
+ const task_queue_1 = require("./task-queue");
15
+ const wsPeers = new Map();
16
+ const activeWsTasks = new Map();
17
+ let wssInstance = null;
18
+ let heartbeatInterval = null;
19
+ let callbacks = null;
20
+ /**
21
+ * Batalkan deadline timer server-side untuk taskId tertentu (misal saat response sudah diterima).
22
+ * Validasi attempt dan sender peer untuk mencegah late/zombie response dari attempt lama (Bug A).
23
+ * TIDAK mereset unresponsive secara serampangan (Bug B).
24
+ */
25
+ function cancelTaskDeadline(taskId, attempt, studioId) {
26
+ const active = activeWsTasks.get(taskId);
27
+ if (!active)
28
+ return false;
29
+ // Bug A: Abaikan pembatalan jika berasal dari attempt lama atau peer lama yang berbeda
30
+ if (attempt !== undefined && active.attempt !== attempt) {
31
+ return false;
32
+ }
33
+ if (studioId && active.peer.studioId !== studioId) {
34
+ return false;
35
+ }
36
+ clearTimeout(active.timer);
37
+ activeWsTasks.delete(taskId);
38
+ active.peer.busyTaskIds.delete(taskId);
39
+ // Bug B: Jangan reset active.peer.unresponsive di sini! Unresponsive direset saat fresh pong/reconnect.
40
+ active.peer.lastSeenActivity = Date.now();
41
+ active.peer.lastSeen = Math.max(active.peer.lastSeenPong, active.peer.lastSeenActivity);
42
+ return true;
43
+ }
44
+ /**
45
+ * Tangani expiry deadline task di sisi server (Bug #1).
46
+ */
47
+ function handleTaskDeadlineExpired(taskId) {
48
+ const active = activeWsTasks.get(taskId);
49
+ if (!active)
50
+ return;
51
+ activeWsTasks.delete(taskId);
52
+ const { task, peer, remainingMs } = active;
53
+ peer.busyTaskIds.delete(taskId);
54
+ // 1. Tandai peer tidak responsif dengan masa cooldown 30 detik (Bug B)
55
+ peer.isAlive = false;
56
+ peer.unresponsive = true;
57
+ peer.unresponsiveUntil = Date.now() + 30000;
58
+ if (callbacks) {
59
+ callbacks.onLog('error', `Studio WS peer ${peer.studioId.substring(0, 8)}… deadline (${remainingMs}ms) expired untuk task '${task.command}'! Peer ditandai tidak responsif.`);
60
+ }
61
+ // 2. Coba re-dispatch ke long-poll atau peer lain jika kuota retry masih ada (Bug K)
62
+ let redispatched = false;
63
+ if ((0, task_queue_1.canRetryTask)(task) && callbacks?.onRedispatchTask) {
64
+ task.retryCount = (task.retryCount || 0) + 1;
65
+ redispatched = callbacks.onRedispatchTask(task, peer.studioId);
66
+ }
67
+ if (!redispatched) {
68
+ // Jika tidak bisa di-redispatch, reject task langsung untuk menutup hang 30 detik (Bug F)
69
+ if (callbacks) {
70
+ callbacks.onTaskResponse(task.id, 'error', undefined, `Timeout: Studio tidak merespons dalam deadline server (${remainingMs}ms). Peer tidak responsif dan tidak ada alternatif peer/poller lain.`);
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Inisialisasi WebSocket Gateway pada HTTP server yang ada (port sharing via upgrade /ws).
76
+ */
77
+ function attachWsGateway(httpServer, cb) {
78
+ callbacks = cb;
79
+ const wss = new ws_1.WebSocketServer({ noServer: true });
80
+ wssInstance = wss;
81
+ // Handle HTTP Upgrade event secara selektif hanya untuk path '/ws'
82
+ httpServer.on('upgrade', (req, socket, head) => {
83
+ try {
84
+ const urlObj = new URL(req.url ?? '/', 'http://localhost');
85
+ if (urlObj.pathname === '/ws') {
86
+ wss.handleUpgrade(req, socket, head, (ws) => {
87
+ wss.emit('connection', ws, req);
88
+ });
89
+ }
90
+ }
91
+ catch {
92
+ // Abaikan request URL yang malformed
93
+ }
94
+ });
95
+ wss.on('connection', (socket, req) => {
96
+ let boundStudioId = null;
97
+ socket.on('message', (raw) => {
98
+ let messageStr;
99
+ if (typeof raw === 'string') {
100
+ messageStr = raw;
101
+ }
102
+ else if (Buffer.isBuffer(raw)) {
103
+ messageStr = raw.toString('utf8');
104
+ }
105
+ else if (raw instanceof ArrayBuffer) {
106
+ messageStr = Buffer.from(raw).toString('utf8');
107
+ }
108
+ else if (Array.isArray(raw)) {
109
+ messageStr = Buffer.concat(raw).toString('utf8');
110
+ }
111
+ else {
112
+ return;
113
+ }
114
+ try {
115
+ const msg = JSON.parse(messageStr);
116
+ if (!msg || typeof msg !== 'object')
117
+ return;
118
+ const kind = msg.kind;
119
+ const now = Date.now();
120
+ // ── Register Envelope ──────────────────────────────────────────
121
+ if (kind === 'register') {
122
+ const studioId = typeof msg.studioId === 'string' && msg.studioId ? msg.studioId : `ws-anon-${Date.now()}`;
123
+ boundStudioId = studioId;
124
+ // Bersihkan koneksi lama untuk studioId yang sama jika ada (Bug I)
125
+ const existing = wsPeers.get(studioId);
126
+ if (existing && existing.socket !== socket) {
127
+ // Invalidate in-flight tasks of old peer before closing it! (Bug I)
128
+ for (const [taskId, active] of Array.from(activeWsTasks.entries())) {
129
+ if (active.peer === existing || active.peer.studioId === studioId) {
130
+ clearTimeout(active.timer);
131
+ activeWsTasks.delete(taskId);
132
+ existing.busyTaskIds.delete(taskId);
133
+ let redispatched = false;
134
+ if (callbacks?.onRedispatchTask && (0, task_queue_1.canRetryTask)(active.task)) {
135
+ active.task.retryCount = (active.task.retryCount || 0) + 1;
136
+ redispatched = callbacks.onRedispatchTask(active.task, studioId);
137
+ }
138
+ if (!redispatched && callbacks) {
139
+ callbacks.onTaskResponse(taskId, 'error', undefined, `Roblox Studio terkoneksi ulang saat mengeksekusi tugas '${active.task.command}'.`);
140
+ }
141
+ }
142
+ }
143
+ existing.busyTaskIds.clear();
144
+ try {
145
+ existing.socket.close(1000, 'Replaced by new connection');
146
+ }
147
+ catch { }
148
+ }
149
+ const peer = {
150
+ studioId,
151
+ socket,
152
+ placeId: msg.placeId !== undefined && msg.placeId !== null ? Number(msg.placeId) : undefined,
153
+ pluginsVersion: msg.pluginsVersion ?? 1,
154
+ connectedAt: now,
155
+ lastSeen: now,
156
+ lastSeenPong: now,
157
+ lastSeenActivity: now,
158
+ isAlive: true,
159
+ unresponsive: false,
160
+ busyTaskIds: new Set(),
161
+ };
162
+ wsPeers.set(studioId, peer);
163
+ // Kirim initial status ke Studio
164
+ const statusPayload = {
165
+ kind: 'status',
166
+ mcpConnected: callbacks ? callbacks.isMcpConnected() : false,
167
+ studioCount: wsPeers.size,
168
+ serverVersion: callbacks ? callbacks.getServerVersion() : '2.2.2',
169
+ };
170
+ try {
171
+ socket.send(JSON.stringify(statusPayload));
172
+ }
173
+ catch { }
174
+ if (callbacks) {
175
+ callbacks.onLog('system', `Studio terhubung via WebSocket ⚡ (ID: ${studioId.substring(0, 8)}…, Place: ${msg.placeId ?? 'Local'})`);
176
+ callbacks.onStudioStatusChange();
177
+ }
178
+ return;
179
+ }
180
+ // ── Pong Envelope (Bug #2: Update lastSeenPong) ────────────────
181
+ if (kind === 'pong') {
182
+ if (boundStudioId && wsPeers.has(boundStudioId)) {
183
+ const peer = wsPeers.get(boundStudioId);
184
+ peer.isAlive = true;
185
+ peer.lastSeenPong = now;
186
+ peer.lastSeen = Math.max(peer.lastSeenPong, peer.lastSeenActivity);
187
+ // Pulihkan unresponsive jika cooldown sudah berakhir (Bug B)
188
+ if (peer.unresponsive && (!peer.unresponsiveUntil || now >= peer.unresponsiveUntil)) {
189
+ peer.unresponsive = false;
190
+ delete peer.unresponsiveUntil;
191
+ }
192
+ }
193
+ return;
194
+ }
195
+ // ── Response Envelope (Bug #2: Update lastSeenActivity) ────────
196
+ if (kind === 'response') {
197
+ const taskId = msg.taskId || msg.id;
198
+ const attempt = typeof msg.attempt === 'number' ? msg.attempt : undefined;
199
+ const status = msg.status === 'success' ? 'success' : 'error';
200
+ const result = msg.result;
201
+ const error = msg.error;
202
+ if (taskId) {
203
+ // Bug A: Verifikasi attempt dan studioId untuk mencegah zombie response
204
+ const active = activeWsTasks.get(taskId);
205
+ if (active) {
206
+ if (attempt !== undefined && active.attempt !== attempt) {
207
+ // Response untuk attempt lama, abaikan!
208
+ return;
209
+ }
210
+ if (boundStudioId && active.peer.studioId !== boundStudioId) {
211
+ // Response dari peer lama yang berbeda, abaikan!
212
+ return;
213
+ }
214
+ }
215
+ if (boundStudioId && wsPeers.has(boundStudioId)) {
216
+ const peer = wsPeers.get(boundStudioId);
217
+ peer.isAlive = true;
218
+ peer.lastSeenActivity = now;
219
+ peer.lastSeen = Math.max(peer.lastSeenPong, peer.lastSeenActivity);
220
+ peer.busyTaskIds.delete(taskId);
221
+ }
222
+ const cancelled = cancelTaskDeadline(taskId, attempt, boundStudioId ?? undefined);
223
+ if (cancelled || !active) {
224
+ if (callbacks) {
225
+ callbacks.onTaskResponse(taskId, status, result, error);
226
+ }
227
+ }
228
+ }
229
+ return;
230
+ }
231
+ }
232
+ catch (err) {
233
+ console.error('[WsGateway] Gagal memproses pesan WebSocket:', err);
234
+ }
235
+ });
236
+ socket.on('pong', () => {
237
+ if (boundStudioId && wsPeers.has(boundStudioId)) {
238
+ const peer = wsPeers.get(boundStudioId);
239
+ const now = Date.now();
240
+ peer.isAlive = true;
241
+ peer.lastSeenPong = now;
242
+ peer.lastSeen = Math.max(peer.lastSeenPong, peer.lastSeenActivity);
243
+ if (peer.unresponsive && (!peer.unresponsiveUntil || now >= peer.unresponsiveUntil)) {
244
+ peer.unresponsive = false;
245
+ delete peer.unresponsiveUntil;
246
+ }
247
+ }
248
+ });
249
+ const cleanup = () => {
250
+ if (boundStudioId && wsPeers.has(boundStudioId)) {
251
+ const existing = wsPeers.get(boundStudioId);
252
+ if (existing && (existing.socket === socket || existing.socket.readyState !== ws_1.WebSocket.OPEN)) {
253
+ wsPeers.delete(boundStudioId);
254
+ existing.busyTaskIds.clear();
255
+ if (callbacks) {
256
+ callbacks.onLog('system', `Studio WebSocket terputus (ID: ${boundStudioId.substring(0, 8)}…)`);
257
+ callbacks.onStudioStatusChange();
258
+ }
259
+ }
260
+ }
261
+ // Tangani task aktif pada peer/socket yang putus (Bug I, K, N)
262
+ for (const [taskId, active] of Array.from(activeWsTasks.entries())) {
263
+ if (active.peer.socket === socket || (boundStudioId && active.peer.studioId === boundStudioId)) {
264
+ clearTimeout(active.timer);
265
+ activeWsTasks.delete(taskId);
266
+ active.peer.busyTaskIds.delete(taskId);
267
+ let redispatched = false;
268
+ if (callbacks?.onRedispatchTask && (0, task_queue_1.canRetryTask)(active.task)) {
269
+ active.task.retryCount = (active.task.retryCount || 0) + 1;
270
+ redispatched = callbacks.onRedispatchTask(active.task, boundStudioId || active.peer.studioId);
271
+ }
272
+ if (!redispatched && callbacks) {
273
+ callbacks.onTaskResponse(taskId, 'error', undefined, `Roblox Studio terputus saat mengeksekusi tugas '${active.task.command}'.`);
274
+ }
275
+ }
276
+ }
277
+ };
278
+ socket.on('close', cleanup);
279
+ socket.on('error', cleanup);
280
+ });
281
+ // ── Heartbeat Interval (5 detik) (Bug C Fix) ───────────────────────────────
282
+ if (heartbeatInterval)
283
+ clearInterval(heartbeatInterval);
284
+ heartbeatInterval = setInterval(() => {
285
+ const now = Date.now();
286
+ for (const [studioId, peer] of Array.from(wsPeers.entries())) {
287
+ if (peer.socket.readyState !== ws_1.WebSocket.OPEN) {
288
+ try {
289
+ peer.socket.terminate();
290
+ }
291
+ catch { }
292
+ peer.busyTaskIds.clear();
293
+ wsPeers.delete(studioId);
294
+ if (callbacks)
295
+ callbacks.onStudioStatusChange();
296
+ continue;
297
+ }
298
+ const isBusy = peer.busyTaskIds.size > 0;
299
+ // BUG C: Jika peer sedang sibuk mengeksekusi task, periksa apakah hang total (>35s tanpa respons/pong)
300
+ if (isBusy) {
301
+ const timeSinceActivity = now - Math.max(peer.lastSeenPong, peer.lastSeenActivity);
302
+ if (timeSinceActivity > 35000) {
303
+ try {
304
+ peer.socket.terminate();
305
+ }
306
+ catch { }
307
+ peer.busyTaskIds.clear();
308
+ wsPeers.delete(studioId);
309
+ for (const [taskId, active] of Array.from(activeWsTasks.entries())) {
310
+ if (active.peer === peer) {
311
+ clearTimeout(active.timer);
312
+ activeWsTasks.delete(taskId);
313
+ let redispatched = false;
314
+ if (callbacks?.onRedispatchTask && (0, task_queue_1.canRetryTask)(active.task)) {
315
+ active.task.retryCount = (active.task.retryCount || 0) + 1;
316
+ redispatched = callbacks.onRedispatchTask(active.task, studioId);
317
+ }
318
+ if (!redispatched && callbacks) {
319
+ callbacks.onTaskResponse(taskId, 'error', undefined, `Studio WebSocket hung (>35s tanpa respons) saat menjalankan task '${active.task.command}'.`);
320
+ }
321
+ }
322
+ }
323
+ if (callbacks) {
324
+ callbacks.onLog('error', `Studio WebSocket ${studioId.substring(0, 8)}… hung (>35s), koneksi diputus.`);
325
+ callbacks.onStudioStatusChange();
326
+ }
327
+ continue;
328
+ }
329
+ peer.isAlive = true;
330
+ try {
331
+ peer.socket.ping();
332
+ }
333
+ catch {
334
+ try {
335
+ peer.socket.terminate();
336
+ }
337
+ catch { }
338
+ peer.busyTaskIds.clear();
339
+ wsPeers.delete(studioId);
340
+ if (callbacks)
341
+ callbacks.onStudioStatusChange();
342
+ }
343
+ continue;
344
+ }
345
+ // Peer sedang IDLE: periksa keterlambatan pong secara terpisah
346
+ const pongDelay = now - peer.lastSeenPong;
347
+ if (!peer.isAlive && pongDelay > 15000) {
348
+ // Tidak menjawab ping dalam toleransi waktu saat idle
349
+ try {
350
+ peer.socket.terminate();
351
+ }
352
+ catch { }
353
+ peer.busyTaskIds.clear();
354
+ wsPeers.delete(studioId);
355
+ if (callbacks) {
356
+ callbacks.onLog('system', `Studio WebSocket timeout (ID: ${studioId.substring(0, 8)}…)`);
357
+ callbacks.onStudioStatusChange();
358
+ }
359
+ continue;
360
+ }
361
+ // Tandai isAlive = false dan kirim ping
362
+ peer.isAlive = false;
363
+ try {
364
+ peer.socket.ping();
365
+ peer.socket.send(JSON.stringify({ kind: 'ping', ts: now }));
366
+ }
367
+ catch {
368
+ try {
369
+ peer.socket.terminate();
370
+ }
371
+ catch { }
372
+ peer.busyTaskIds.clear();
373
+ wsPeers.delete(studioId);
374
+ if (callbacks)
375
+ callbacks.onStudioStatusChange();
376
+ }
377
+ }
378
+ }, 5000);
379
+ return wss;
380
+ }
381
+ /**
382
+ * Cek apakah ada setidaknya satu peer Studio aktif terhubung via WebSocket.
383
+ */
384
+ function hasActiveWsPeer(filter) {
385
+ for (const peer of wsPeers.values()) {
386
+ if (peer.socket.readyState === ws_1.WebSocket.OPEN && !peer.unresponsive) {
387
+ if (filter?.studioId && peer.studioId !== filter.studioId)
388
+ continue;
389
+ if (filter?.placeId !== undefined && filter?.placeId !== null && peer.placeId !== filter.placeId)
390
+ continue;
391
+ return true;
392
+ }
393
+ }
394
+ return false;
395
+ }
396
+ /**
397
+ * Jumlah studio aktif terhubung via WebSocket.
398
+ */
399
+ function getWsPeerCount() {
400
+ let count = 0;
401
+ for (const peer of wsPeers.values()) {
402
+ if (peer.socket.readyState === ws_1.WebSocket.OPEN) {
403
+ count++;
404
+ }
405
+ }
406
+ return count;
407
+ }
408
+ /**
409
+ * Dapatkan daftar studioId aktif terhubung via WebSocket.
410
+ */
411
+ function getWsStudioIds() {
412
+ const list = [];
413
+ for (const [sid, peer] of wsPeers.entries()) {
414
+ if (peer.socket.readyState === ws_1.WebSocket.OPEN) {
415
+ list.push(sid);
416
+ }
417
+ }
418
+ return list;
419
+ }
420
+ /**
421
+ * Dapatkan detail seluruh WS peer untuk reporting/discovery.
422
+ */
423
+ function getWsPeersDetails() {
424
+ return Array.from(wsPeers.values()).map(p => ({
425
+ studioId: p.studioId,
426
+ placeId: p.placeId,
427
+ connectedAt: p.connectedAt,
428
+ lastSeen: p.lastSeen,
429
+ lastSeenPong: p.lastSeenPong,
430
+ lastSeenActivity: p.lastSeenActivity,
431
+ isAlive: p.isAlive,
432
+ isBusy: p.busyTaskIds.size > 0,
433
+ unresponsive: !!p.unresponsive,
434
+ }));
435
+ }
436
+ /**
437
+ * Pilih peer WebSocket berdasarkan kriteria studioId / placeId (Bug #3 Routing & Bug L Load Balancing).
438
+ */
439
+ function selectWsPeer(criteria) {
440
+ if (wsPeers.size === 0)
441
+ return null;
442
+ const now = Date.now();
443
+ const isPeerEligible = (p) => {
444
+ if (p.socket.readyState !== ws_1.WebSocket.OPEN)
445
+ return false;
446
+ if (p.unresponsive) {
447
+ // Bug B: Pulihkan jika masa cooldown sudah berakhir
448
+ if (p.unresponsiveUntil && now >= p.unresponsiveUntil) {
449
+ p.unresponsive = false;
450
+ delete p.unresponsiveUntil;
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+ return true;
456
+ };
457
+ // 1. Jika studioId spesifik diminta
458
+ if (criteria?.studioId) {
459
+ const peer = wsPeers.get(criteria.studioId);
460
+ if (peer && isPeerEligible(peer)) {
461
+ return peer;
462
+ }
463
+ return null;
464
+ }
465
+ // 2. Jika placeId spesifik diminta
466
+ let candidates = [];
467
+ if (criteria?.placeId !== undefined && criteria?.placeId !== null) {
468
+ candidates = Array.from(wsPeers.values()).filter(p => isPeerEligible(p) && p.placeId === criteria.placeId);
469
+ }
470
+ else {
471
+ candidates = Array.from(wsPeers.values()).filter(isPeerEligible);
472
+ }
473
+ if (candidates.length === 0)
474
+ return null;
475
+ // Bug L: Prioritaskan peer yang idle (busyTaskIds.size === 0) dan least busy
476
+ candidates.sort((a, b) => {
477
+ if (a.busyTaskIds.size !== b.busyTaskIds.size) {
478
+ return a.busyTaskIds.size - b.busyTaskIds.size;
479
+ }
480
+ return b.lastSeen - a.lastSeen;
481
+ });
482
+ return candidates[0];
483
+ }
484
+ /**
485
+ * Mengirim task ke Studio via WebSocket dengan server-side deadline enforcement (Bug #1),
486
+ * routing berbasis target/place (Bug #3), attempt stamping (Bug A), dan load checking (Bug L & M).
487
+ */
488
+ function sendTaskViaWs(task, targetStudioId) {
489
+ if (wsPeers.size === 0)
490
+ return false;
491
+ const targetPeer = selectWsPeer({
492
+ studioId: targetStudioId || task.studioId,
493
+ placeId: task.placeId,
494
+ });
495
+ if (!targetPeer || targetPeer.socket.readyState !== ws_1.WebSocket.OPEN)
496
+ return false;
497
+ // Bug J: Hitung sisa waktu aktual (remainingMs) dari budget total dikurangi waktu tunggu
498
+ const totalBudget = task.remainingMs ?? 30000;
499
+ const elapsed = task.createdAt ? (Date.now() - task.createdAt) : 0;
500
+ const remainingMs = Math.max(1000, totalBudget - elapsed);
501
+ // Bug A & E: Stamp attempt counter dan tandai channel pengiriman
502
+ const attempt = (task.attempt || 0) + 1;
503
+ task.attempt = attempt;
504
+ task.dispatchedAt = Date.now();
505
+ task.deliveredVia = 'ws';
506
+ const envelope = {
507
+ kind: 'request',
508
+ requestId: task.id,
509
+ taskId: task.id,
510
+ attempt,
511
+ command: task.command,
512
+ target: task.target,
513
+ data: task.data ?? null,
514
+ remainingMs,
515
+ };
516
+ // Bug M: Pastikan socket masih OPEN dan tangani error saat pengiriman
517
+ try {
518
+ if (targetPeer.socket.readyState !== ws_1.WebSocket.OPEN)
519
+ return false;
520
+ targetPeer.socket.send(JSON.stringify(envelope));
521
+ }
522
+ catch {
523
+ return false;
524
+ }
525
+ // Enforce deadline server-side (Bug #1)
526
+ targetPeer.busyTaskIds.add(task.id);
527
+ targetPeer.lastSeenActivity = Date.now();
528
+ targetPeer.lastSeen = Math.max(targetPeer.lastSeenPong, targetPeer.lastSeenActivity);
529
+ const taskTimer = setTimeout(() => {
530
+ handleTaskDeadlineExpired(task.id);
531
+ }, remainingMs);
532
+ activeWsTasks.set(task.id, {
533
+ task,
534
+ peer: targetPeer,
535
+ timer: taskTimer,
536
+ remainingMs,
537
+ dispatchedAt: task.dispatchedAt,
538
+ attempt,
539
+ });
540
+ return true;
541
+ }
542
+ /**
543
+ * Broadcast event ke semua Studio yang terhubung via WebSocket.
544
+ */
545
+ function broadcastWsEvent(kind, payload) {
546
+ if (wsPeers.size === 0)
547
+ return;
548
+ const body = JSON.stringify({ kind, ...payload });
549
+ for (const peer of wsPeers.values()) {
550
+ if (peer.socket.readyState === ws_1.WebSocket.OPEN) {
551
+ try {
552
+ peer.socket.send(body);
553
+ }
554
+ catch { }
555
+ }
556
+ }
557
+ }
558
+ /**
559
+ * Menutup semua koneksi WebSocket dan membersihkan state (Bug N).
560
+ */
561
+ function closeAllWsPeers() {
562
+ if (heartbeatInterval) {
563
+ clearInterval(heartbeatInterval);
564
+ heartbeatInterval = null;
565
+ }
566
+ for (const active of activeWsTasks.values()) {
567
+ clearTimeout(active.timer);
568
+ }
569
+ activeWsTasks.clear();
570
+ for (const peer of wsPeers.values()) {
571
+ peer.busyTaskIds.clear();
572
+ peer.isAlive = false;
573
+ try {
574
+ peer.socket.close(1000, 'Server stopping');
575
+ }
576
+ catch { }
577
+ }
578
+ wsPeers.clear();
579
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "dist",
11
11
  "public",
12
- "studio-plugin"
12
+ "studio-plugin",
13
+ "agent-plugin"
13
14
  ],
14
15
  "scripts": {
15
16
  "build": "tsc && npm run build:plugin",
@@ -28,13 +29,16 @@
28
29
  "@modelcontextprotocol/sdk": "^1.30.0",
29
30
  "express": "^5.2.1",
30
31
  "socket.io": "^4.8.3",
31
- "uuid": "^14.0.2"
32
+ "uuid": "^14.0.2",
33
+ "ws": "^8.22.0",
34
+ "yaml": "^2.9.1"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@types/express": "^5.0.6",
35
38
  "@types/node": "^26.4.1",
36
39
  "@types/socket.io": "^3.0.1",
37
40
  "@types/uuid": "^10.0.0",
41
+ "@types/ws": "^8.18.2",
38
42
  "ts-node": "^10.9.2",
39
43
  "typescript": "^7.0.2"
40
44
  }