@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 +30 -8
- package/dist/index.js +31 -15
- package/dist/luau-validator.js +26 -57
- package/dist/roblox-bridge.js +89 -56
- package/dist/task-queue.js +15 -1
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +50 -22
- package/studio-plugin/create_instance.lua +20 -10
- package/studio-plugin/delete_instance.lua +9 -0
- package/studio-plugin/execute_luau.lua +25 -6
- package/studio-plugin/generate_terrain.lua +2 -1
- package/studio-plugin/get_children.lua +7 -1
- package/studio-plugin/get_full_tree.lua +46 -0
- package/studio-plugin/insert_asset.lua +8 -3
- package/studio-plugin/nvstudio_mcp.rbxmx +356 -69
- package/studio-plugin/patch_script_source.lua +24 -4
- package/studio-plugin/rollback_script.lua +9 -3
- package/studio-plugin/script_grep.lua +24 -9
- package/studio-plugin/search_instance.lua +24 -5
- package/studio-plugin/set_properties.lua +73 -0
- package/studio-plugin/ui_manager.lua +4 -4
- package/studio-plugin/update_script_source.lua +5 -1
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
87
|
-
- **Anti-Zombie Port Handling:**
|
|
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(
|
|
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:
|
|
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: '
|
|
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
|
|
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"
|
|
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
|
|
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'
|
|
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
|
-
|
|
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 {
|
package/dist/luau-validator.js
CHANGED
|
@@ -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 (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
}
|
package/dist/roblox-bridge.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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:
|
|
80
|
-
serverVersion:
|
|
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()
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
184
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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('
|
|
328
|
-
if (
|
|
362
|
+
task_queue_1.taskEmitter.on('new_task', (task) => {
|
|
363
|
+
if (waitingPollers.length > 0) {
|
|
329
364
|
const pollRes = waitingPollers.shift();
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
return
|
|
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);
|
package/dist/task-queue.js
CHANGED
|
@@ -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.
|
|
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
|
}
|