dominus-cli 0.5.8 → 0.6.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/dist/mcp.js CHANGED
@@ -68,7 +68,12 @@ function serializeMessage(msg) {
68
68
  // src/transport/ws-server.ts
69
69
  var DominusServer = class extends EventEmitter {
70
70
  wss = null;
71
- studioClient = null;
71
+ /** All connected Studio instances, keyed by placeId (0 for unknown) */
72
+ studioClients = /* @__PURE__ */ new Map();
73
+ /** Reverse lookup: ws → placeId */
74
+ wsToPlaceId = /* @__PURE__ */ new Map();
75
+ /** Which placeId to target. 0 = auto-select if only one is connected. */
76
+ activePlaceId = 0;
72
77
  controllers = /* @__PURE__ */ new Set();
73
78
  pendingRequests = /* @__PURE__ */ new Map();
74
79
  relayRequests = /* @__PURE__ */ new Map();
@@ -117,7 +122,9 @@ var DominusServer = class extends EventEmitter {
117
122
  this.addController(ws);
118
123
  ws.send(serializeMessage(createMessage("controller:welcome", {
119
124
  studioConnected: this.isConnected(),
120
- port: this.port
125
+ port: this.port,
126
+ connections: this.listConnections(),
127
+ activePlaceId: this.activePlaceId
121
128
  })));
122
129
  return;
123
130
  }
@@ -125,7 +132,7 @@ var DominusServer = class extends EventEmitter {
125
132
  if (this.controllers.has(ws)) {
126
133
  this.handleControllerMessage(ws, msg);
127
134
  } else {
128
- this.handleStudioMessage(msg);
135
+ this.handleStudioMessage(ws, msg);
129
136
  }
130
137
  } catch (err) {
131
138
  this.emit("server:error", err);
@@ -137,14 +144,22 @@ var DominusServer = class extends EventEmitter {
137
144
  for (const [id, controllerWs] of this.relayRequests) {
138
145
  if (controllerWs === ws) this.relayRequests.delete(id);
139
146
  }
140
- } else if (this.studioClient?.ws === ws) {
141
- this.studioClient = null;
142
- this.emit("studio:disconnected");
143
- this.broadcastToControllers("studio:disconnected", {});
144
- for (const [id, pending] of this.pendingRequests) {
145
- clearTimeout(pending.timer);
146
- pending.reject(new Error("Studio disconnected"));
147
- this.pendingRequests.delete(id);
147
+ } else {
148
+ const placeId = this.wsToPlaceId.get(ws);
149
+ if (placeId !== void 0) {
150
+ const conn = this.studioClients.get(placeId);
151
+ this.studioClients.delete(placeId);
152
+ this.wsToPlaceId.delete(ws);
153
+ this.emit("studio:disconnected", { placeId, placeName: conn?.placeName });
154
+ this.broadcastToControllers("studio:disconnected", { placeId, placeName: conn?.placeName });
155
+ for (const [id, pending] of this.pendingRequests) {
156
+ clearTimeout(pending.timer);
157
+ pending.reject(new Error(`Studio disconnected (place ${placeId})`));
158
+ this.pendingRequests.delete(id);
159
+ }
160
+ if (this.activePlaceId === placeId) {
161
+ this.activePlaceId = 0;
162
+ }
148
163
  }
149
164
  }
150
165
  });
@@ -153,19 +168,24 @@ var DominusServer = class extends EventEmitter {
153
168
  });
154
169
  }
155
170
  setStudioClient(ws, msg) {
156
- if (this.studioClient) {
157
- this.studioClient.ws.close();
158
- }
159
171
  const payload = msg.payload;
160
- this.studioClient = {
172
+ const placeId = payload.placeId ?? 0;
173
+ const existing = this.studioClients.get(placeId);
174
+ if (existing && existing.ws !== ws) {
175
+ existing.ws.close();
176
+ this.wsToPlaceId.delete(existing.ws);
177
+ }
178
+ const conn = {
161
179
  ws,
162
180
  connectedAt: Date.now(),
163
181
  studioVersion: payload.studioVersion,
164
- placeId: payload.placeId,
182
+ placeId,
165
183
  placeName: payload.placeName
166
184
  };
167
- this.emit("studio:connected", payload);
168
- this.broadcastToControllers("studio:connected", payload);
185
+ this.studioClients.set(placeId, conn);
186
+ this.wsToPlaceId.set(ws, placeId);
187
+ this.emit("studio:connected", { ...payload, placeId });
188
+ this.broadcastToControllers("studio:connected", { ...payload, placeId });
169
189
  }
170
190
  addController(ws) {
171
191
  this.controllers.add(ws);
@@ -180,25 +200,30 @@ var DominusServer = class extends EventEmitter {
180
200
  }
181
201
  }
182
202
  handleControllerMessage(controllerWs, msg) {
183
- if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
203
+ const target = this.resolveTarget();
204
+ if (!target || target.ws.readyState !== WebSocket.OPEN) {
184
205
  controllerWs.send(serializeMessage({
185
206
  id: msg.id,
186
207
  type: StudioMsg.RESPONSE,
187
- payload: { error: "Studio not connected" },
208
+ payload: { error: this.getTargetError() },
188
209
  ts: Date.now()
189
210
  }));
190
211
  return;
191
212
  }
192
213
  this.relayRequests.set(msg.id, controllerWs);
193
- this.studioClient.ws.send(serializeMessage(msg));
214
+ target.ws.send(serializeMessage(msg));
194
215
  }
195
- handleStudioMessage(msg) {
216
+ handleStudioMessage(ws, msg) {
196
217
  if (msg.type === StudioMsg.CONNECTED) {
197
218
  const payload = msg.payload;
198
- if (this.studioClient) {
199
- this.studioClient.studioVersion = payload.studioVersion;
200
- this.studioClient.placeId = payload.placeId;
201
- this.studioClient.placeName = payload.placeName;
219
+ const placeId = this.wsToPlaceId.get(ws);
220
+ if (placeId !== void 0) {
221
+ const conn = this.studioClients.get(placeId);
222
+ if (conn) {
223
+ conn.studioVersion = payload.studioVersion;
224
+ conn.placeId = payload.placeId;
225
+ conn.placeName = payload.placeName;
226
+ }
202
227
  }
203
228
  this.emit("studio:connected", payload);
204
229
  this.broadcastToControllers("studio:connected", payload);
@@ -223,10 +248,35 @@ var DominusServer = class extends EventEmitter {
223
248
  this.emit("message", msg);
224
249
  this.broadcastToControllers(msg.type, msg.payload);
225
250
  }
251
+ /** Resolve which Studio connection to target */
252
+ resolveTarget() {
253
+ if (this.studioClients.size === 0) return null;
254
+ if (this.activePlaceId !== 0) {
255
+ const conn = this.studioClients.get(this.activePlaceId);
256
+ if (conn && conn.ws.readyState === WebSocket.OPEN) return conn;
257
+ return null;
258
+ }
259
+ if (this.studioClients.size === 1) {
260
+ const conn = this.studioClients.values().next().value;
261
+ if (conn.ws.readyState === WebSocket.OPEN) return conn;
262
+ return null;
263
+ }
264
+ return null;
265
+ }
266
+ /** Generate an appropriate error message when no target found */
267
+ getTargetError() {
268
+ if (this.studioClients.size === 0) return "No Studio connection";
269
+ if (this.activePlaceId !== 0) {
270
+ return `Active place ${this.activePlaceId} is not connected. Use set_active_place or list_connections.`;
271
+ }
272
+ const places = [...this.studioClients.values()].map((c) => `${c.placeName ?? "Unknown"} (${c.placeId})`);
273
+ return `Multiple Studio instances connected: ${places.join(", ")}. Use set_active_place to choose one.`;
274
+ }
226
275
  sendRequest(type, payload, timeoutMs = 1e4) {
227
276
  return new Promise((resolve, reject) => {
228
- if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
229
- reject(new Error("No Studio connection"));
277
+ const target = this.resolveTarget();
278
+ if (!target || target.ws.readyState !== WebSocket.OPEN) {
279
+ reject(new Error(this.getTargetError()));
230
280
  return;
231
281
  }
232
282
  const msg = createMessage(type, payload);
@@ -239,31 +289,45 @@ var DominusServer = class extends EventEmitter {
239
289
  reject,
240
290
  timer
241
291
  });
242
- this.studioClient.ws.send(serializeMessage(msg));
292
+ target.ws.send(serializeMessage(msg));
243
293
  });
244
294
  }
245
295
  sendNotification(type, payload) {
246
- if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
247
- return;
248
- }
296
+ const target = this.resolveTarget();
297
+ if (!target || target.ws.readyState !== WebSocket.OPEN) return;
249
298
  const msg = createMessage(type, payload);
250
- this.studioClient.ws.send(serializeMessage(msg));
299
+ target.ws.send(serializeMessage(msg));
251
300
  }
252
301
  isConnected() {
253
- return this.studioClient !== null && this.studioClient.ws.readyState === WebSocket.OPEN;
302
+ return this.resolveTarget() !== null;
254
303
  }
255
304
  getConnectionInfo() {
256
- return this.studioClient;
305
+ return this.resolveTarget();
306
+ }
307
+ listConnections() {
308
+ return [...this.studioClients.values()].filter((c) => c.ws.readyState === WebSocket.OPEN).map((c) => ({
309
+ placeId: c.placeId ?? 0,
310
+ placeName: c.placeName,
311
+ studioVersion: c.studioVersion,
312
+ connectedAt: c.connectedAt
313
+ }));
314
+ }
315
+ getActivePlaceId() {
316
+ return this.activePlaceId;
317
+ }
318
+ setActivePlaceId(placeId) {
319
+ this.activePlaceId = placeId;
257
320
  }
258
321
  getPort() {
259
322
  return this.port;
260
323
  }
261
324
  stop() {
262
325
  return new Promise((resolve) => {
263
- if (this.studioClient) {
264
- this.studioClient.ws.close();
265
- this.studioClient = null;
326
+ for (const conn of this.studioClients.values()) {
327
+ conn.ws.close();
266
328
  }
329
+ this.studioClients.clear();
330
+ this.wsToPlaceId.clear();
267
331
  for (const ws of this.controllers) {
268
332
  ws.close();
269
333
  }
@@ -286,7 +350,8 @@ var DominusClient = class extends EventEmitter {
286
350
  ws = null;
287
351
  port;
288
352
  studioConnected = false;
289
- connectionInfo = null;
353
+ connections = /* @__PURE__ */ new Map();
354
+ activePlaceId = 0;
290
355
  pendingRequests = /* @__PURE__ */ new Map();
291
356
  constructor(port = 18088) {
292
357
  super();
@@ -330,6 +395,20 @@ var DominusClient = class extends EventEmitter {
330
395
  if (msg.type === "controller:welcome") {
331
396
  const p = msg.payload;
332
397
  this.studioConnected = p.studioConnected;
398
+ if (p.connections) {
399
+ this.connections.clear();
400
+ for (const c of p.connections) {
401
+ this.connections.set(c.placeId, {
402
+ connectedAt: c.connectedAt,
403
+ studioVersion: c.studioVersion,
404
+ placeId: c.placeId,
405
+ placeName: c.placeName
406
+ });
407
+ }
408
+ }
409
+ if (p.activePlaceId !== void 0) {
410
+ this.activePlaceId = p.activePlaceId;
411
+ }
333
412
  if (this.studioConnected) {
334
413
  this.emit("studio:connected", {});
335
414
  }
@@ -338,19 +417,27 @@ var DominusClient = class extends EventEmitter {
338
417
  if (msg.type === "studio:connected") {
339
418
  this.studioConnected = true;
340
419
  const payload = msg.payload;
341
- this.connectionInfo = {
420
+ const placeId = payload.placeId ?? 0;
421
+ this.connections.set(placeId, {
342
422
  connectedAt: Date.now(),
343
423
  studioVersion: payload.studioVersion,
344
- placeId: payload.placeId,
424
+ placeId,
345
425
  placeName: payload.placeName
346
- };
426
+ });
347
427
  this.emit("studio:connected", payload);
348
428
  return;
349
429
  }
350
430
  if (msg.type === "studio:disconnected") {
351
- this.studioConnected = false;
352
- this.connectionInfo = null;
353
- this.emit("studio:disconnected");
431
+ const payload = msg.payload;
432
+ const placeId = payload.placeId ?? 0;
433
+ this.connections.delete(placeId);
434
+ if (this.connections.size === 0) {
435
+ this.studioConnected = false;
436
+ }
437
+ if (this.activePlaceId === placeId) {
438
+ this.activePlaceId = 0;
439
+ }
440
+ this.emit("studio:disconnected", payload);
354
441
  return;
355
442
  }
356
443
  if (msg.type === StudioMsg.RESPONSE) {
@@ -394,11 +481,27 @@ var DominusClient = class extends EventEmitter {
394
481
  this.ws.send(serializeMessage(msg));
395
482
  }
396
483
  isConnected() {
397
- return this.studioConnected;
484
+ return this.studioConnected && this.connections.size > 0;
398
485
  }
399
486
  getConnectionInfo() {
400
- if (!this.connectionInfo) return null;
401
- return { ...this.connectionInfo, ws: this.ws };
487
+ if (this.connections.size === 0) return null;
488
+ const target = this.activePlaceId !== 0 ? this.connections.get(this.activePlaceId) : this.connections.values().next().value;
489
+ if (!target) return null;
490
+ return { ...target, ws: this.ws };
491
+ }
492
+ listConnections() {
493
+ return [...this.connections.values()].map((c) => ({
494
+ placeId: c.placeId ?? 0,
495
+ placeName: c.placeName,
496
+ studioVersion: c.studioVersion,
497
+ connectedAt: c.connectedAt
498
+ }));
499
+ }
500
+ getActivePlaceId() {
501
+ return this.activePlaceId;
502
+ }
503
+ setActivePlaceId(placeId) {
504
+ this.activePlaceId = placeId;
402
505
  }
403
506
  getPort() {
404
507
  return this.port;
@@ -673,6 +776,7 @@ These are built-in lessons that ship with Dominus. Follow them exactly.
673
776
  - **Inspecting user's selection \u2192 \`get_selection\`, then \`get_properties\` or \`serialize_ui\`**
674
777
  - **Reading properties \u2192 \`get_properties\` (NEVER \`run_code\` with dump scripts)**
675
778
  - **Browsing the tree \u2192 \`get_explorer\`**
779
+ - **Multiple Studios open \u2192 \`list_connections\` to see all, \`set_active_place\` to target one**
676
780
  - Anything exotic or procedural \u2192 \`run_code\` as last resort
677
781
 
678
782
  ### NEVER use run_code for:
@@ -731,7 +835,7 @@ async function startMcpServer() {
731
835
  }
732
836
  const mcp = new McpServer({
733
837
  name: "dominus",
734
- version: "0.5.8"
838
+ version: "0.6.0"
735
839
  }, {
736
840
  instructions: INTERNAL_MEMORY
737
841
  });
@@ -744,6 +848,11 @@ async function startMcpServer() {
744
848
  for (const p of parts) r += `[${luaStr(p)}]`;
745
849
  return r;
746
850
  }
851
+ function cleanNum(n) {
852
+ if (Number.isInteger(n)) return String(n);
853
+ const rounded = Math.round(n * 1e3) / 1e3;
854
+ return String(rounded);
855
+ }
747
856
  function generateComponentCode(tree, componentName, framework) {
748
857
  const isReact = framework === "react";
749
858
  const lib = isReact ? "React" : "Roact";
@@ -801,7 +910,7 @@ async function startMcpServer() {
801
910
  function luaValue(val, _depth) {
802
911
  if (val === null || val === void 0) return "nil";
803
912
  if (typeof val === "boolean") return String(val);
804
- if (typeof val === "number") return String(val);
913
+ if (typeof val === "number") return cleanNum(val);
805
914
  if (typeof val === "string") {
806
915
  if (val.startsWith("#") && val.length === 7) {
807
916
  return `Color3.fromHex("${val}")`;
@@ -813,15 +922,15 @@ async function startMcpServer() {
813
922
  }
814
923
  if (Array.isArray(val)) {
815
924
  if (val.length === 4 && val.every((v) => typeof v === "number")) {
816
- return `UDim2.new(${val.join(", ")})`;
925
+ return `UDim2.new(${val.map(cleanNum).join(", ")})`;
817
926
  }
818
927
  if (val.length === 2 && val.every((v) => typeof v === "number")) {
819
- return `Vector2.new(${val.join(", ")})`;
928
+ return `Vector2.new(${val.map(cleanNum).join(", ")})`;
820
929
  }
821
930
  if (val.length === 3 && val.every((v) => typeof v === "number")) {
822
931
  const allSmall = val.every((v) => v <= 1);
823
- if (allSmall) return `Color3.new(${val.join(", ")})`;
824
- return `Color3.fromRGB(${val.join(", ")})`;
932
+ if (allSmall) return `Color3.new(${val.map(cleanNum).join(", ")})`;
933
+ return `Color3.fromRGB(${val.map((v) => String(Math.round(v))).join(", ")})`;
825
934
  }
826
935
  return `{${val.map((v) => luaValue(v, _depth)).join(", ")}}`;
827
936
  }
@@ -1314,6 +1423,57 @@ ${" ".repeat(_depth)}}`;
1314
1423
  return { content: [{ type: "text", text: code }] };
1315
1424
  }
1316
1425
  );
1426
+ mcp.tool(
1427
+ "list_connections",
1428
+ "List all Roblox Studio instances currently connected to Dominus. Shows placeId, placeName, and connection time for each. Use this when working with multiple Studio windows.",
1429
+ {},
1430
+ async () => {
1431
+ const connections = bridge.listConnections();
1432
+ const activePlaceId = bridge.getActivePlaceId();
1433
+ return {
1434
+ content: [{
1435
+ type: "text",
1436
+ text: JSON.stringify({
1437
+ activePlaceId: activePlaceId || "auto",
1438
+ connections: connections.map((c) => ({
1439
+ ...c,
1440
+ active: activePlaceId === 0 && connections.length === 1 ? true : c.placeId === activePlaceId
1441
+ }))
1442
+ }, null, 2)
1443
+ }]
1444
+ };
1445
+ }
1446
+ );
1447
+ mcp.tool(
1448
+ "set_active_place",
1449
+ "Set which Roblox Studio instance to target when multiple are connected. Pass placeId=0 to reset to auto mode (works when only one is connected). Call list_connections first to see available placeIds.",
1450
+ {
1451
+ placeId: z.number().describe("The placeId to target. Use 0 to reset to auto-select.")
1452
+ },
1453
+ async ({ placeId }) => {
1454
+ const connections = bridge.listConnections();
1455
+ if (placeId !== 0 && !connections.some((c) => c.placeId === placeId)) {
1456
+ const available = connections.map((c) => `${c.placeName ?? "Unknown"} (${c.placeId})`).join(", ");
1457
+ return {
1458
+ content: [{
1459
+ type: "text",
1460
+ text: `Place ${placeId} is not connected. Available: ${available || "none"}`
1461
+ }]
1462
+ };
1463
+ }
1464
+ bridge.setActivePlaceId(placeId);
1465
+ if (placeId === 0) {
1466
+ return { content: [{ type: "text", text: "Active place reset to auto-select." }] };
1467
+ }
1468
+ const target = connections.find((c) => c.placeId === placeId);
1469
+ return {
1470
+ content: [{
1471
+ type: "text",
1472
+ text: `Active place set to ${target?.placeName ?? "Unknown"} (${placeId})`
1473
+ }]
1474
+ };
1475
+ }
1476
+ );
1317
1477
  mcp.tool(
1318
1478
  "recall_memory",
1319
1479
  "Search persistent memory for facts about the current Roblox project",