@adiholick/nvstudio-mcp 2.1.8 → 2.2.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.
- package/README.md +21 -5
- package/dist/index.js +14 -15
- package/dist/luau-validator.js +26 -57
- package/dist/roblox-bridge.js +66 -52
- 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
|
@@ -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
|
|
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,9 +137,6 @@ 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
|
|
@@ -166,13 +163,15 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
166
163
|
}
|
|
167
164
|
result = bridgeJson.result;
|
|
168
165
|
}
|
|
166
|
+
else {
|
|
167
|
+
throw new Error(`Bridge server returned HTTP ${bridgeRes.status}`);
|
|
168
|
+
}
|
|
169
169
|
}
|
|
170
170
|
catch (err) {
|
|
171
171
|
if (forwardedToBridge) {
|
|
172
172
|
throw err;
|
|
173
173
|
}
|
|
174
|
-
|
|
175
|
-
result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
174
|
+
throw new Error("Gagal menghubungi server Bridge. Pastikan server daemon berjalan di port 3055.");
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
177
|
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
|
@@ -12,6 +12,7 @@ const socket_io_1 = require("socket.io");
|
|
|
12
12
|
const path_1 = __importDefault(require("path"));
|
|
13
13
|
const child_process_1 = require("child_process");
|
|
14
14
|
const task_queue_1 = require("./task-queue");
|
|
15
|
+
const { version: SERVER_VERSION } = require('../package.json');
|
|
15
16
|
exports.isBridgeHosting = false;
|
|
16
17
|
// Global process error handlers agar server bridge tidak mati mendadak
|
|
17
18
|
process.on('uncaughtException', (err) => {
|
|
@@ -27,17 +28,32 @@ let errorCount = 0;
|
|
|
27
28
|
const sessionStart = new Date().toISOString();
|
|
28
29
|
// Per-studio pending events queue: events yang belum diambil oleh Studio
|
|
29
30
|
const studioEventQueues = new Map();
|
|
31
|
+
const studioLastSeen = new Map();
|
|
32
|
+
setInterval(() => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [sid, lastSeen] of studioLastSeen.entries()) {
|
|
35
|
+
if (now - lastSeen > 60000) {
|
|
36
|
+
studioEventQueues.delete(sid);
|
|
37
|
+
studioLastSeen.delete(sid);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, 30000);
|
|
30
41
|
function enqueueForStudio(studioId, event) {
|
|
31
42
|
if (studioId) {
|
|
32
43
|
// Event untuk studio tertentu
|
|
33
44
|
if (!studioEventQueues.has(studioId))
|
|
34
45
|
studioEventQueues.set(studioId, []);
|
|
35
|
-
studioEventQueues.get(studioId)
|
|
46
|
+
const q = studioEventQueues.get(studioId);
|
|
47
|
+
q.push(event);
|
|
48
|
+
if (q.length > 100)
|
|
49
|
+
q.shift();
|
|
36
50
|
}
|
|
37
51
|
else {
|
|
38
52
|
// Broadcast ke semua studio yang terdaftar
|
|
39
|
-
for (const [sid] of studioEventQueues) {
|
|
40
|
-
|
|
53
|
+
for (const [sid, q] of studioEventQueues.entries()) {
|
|
54
|
+
q.push(event);
|
|
55
|
+
if (q.length > 100)
|
|
56
|
+
q.shift();
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
}
|
|
@@ -77,11 +93,9 @@ function broadcastStatus() {
|
|
|
77
93
|
const statusEvent = {
|
|
78
94
|
mcpConnected,
|
|
79
95
|
studioCount: Math.max(ssePeers.size, studioEventQueues.size),
|
|
80
|
-
serverVersion:
|
|
96
|
+
serverVersion: SERVER_VERSION,
|
|
81
97
|
};
|
|
82
98
|
broadcastEvent('status', statusEvent);
|
|
83
|
-
// Juga enqueue untuk Studio yang belum polling saat ini
|
|
84
|
-
enqueueForStudio(null, { kind: 'status', ...statusEvent });
|
|
85
99
|
}
|
|
86
100
|
/**
|
|
87
101
|
* Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
|
|
@@ -95,14 +109,13 @@ function broadcastTask(task) {
|
|
|
95
109
|
data: task.data ?? null,
|
|
96
110
|
remainingMs: 29000,
|
|
97
111
|
};
|
|
98
|
-
broadcastEvent('request', taskEvent);
|
|
99
|
-
// Juga enqueue untuk Studio yang sedang tidak polling
|
|
100
112
|
enqueueForStudio(null, { kind: 'request', ...taskEvent });
|
|
101
113
|
}
|
|
102
114
|
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
115
|
+
let logCounter = 0;
|
|
103
116
|
function addLog(io, type, message) {
|
|
104
117
|
const log = {
|
|
105
|
-
id: Date.now()
|
|
118
|
+
id: `${Date.now()}-${++logCounter}`,
|
|
106
119
|
time: new Date().toLocaleTimeString('id-ID'),
|
|
107
120
|
type,
|
|
108
121
|
message,
|
|
@@ -123,6 +136,9 @@ function killZombieOnPort(port) {
|
|
|
123
136
|
const lines = output.trim().split('\n');
|
|
124
137
|
for (const line of lines) {
|
|
125
138
|
const parts = line.trim().split(/\s+/);
|
|
139
|
+
const localAddress = parts[1];
|
|
140
|
+
if (!localAddress || !localAddress.endsWith(`:${port}`))
|
|
141
|
+
continue;
|
|
126
142
|
const pid = parts[parts.length - 1];
|
|
127
143
|
if (pid && pid !== '0' && pid !== String(process.pid)) {
|
|
128
144
|
try {
|
|
@@ -152,7 +168,7 @@ function killZombieOnPort(port) {
|
|
|
152
168
|
return false;
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
|
-
function startBridgeServer(port =
|
|
171
|
+
function startBridgeServer(port, isDaemon = false) {
|
|
156
172
|
const app = (0, express_1.default)();
|
|
157
173
|
const httpServer = (0, http_1.createServer)(app);
|
|
158
174
|
const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
|
|
@@ -178,10 +194,16 @@ function startBridgeServer(port = 3055) {
|
|
|
178
194
|
});
|
|
179
195
|
});
|
|
180
196
|
// ── /api/ping ─────────────────────────────────────────────────────────────
|
|
181
|
-
// Dipertahankan untuk backward-compat (installer check, health check).
|
|
182
197
|
app.get('/api/ping', (req, res) => {
|
|
198
|
+
const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
|
|
183
199
|
const activeStudios = Math.max(ssePeers.size, studioEventQueues.size);
|
|
184
|
-
res.json({
|
|
200
|
+
res.json({
|
|
201
|
+
status: 'ok',
|
|
202
|
+
version: SERVER_VERSION,
|
|
203
|
+
uptimeSeconds: uptime,
|
|
204
|
+
totalTasksProcessed: totalTasks,
|
|
205
|
+
activeStudios: activeStudios
|
|
206
|
+
});
|
|
185
207
|
});
|
|
186
208
|
// ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
|
|
187
209
|
// Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
|
|
@@ -190,12 +212,13 @@ function startBridgeServer(port = 3055) {
|
|
|
190
212
|
// Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
|
|
191
213
|
app.get('/api/stream', (req, res) => {
|
|
192
214
|
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
215
|
+
studioLastSeen.set(studioId, Date.now());
|
|
193
216
|
// Daftarkan studio jika belum ada
|
|
194
217
|
if (!studioEventQueues.has(studioId)) {
|
|
195
218
|
studioEventQueues.set(studioId, []);
|
|
196
219
|
io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
|
|
197
220
|
addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
|
|
198
|
-
if (!dashboardOpened) {
|
|
221
|
+
if (!isDaemon && !dashboardOpened) {
|
|
199
222
|
dashboardOpened = true;
|
|
200
223
|
const url = `http://localhost:${port}`;
|
|
201
224
|
const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
@@ -210,7 +233,7 @@ function startBridgeServer(port = 3055) {
|
|
|
210
233
|
studioEventQueues.set(studioId, []); // kosongkan antrian
|
|
211
234
|
// Selalu sertakan status terkini
|
|
212
235
|
const events = [
|
|
213
|
-
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion:
|
|
236
|
+
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
|
|
214
237
|
];
|
|
215
238
|
// Tambahkan events tertunda (misalnya task requests)
|
|
216
239
|
for (const ev of queue) {
|
|
@@ -218,20 +241,6 @@ function startBridgeServer(port = 3055) {
|
|
|
218
241
|
if (ev.kind !== 'status')
|
|
219
242
|
events.push(ev);
|
|
220
243
|
}
|
|
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
244
|
// Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
|
|
236
245
|
res.setHeader('Content-Type', 'text/plain');
|
|
237
246
|
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
@@ -248,18 +257,26 @@ function startBridgeServer(port = 3055) {
|
|
|
248
257
|
res.setHeader('Connection', 'keep-alive');
|
|
249
258
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
250
259
|
res.flushHeaders();
|
|
260
|
+
let silenceTimer;
|
|
251
261
|
const heartbeatTimer = setInterval(() => {
|
|
252
262
|
try {
|
|
253
|
-
|
|
263
|
+
const payload = JSON.stringify({
|
|
264
|
+
kind: 'heartbeat',
|
|
265
|
+
timestamp: Date.now(),
|
|
266
|
+
mcpConnected,
|
|
267
|
+
serverVersion: SERVER_VERSION,
|
|
268
|
+
});
|
|
269
|
+
res.write(`data: ${payload}\n\n`);
|
|
270
|
+
silenceTimer.refresh();
|
|
254
271
|
}
|
|
255
272
|
catch {
|
|
256
273
|
cleanup();
|
|
257
274
|
}
|
|
258
275
|
}, 5000);
|
|
259
|
-
|
|
276
|
+
silenceTimer = setTimeout(() => cleanup(), 60000);
|
|
260
277
|
const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
|
|
261
278
|
ssePeers.set(studioId, peer);
|
|
262
|
-
sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion:
|
|
279
|
+
sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION });
|
|
263
280
|
function cleanup() {
|
|
264
281
|
clearInterval(heartbeatTimer);
|
|
265
282
|
clearTimeout(silenceTimer);
|
|
@@ -304,11 +321,10 @@ function startBridgeServer(port = 3055) {
|
|
|
304
321
|
// ── Notifikasi task baru ke Dashboard ────────────────────────────────────
|
|
305
322
|
// Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
|
|
306
323
|
// 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...`);
|
|
324
|
+
task_queue_1.taskEmitter.on('new_task', (task) => {
|
|
325
|
+
totalTasks++;
|
|
326
|
+
addLog(io, 'task', `Task dikirim ke Studio: '${task.command}'${task.target ? ` → ${task.target}` : ''}`);
|
|
327
|
+
broadcastTask(task);
|
|
312
328
|
});
|
|
313
329
|
// ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
|
|
314
330
|
app.post('/api/tasks/enqueue', async (req, res) => {
|
|
@@ -324,32 +340,30 @@ function startBridgeServer(port = 3055) {
|
|
|
324
340
|
// ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
|
|
325
341
|
// Studio lama yang belum update plugin masih bisa bekerja via polling.
|
|
326
342
|
const waitingPollers = [];
|
|
327
|
-
task_queue_1.taskEmitter.on('
|
|
328
|
-
if (
|
|
343
|
+
task_queue_1.taskEmitter.on('new_task', (task) => {
|
|
344
|
+
if (waitingPollers.length > 0) {
|
|
329
345
|
const pollRes = waitingPollers.shift();
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
346
|
+
const index = task_queue_1.taskQueue.findIndex(t => t.id === task.id);
|
|
347
|
+
if (index !== -1)
|
|
348
|
+
task_queue_1.taskQueue.splice(index, 1);
|
|
349
|
+
for (const queue of studioEventQueues.values()) {
|
|
350
|
+
const qIndex = queue.findIndex((e) => e.requestId === task.id);
|
|
351
|
+
if (qIndex !== -1)
|
|
352
|
+
queue.splice(qIndex, 1);
|
|
353
|
+
}
|
|
354
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah '${task.command}' ke Studio.`);
|
|
333
355
|
pollRes?.json(task);
|
|
334
356
|
}
|
|
335
357
|
});
|
|
336
358
|
app.get('/api/tasks', (req, res) => {
|
|
337
359
|
if (task_queue_1.taskQueue.length > 0) {
|
|
338
360
|
const task = task_queue_1.taskQueue.shift();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return
|
|
361
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${task?.command}' ke Studio.`);
|
|
362
|
+
res.json(task);
|
|
363
|
+
return;
|
|
342
364
|
}
|
|
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
365
|
waitingPollers.push(res);
|
|
351
366
|
req.on('close', () => {
|
|
352
|
-
clearTimeout(timeoutId);
|
|
353
367
|
const index = waitingPollers.indexOf(res);
|
|
354
368
|
if (index !== -1)
|
|
355
369
|
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
|
}
|