dominus-cli 2.2.0 → 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.
package/dist/mcp.js CHANGED
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import fs3 from 'fs';
5
- import path3 from 'path';
4
+ import fs4 from 'fs';
5
+ import path4 from 'path';
6
6
  import os from 'os';
7
+ import { spawn } from 'child_process';
8
+ import { fileURLToPath } from 'url';
7
9
  import { EventEmitter } from 'events';
8
- import { WebSocket, WebSocketServer } from 'ws';
10
+ import { WebSocket } from 'ws';
9
11
  import { nanoid } from 'nanoid';
10
12
  import { z } from 'zod';
11
13
  import * as crypto from 'crypto';
12
- import { randomUUID } from 'crypto';
13
14
 
14
- var DOMINUS_DIR = path3.join(os.homedir(), ".dominus");
15
- var CONFIG_PATH = path3.join(DOMINUS_DIR, "config.json");
16
- path3.join(DOMINUS_DIR, "dominus.db");
17
- path3.join(DOMINUS_DIR, "rules.json");
15
+ var DOMINUS_DIR = path4.join(os.homedir(), ".dominus");
16
+ var CONFIG_PATH = path4.join(DOMINUS_DIR, "config.json");
17
+ path4.join(DOMINUS_DIR, "dominus.db");
18
+ path4.join(DOMINUS_DIR, "rules.json");
18
19
  var DEFAULTS = {
19
20
  provider: "openai",
20
21
  apiBaseUrl: "",
@@ -28,14 +29,14 @@ var DEFAULTS = {
28
29
  userName: "Developer"
29
30
  };
30
31
  function ensureDir() {
31
- if (!fs3.existsSync(DOMINUS_DIR)) {
32
- fs3.mkdirSync(DOMINUS_DIR, { recursive: true });
32
+ if (!fs4.existsSync(DOMINUS_DIR)) {
33
+ fs4.mkdirSync(DOMINUS_DIR, { recursive: true });
33
34
  }
34
35
  }
35
36
  function readJson(filePath, defaults) {
36
37
  try {
37
- if (fs3.existsSync(filePath)) {
38
- const raw = fs3.readFileSync(filePath, "utf-8");
38
+ if (fs4.existsSync(filePath)) {
39
+ const raw = fs4.readFileSync(filePath, "utf-8");
39
40
  return { ...defaults, ...JSON.parse(raw) };
40
41
  }
41
42
  } catch {
@@ -108,6 +109,14 @@ var CliMsg = {
108
109
  V2_READ_SCRIPT: "studio:v2:read_script",
109
110
  V2_UPDATE_SCRIPT: "studio:v2:update_script",
110
111
  V2_APPLY: "studio:v2:apply",
112
+ V2_CREATE_PARTS: "studio:v2:create_parts",
113
+ V2_CLONE_INSTANCES: "studio:v2:clone_instances",
114
+ V2_GROUP_INSTANCES: "studio:v2:group_instances",
115
+ V2_UNGROUP_INSTANCES: "studio:v2:ungroup_instances",
116
+ V2_TRANSFORM_INSTANCES: "studio:v2:transform_instances",
117
+ V2_CREATE_WELDS: "studio:v2:create_welds",
118
+ V2_QUERY_PARTS: "studio:v2:query_parts",
119
+ V2_SET_SELECTION: "studio:v2:set_selection",
111
120
  V2_DELETE: "studio:v2:delete",
112
121
  V2_BUILD_UI: "studio:v2:build_ui",
113
122
  V2_SNAPSHOT_UI: "studio:v2:snapshot_ui",
@@ -144,27 +153,21 @@ var wsMessageSchema = z.object({
144
153
  }).strict();
145
154
  var TOKEN_FILE = "bridge-token";
146
155
  function loadOrCreateBridgeToken() {
147
- const tokenPath = path3.join(getDominusDir(), TOKEN_FILE);
156
+ const tokenPath = path4.join(getDominusDir(), TOKEN_FILE);
148
157
  try {
149
- const existing = fs3.readFileSync(tokenPath, "utf8").trim();
158
+ const existing = fs4.readFileSync(tokenPath, "utf8").trim();
150
159
  if (existing.length >= 32) return existing;
151
160
  } catch {
152
161
  }
153
162
  const token = crypto.randomBytes(32).toString("base64url");
154
- fs3.writeFileSync(tokenPath, `${token}
163
+ fs4.writeFileSync(tokenPath, `${token}
155
164
  `, { encoding: "utf8", mode: 384 });
156
165
  try {
157
- fs3.chmodSync(tokenPath, 384);
166
+ fs4.chmodSync(tokenPath, 384);
158
167
  } catch {
159
168
  }
160
169
  return token;
161
170
  }
162
- function tokensEqual(actual, expected) {
163
- if (typeof actual !== "string") return false;
164
- const actualBuffer = Buffer.from(actual);
165
- const expectedBuffer = Buffer.from(expected);
166
- return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
167
- }
168
171
 
169
172
  // src/transport/ws-client.ts
170
173
  var DominusClient = class extends EventEmitter {
@@ -197,10 +200,14 @@ var DominusClient = class extends EventEmitter {
197
200
  }, this.connectTimeoutMs);
198
201
  ws.on("open", () => {
199
202
  this.ws = ws;
200
- ws.send(serializeMessage(createMessage(ControllerMsg.HELLO, {
201
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
202
- token: this.token
203
- })));
203
+ ws.send(
204
+ serializeMessage(
205
+ createMessage(ControllerMsg.HELLO, {
206
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
207
+ token: this.token
208
+ })
209
+ )
210
+ );
204
211
  });
205
212
  ws.on("message", (raw) => {
206
213
  try {
@@ -341,7 +348,11 @@ var DominusClient = class extends EventEmitter {
341
348
  sendRequest(type, payload, timeoutMs = 15e3) {
342
349
  const target = this.resolveTargetInfo();
343
350
  if (!target || !this.activeConnectionId) {
344
- return Promise.reject(new Error(this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"));
351
+ return Promise.reject(
352
+ new Error(
353
+ this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"
354
+ )
355
+ );
345
356
  }
346
357
  const request = createMessage(type, payload);
347
358
  const relay = createMessage(ControllerMsg.RELAY, {
@@ -370,10 +381,12 @@ var DominusClient = class extends EventEmitter {
370
381
  sendNotification(type, payload) {
371
382
  if (!this.activeConnectionId) return;
372
383
  const request = createMessage(type, payload);
373
- this.sendMessage(createMessage(ControllerMsg.RELAY, {
374
- connectionId: this.activeConnectionId,
375
- request
376
- }));
384
+ this.sendMessage(
385
+ createMessage(ControllerMsg.RELAY, {
386
+ connectionId: this.activeConnectionId,
387
+ request
388
+ })
389
+ );
377
390
  }
378
391
  isConnected() {
379
392
  return this.resolveTargetInfo() !== null;
@@ -412,9 +425,13 @@ var DominusClient = class extends EventEmitter {
412
425
  void this.setActiveConnectionId(null);
413
426
  return;
414
427
  }
415
- const matches = [...this.connections.values()].filter((connection) => connection.placeId === placeId);
428
+ const matches = [...this.connections.values()].filter(
429
+ (connection) => connection.placeId === placeId
430
+ );
416
431
  if (matches.length !== 1) {
417
- throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
432
+ throw new Error(
433
+ matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
434
+ );
418
435
  }
419
436
  void this.setActiveConnectionId(matches[0].connectionId);
420
437
  }
@@ -475,413 +492,74 @@ async function isPortInUse(port) {
475
492
  });
476
493
  });
477
494
  }
478
- var HANDSHAKE_TIMEOUT_MS = 8e3;
479
- var RATE_WINDOW_MS = 1e4;
480
- var RATE_LIMIT = 250;
481
- var DominusServer = class extends EventEmitter {
482
- wss = null;
483
- studioClients = /* @__PURE__ */ new Map();
484
- wsToConnectionId = /* @__PURE__ */ new Map();
485
- controllers = /* @__PURE__ */ new Set();
486
- socketRoles = /* @__PURE__ */ new Map();
487
- pendingRequests = /* @__PURE__ */ new Map();
488
- relayRequests = /* @__PURE__ */ new Map();
489
- rateState = /* @__PURE__ */ new Map();
490
- token;
491
- host;
492
- maxPayloadBytes;
493
- handshakeTimeoutMs;
494
- activeConnectionId = null;
495
- port;
496
- constructor(port = 18088, options = {}) {
497
- super();
498
- this.port = port;
499
- this.token = options.token ?? loadOrCreateBridgeToken();
500
- this.host = options.host ?? "127.0.0.1";
501
- this.maxPayloadBytes = options.maxPayloadBytes ?? MAX_MESSAGE_BYTES;
502
- this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
503
- }
504
- start(maxRetries = 0) {
505
- return new Promise((resolve, reject) => {
506
- const tryPort = (port, attempt) => {
507
- const wss = new WebSocketServer({
508
- port,
509
- host: this.host,
510
- maxPayload: this.maxPayloadBytes,
511
- perMessageDeflate: false
512
- });
513
- wss.on("listening", () => {
514
- this.wss = wss;
515
- this.port = port;
516
- wss.on("connection", (ws) => this.handleConnection(ws));
517
- this.emit("server:started", { port, host: this.host });
518
- resolve();
519
- });
520
- wss.on("error", (err) => {
521
- if (err.code === "EADDRINUSE" && attempt < maxRetries) {
522
- wss.close();
523
- tryPort(port + 1, attempt + 1);
524
- return;
525
- }
526
- this.emit("server:error", err);
527
- reject(err);
528
- });
529
- };
530
- tryPort(this.port, 0);
531
- });
532
- }
533
- handleConnection(ws) {
534
- this.socketRoles.set(ws, "unknown");
535
- this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
536
- const handshakeTimer = setTimeout(() => {
537
- if (this.socketRoles.get(ws) === "unknown") {
538
- ws.close(1008, "Dominus handshake timed out");
539
- }
540
- }, this.handshakeTimeoutMs);
541
- ws.on("message", (raw) => {
542
- try {
543
- if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
544
- ws.close(1009, "Dominus message limit exceeded");
545
- return;
546
- }
547
- const msg = parseMessage(raw.toString());
548
- const role = this.socketRoles.get(ws) ?? "unknown";
549
- if (role === "unknown") {
550
- clearTimeout(handshakeTimer);
551
- this.handleHandshake(ws, msg);
552
- } else if (role === "controller") {
553
- this.handleControllerMessage(ws, msg);
554
- } else if (role === "studio") {
555
- this.handleStudioMessage(ws, msg);
556
- }
557
- } catch (err) {
558
- this.emit("server:error", err);
559
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
560
- error: err instanceof Error ? err.message : String(err)
561
- }));
562
- }
563
- });
564
- ws.on("close", () => {
565
- clearTimeout(handshakeTimer);
566
- this.cleanupSocket(ws);
567
- });
568
- ws.on("error", (err) => this.emit("server:error", err));
569
- }
570
- consumeRateBudget(ws) {
571
- const now = Date.now();
572
- const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
573
- if (now - state.startedAt >= RATE_WINDOW_MS) {
574
- state.startedAt = now;
575
- state.count = 0;
576
- }
577
- state.count += 1;
578
- this.rateState.set(ws, state);
579
- return state.count <= RATE_LIMIT;
580
- }
581
- handleHandshake(ws, msg) {
582
- if (msg.type === StudioMsg.HELLO) {
583
- const hello = validateStudioHello(msg.payload);
584
- if (tokensEqual(hello.token, this.token)) {
585
- this.authorizeStudio(ws, hello, randomUUID());
586
- } else {
587
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
588
- error: "Studio plugin credentials are missing or stale. Run dominus-install-plugin and restart Studio."
589
- }));
590
- ws.close(1008, "Unauthorized Studio plugin");
591
- }
592
- return;
593
- }
594
- if (msg.type === ControllerMsg.HELLO) {
595
- const payload = msg.payload;
596
- if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
597
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, { error: "Invalid Dominus controller credentials" }));
598
- ws.close(1008, "Unauthorized controller");
599
- return;
600
- }
601
- this.socketRoles.set(ws, "controller");
602
- this.controllers.add(ws);
603
- this.sendSafe(ws, createMessage(ControllerMsg.WELCOME, {
604
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
605
- port: this.port,
606
- connections: this.listConnections(),
607
- activeConnectionId: this.activeConnectionId
608
- }));
609
- return;
610
- }
611
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
612
- error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
613
- }));
614
- ws.close(1008, "Invalid Dominus handshake");
615
- }
616
- authorizeStudio(ws, hello, connectionId) {
617
- const connection = {
618
- ws,
619
- connectionId,
620
- clientId: hello.clientId,
621
- connectedAt: Date.now(),
622
- studioVersion: hello.studioVersion,
623
- placeId: hello.placeId ?? 0,
624
- placeName: hello.placeName
625
- };
626
- this.studioClients.set(connectionId, connection);
627
- this.wsToConnectionId.set(ws, connectionId);
628
- this.socketRoles.set(ws, "studio");
629
- if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
630
- this.activeConnectionId = connectionId;
631
- }
632
- this.sendSafe(ws, createMessage(StudioMsg.AUTHENTICATED, {
633
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
634
- connectionId,
635
- token: this.token
636
- }));
637
- const info = this.toPlaceInfo(connection);
638
- this.emit("studio:connected", info);
639
- this.broadcastToControllers(StudioMsg.CONNECTED, info);
640
- this.broadcastTargetState();
641
- return connection;
642
- }
643
- handleControllerMessage(controllerWs, msg) {
644
- if (msg.type === ControllerMsg.RELAY) {
645
- const payload = msg.payload;
646
- const request = parseMessage(JSON.stringify(payload.request));
647
- const target = this.resolveConnection(typeof payload.connectionId === "string" ? payload.connectionId : null);
648
- if (!target) {
649
- this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
650
- return;
651
- }
652
- this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
653
- this.sendSafe(target.ws, request);
654
- return;
655
- }
656
- if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
657
- const payload = msg.payload;
658
- const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
659
- if (connectionId && !this.studioClients.has(connectionId)) {
660
- this.sendResponse(controllerWs, msg.id, { success: false, error: `Studio connection ${connectionId} is not connected` });
661
- return;
662
- }
663
- this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
664
- return;
665
- }
666
- this.sendResponse(controllerWs, msg.id, { error: `Unsupported controller message: ${msg.type}` });
667
- }
668
- handleStudioMessage(ws, msg) {
669
- if (msg.type === StudioMsg.RESPONSE) {
670
- const relay = this.relayRequests.get(msg.id);
671
- if (relay) {
672
- if (relay.targetWs !== ws) {
673
- this.emit("server:error", new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`));
674
- return;
675
- }
676
- this.sendSafe(relay.controllerWs, msg);
677
- this.relayRequests.delete(msg.id);
678
- return;
679
- }
680
- const pending = this.pendingRequests.get(msg.id);
681
- if (pending) {
682
- if (pending.targetWs !== ws) {
683
- this.emit("server:error", new Error(`Ignored response ${msg.id} from the wrong Studio session`));
684
- return;
685
- }
686
- clearTimeout(pending.timer);
687
- this.pendingRequests.delete(msg.id);
688
- pending.resolve(msg);
689
- }
690
- return;
691
- }
692
- this.emit(msg.type, msg.payload);
693
- this.emit("message", msg);
495
+
496
+ // src/transport/daemon-launcher.ts
497
+ function resolveDaemonPath() {
498
+ const besideEntry = fileURLToPath(new URL("./bridge-daemon.js", import.meta.url));
499
+ if (fs4.existsSync(besideEntry)) return besideEntry;
500
+ const projectBuild = path4.resolve(
501
+ path4.dirname(fileURLToPath(import.meta.url)),
502
+ "../../dist/bridge-daemon.js"
503
+ );
504
+ if (fs4.existsSync(projectBuild)) return projectBuild;
505
+ throw new Error("Dominus bridge daemon is missing. Reinstall or rebuild dominus-cli.");
506
+ }
507
+ function launchBridgeDaemon(port, daemonPath = resolveDaemonPath()) {
508
+ const child = spawn(process.execPath, [daemonPath], {
509
+ detached: true,
510
+ env: { ...process.env, DOMINUS_BRIDGE_PORT: String(port) },
511
+ stdio: "ignore",
512
+ windowsHide: true
513
+ });
514
+ child.unref();
515
+ }
516
+ async function connectController(port, timeoutMs, token) {
517
+ const client = new DominusClient(port, { connectTimeoutMs: timeoutMs, token });
518
+ try {
519
+ await client.connect();
520
+ return client;
521
+ } catch (error) {
522
+ await client.stop().catch(() => void 0);
523
+ throw error;
694
524
  }
695
- cleanupSocket(ws) {
696
- const role = this.socketRoles.get(ws);
697
- this.socketRoles.delete(ws);
698
- this.rateState.delete(ws);
699
- if (role === "controller") {
700
- this.controllers.delete(ws);
701
- for (const [id, relay] of this.relayRequests) {
702
- if (relay.controllerWs === ws) this.relayRequests.delete(id);
703
- }
704
- return;
705
- }
706
- const connectionId = this.wsToConnectionId.get(ws);
707
- if (!connectionId) return;
708
- const connection = this.studioClients.get(connectionId);
709
- this.wsToConnectionId.delete(ws);
710
- this.studioClients.delete(connectionId);
711
- for (const [id, pending] of this.pendingRequests) {
712
- if (pending.targetWs === ws) {
713
- clearTimeout(pending.timer);
714
- pending.reject(new Error(`Studio disconnected (${connectionId})`));
715
- this.pendingRequests.delete(id);
716
- }
717
- }
718
- for (const [id, relay] of this.relayRequests) {
719
- if (relay.targetWs === ws) {
720
- this.sendResponse(relay.controllerWs, id, { error: `Studio disconnected (${connectionId})` });
721
- this.relayRequests.delete(id);
722
- }
723
- }
724
- if (this.activeConnectionId === connectionId) {
725
- this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
726
- }
727
- const info = connection ? this.toPlaceInfo(connection) : { connectionId };
728
- this.emit("studio:disconnected", info);
729
- this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
730
- this.broadcastTargetState();
731
- }
732
- resolveConnection(connectionId = this.activeConnectionId) {
733
- if (connectionId) {
734
- const connection = this.studioClients.get(connectionId);
735
- return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
736
- }
737
- if (this.studioClients.size === 1) {
738
- const connection = this.studioClients.values().next().value;
739
- return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
525
+ }
526
+ async function ensureBridgeDaemon(port, options = {}) {
527
+ const attempts = options.attempts ?? 30;
528
+ const connectTimeoutMs = options.connectTimeoutMs ?? 750;
529
+ const retryDelayMs = options.retryDelayMs ?? 100;
530
+ try {
531
+ return await connectController(port, connectTimeoutMs, options.token);
532
+ } catch (initialError) {
533
+ if (await isPortInUse(port)) {
534
+ throw new Error(
535
+ `Port ${port} is occupied by a process that is not this Dominus bridge: ${formatError(initialError)}`
536
+ );
740
537
  }
741
- return null;
742
- }
743
- getTargetError() {
744
- if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
745
- if (this.activeConnectionId) return `Studio connection ${this.activeConnectionId} is unavailable`;
746
- return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
747
- }
748
- toPlaceInfo(connection) {
749
- return {
750
- placeId: connection.placeId ?? 0,
751
- connectionId: connection.connectionId,
752
- clientId: connection.clientId,
753
- placeName: connection.placeName,
754
- studioVersion: connection.studioVersion,
755
- connectedAt: connection.connectedAt,
756
- active: connection.connectionId === this.activeConnectionId
757
- };
758
- }
759
- broadcastToControllers(type, payload) {
760
- const message = createMessage(type, payload);
761
- for (const controller of this.controllers) this.sendSafe(controller, message);
762
- }
763
- broadcastTargetState() {
764
- this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
765
- connections: this.listConnections(),
766
- defaultActiveConnectionId: this.activeConnectionId
767
- });
768
- }
769
- sendResponse(ws, id, payload) {
770
- this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
771
538
  }
772
- sendSafe(ws, message) {
773
- if (ws.readyState !== WebSocket.OPEN) return;
539
+ const launch = options.launch ?? ((targetPort) => launchBridgeDaemon(targetPort, options.daemonPath));
540
+ launch(port);
541
+ let lastError;
542
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
543
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
774
544
  try {
775
- ws.send(serializeMessage(message));
776
- } catch (err) {
777
- this.emit("server:error", err);
778
- }
779
- }
780
- sendRequest(type, payload, timeoutMs = 15e3) {
781
- return new Promise((resolve, reject) => {
782
- const target = this.resolveConnection();
783
- if (!target) {
784
- reject(new Error(this.getTargetError()));
785
- return;
786
- }
787
- const message = createMessage(type, payload);
788
- const timer = setTimeout(() => {
789
- this.pendingRequests.delete(message.id);
790
- reject(new Error(`Studio request timed out: ${type}`));
791
- }, timeoutMs);
792
- this.pendingRequests.set(message.id, {
793
- resolve,
794
- reject,
795
- timer,
796
- targetWs: target.ws
797
- });
798
- this.sendSafe(target.ws, message);
799
- });
800
- }
801
- sendNotification(type, payload) {
802
- const target = this.resolveConnection();
803
- if (target) this.sendSafe(target.ws, createMessage(type, payload));
804
- }
805
- isConnected() {
806
- return this.resolveConnection() !== null;
807
- }
808
- getConnectionInfo() {
809
- return this.resolveConnection();
810
- }
811
- listConnections() {
812
- return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
813
- }
814
- getActiveConnectionId() {
815
- return this.activeConnectionId;
816
- }
817
- async setActiveConnectionId(connectionId) {
818
- if (connectionId && !this.studioClients.has(connectionId)) {
819
- throw new Error(`Studio connection ${connectionId} is not connected`);
820
- }
821
- this.activeConnectionId = connectionId;
822
- this.broadcastTargetState();
823
- }
824
- getActivePlaceId() {
825
- return this.resolveConnection()?.placeId ?? 0;
826
- }
827
- setActivePlaceId(placeId) {
828
- if (placeId === 0) {
829
- this.activeConnectionId = null;
830
- return;
831
- }
832
- const matches = [...this.studioClients.values()].filter((connection) => connection.placeId === placeId);
833
- if (matches.length !== 1) {
834
- throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
545
+ return await connectController(port, connectTimeoutMs, options.token);
546
+ } catch (error) {
547
+ lastError = error;
835
548
  }
836
- this.activeConnectionId = matches[0].connectionId;
837
- }
838
- getPort() {
839
- return this.port;
840
- }
841
- stop() {
842
- return new Promise((resolve) => {
843
- for (const pending of this.pendingRequests.values()) {
844
- clearTimeout(pending.timer);
845
- pending.reject(new Error("Dominus bridge is shutting down"));
846
- }
847
- this.pendingRequests.clear();
848
- this.relayRequests.clear();
849
- for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
850
- this.studioClients.clear();
851
- this.controllers.clear();
852
- this.socketRoles.clear();
853
- this.wsToConnectionId.clear();
854
- if (this.wss) this.wss.close(() => resolve());
855
- else resolve();
856
- });
857
549
  }
858
- };
859
- function validateStudioHello(payload) {
860
- if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
861
- const hello = payload;
862
- if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
863
- throw new Error(`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`);
864
- }
865
- if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
866
- throw new Error("Studio hello is missing a valid clientId");
867
- }
868
- if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
869
- throw new Error("Studio hello is missing studioVersion");
870
- }
871
- if (hello.placeName && hello.placeName.length > 256) throw new Error("Studio placeName is too long");
872
- return hello;
550
+ throw new Error(
551
+ `Dominus could not start its local Studio bridge on port ${port}: ${formatError(lastError)}`
552
+ );
873
553
  }
874
- function rawByteLength(raw) {
875
- if (typeof raw === "string") return Buffer.byteLength(raw);
876
- if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
877
- return raw.byteLength;
554
+ function formatError(error) {
555
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
878
556
  }
879
557
 
880
558
  // src/mcp/instructions.ts
881
559
  var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
882
560
 
883
561
  Required workflow for Studio changes:
884
- 1. Call dominus_status and select the intended Studio connection when more than one is open.
562
+ 1. Call dominus_status. If Studio is disconnected, ask the user to run dominus-install-plugin only when the Studio Output reports an authentication failure. Select the intended authenticated Studio connection when more than one window is open.
885
563
  2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
886
564
  3. Form a short plan with explicit acceptance checks.
887
565
  4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
@@ -891,11 +569,18 @@ Required workflow for Studio changes:
891
569
 
892
570
  For broad tasks with independent Studio branches, prefer run_parallel_task when the MCP client supports Sampling. Dominus partitions immediate child scopes, runs proposal-only workers concurrently, rejects unknown refs and overlapping writes, applies only the coordinator-approved atomic plan, and reads the result back. Use the normal workflow when Sampling is unavailable or the task is a simple edit.
893
571
 
572
+ Building workflow:
573
+ - Use studio_create_parts for multi-part structures instead of many generic create operations.
574
+ - Use studio_clone_instances when an existing instance or model is the intended template. Clone rather than manually recreating descendants and properties.
575
+ - Use studio_transform_instances for Models and BaseParts so model layouts move through PVInstance pivots.
576
+ - Use studio_group_instances and studio_ungroup_instances to organize hierarchy, studio_create_welds for rigid assemblies, and studio_query_parts to inspect crowded 3D regions before editing.
577
+ - Use studio_set_selection when surfacing completed objects to the Studio user.
578
+
894
579
  Safety rules:
895
580
  - Never invent instance IDs or Roblox API members.
896
581
  - Do not retry a failed write unchanged. Inspect the error and current state first.
897
582
  - Deletion is a separate destructive tool and requires explicit user intent.
898
- - studio_apply is transactional. Keep related edits together, but stay within its documented limits.
583
+ - studio_apply is transactional and supports setProperties, create, clone, and move operations. Keep related edits together, but stay within its documented limits.
899
584
  - UI fidelity work must snapshot and inspect the result after building; verify fonts, UDim2 values, colors, strokes, and hierarchy.
900
585
  - Dominus intentionally has no arbitrary Luau execution tool.`;
901
586
 
@@ -962,6 +647,8 @@ function failure(error, data) {
962
647
  data
963
648
  });
964
649
  }
650
+
651
+ // src/mcp/tools/shared.ts
965
652
  var instanceRefSchema = z.object({
966
653
  instanceId: z.string().uuid().optional(),
967
654
  pathSegments: z.array(z.string().min(1).max(256)).min(1).max(100).optional()
@@ -994,7 +681,245 @@ function ref(value) {
994
681
  return value;
995
682
  }
996
683
 
997
- // src/mcp/tools/connections.ts
684
+ // src/mcp/tools/building.ts
685
+ var finiteNumber = z.number().finite();
686
+ var vector3Schema = z.object({ x: finiteNumber, y: finiteNumber, z: finiteNumber }).strict();
687
+ var propertiesSchema = z.record(z.unknown()).refine(
688
+ (properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
689
+ "At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
690
+ );
691
+ var partSchema = z.object({
692
+ name: z.string().min(1).max(100),
693
+ className: z.enum([
694
+ "Part",
695
+ "WedgePart",
696
+ "CornerWedgePart",
697
+ "TrussPart",
698
+ "MeshPart",
699
+ "SpawnLocation",
700
+ "Seat",
701
+ "VehicleSeat"
702
+ ]).optional().default("Part"),
703
+ shape: z.enum(["Block", "Ball", "Cylinder", "Wedge", "CornerWedge"]).optional(),
704
+ position: vector3Schema.optional(),
705
+ orientation: vector3Schema.optional(),
706
+ size: vector3Schema.optional(),
707
+ color: z.string().min(1).max(100).optional(),
708
+ material: z.string().min(1).max(100).optional(),
709
+ transparency: z.number().min(0).max(1).optional(),
710
+ anchored: z.boolean().optional().default(true),
711
+ canCollide: z.boolean().optional(),
712
+ properties: propertiesSchema.optional()
713
+ });
714
+ var cloneRequestSchema = z.object({
715
+ target: instanceRefSchema,
716
+ parent: instanceRefSchema.optional(),
717
+ name: z.string().min(1).max(100).optional(),
718
+ count: z.number().int().min(1).max(50).optional().default(1),
719
+ offset: vector3Schema.optional(),
720
+ stepOffset: vector3Schema.optional(),
721
+ properties: propertiesSchema.optional()
722
+ });
723
+ var cframeSchema = z.array(finiteNumber).refine((value) => value.length === 3 || value.length === 12, "CFrame requires 3 or 12 numbers");
724
+ var transformOperationSchema = z.object({
725
+ target: instanceRefSchema,
726
+ mode: z.enum(["absolute", "relative"]).optional().default("absolute"),
727
+ cframe: cframeSchema.optional(),
728
+ position: vector3Schema.optional(),
729
+ orientation: vector3Schema.optional(),
730
+ offset: vector3Schema.optional(),
731
+ rotationOffset: vector3Schema.optional(),
732
+ space: z.enum(["world", "local"]).optional().default("world")
733
+ }).refine(
734
+ (value) => value.mode === "relative" ? Boolean(value.offset || value.rotationOffset) : Boolean(value.cframe || value.position || value.orientation),
735
+ "Absolute transforms require cframe, position, or orientation; relative transforms require offset or rotationOffset"
736
+ );
737
+ function registerBuildingTools(server, bridge) {
738
+ server.registerTool(
739
+ "studio_create_parts",
740
+ {
741
+ title: "Build Roblox Parts",
742
+ description: "Create up to 200 typed BaseParts in one undoable batch, optionally grouped under a new Model or Folder. Supports positions, orientations, sizes, colors, materials, shapes, and additional writable properties.",
743
+ inputSchema: {
744
+ parent: instanceRefSchema.optional(),
745
+ groupName: z.string().min(1).max(100).optional(),
746
+ groupClass: z.enum(["Model", "Folder"]).optional().default("Model"),
747
+ parts: z.array(partSchema).min(1).max(200)
748
+ },
749
+ outputSchema: toolOutputSchema,
750
+ annotations: {
751
+ readOnlyHint: false,
752
+ destructiveHint: false,
753
+ idempotentHint: false,
754
+ openWorldHint: false
755
+ }
756
+ },
757
+ ({ parent, groupName, groupClass, parts }) => callStudio(bridge, CliMsg.V2_CREATE_PARTS, { parent, groupName, groupClass, parts }, 12e4)
758
+ );
759
+ server.registerTool(
760
+ "studio_clone_instances",
761
+ {
762
+ title: "Clone Roblox Instances",
763
+ description: "Deep-clone instances and descendants with Instance:Clone(), optionally rename, reparent, override root properties, and create repeated spatial copies using offsets. The entire request is undoable and rolls back on failure.",
764
+ inputSchema: { requests: z.array(cloneRequestSchema).min(1).max(100) },
765
+ outputSchema: toolOutputSchema,
766
+ annotations: {
767
+ readOnlyHint: false,
768
+ destructiveHint: false,
769
+ idempotentHint: false,
770
+ openWorldHint: false
771
+ }
772
+ },
773
+ ({ requests }) => callStudio(bridge, CliMsg.V2_CLONE_INSTANCES, { requests }, 12e4)
774
+ );
775
+ server.registerTool(
776
+ "studio_group_instances",
777
+ {
778
+ title: "Group Roblox Instances",
779
+ description: "Create a Model or Folder and move up to 100 independent instances into it in one undoable operation.",
780
+ inputSchema: {
781
+ targets: z.array(instanceRefSchema).min(1).max(100),
782
+ name: z.string().min(1).max(100),
783
+ className: z.enum(["Model", "Folder"]).optional().default("Model"),
784
+ parent: instanceRefSchema.optional()
785
+ },
786
+ outputSchema: toolOutputSchema,
787
+ annotations: {
788
+ readOnlyHint: false,
789
+ destructiveHint: false,
790
+ idempotentHint: false,
791
+ openWorldHint: false
792
+ }
793
+ },
794
+ ({ targets, name, className, parent }) => callStudio(bridge, CliMsg.V2_GROUP_INSTANCES, { targets, name, className, parent }, 6e4)
795
+ );
796
+ server.registerTool(
797
+ "studio_ungroup_instances",
798
+ {
799
+ title: "Ungroup Roblox Instances",
800
+ description: "Move every child out of up to 20 Models or Folders, then remove the empty containers in one undoable operation.",
801
+ inputSchema: {
802
+ targets: z.array(instanceRefSchema).min(1).max(20),
803
+ parent: instanceRefSchema.optional(),
804
+ confirm: z.literal(true)
805
+ },
806
+ outputSchema: toolOutputSchema,
807
+ annotations: {
808
+ readOnlyHint: false,
809
+ destructiveHint: true,
810
+ idempotentHint: false,
811
+ openWorldHint: false
812
+ }
813
+ },
814
+ ({ targets, parent, confirm }) => callStudio(bridge, CliMsg.V2_UNGROUP_INSTANCES, { targets, parent, confirm }, 6e4)
815
+ );
816
+ server.registerTool(
817
+ "studio_transform_instances",
818
+ {
819
+ title: "Transform Roblox Models and Parts",
820
+ description: "Move or rotate up to 100 Models and BaseParts through PVInstance:PivotTo. Supports exact absolute CFrames and world/local relative offsets without breaking model layouts.",
821
+ inputSchema: { operations: z.array(transformOperationSchema).min(1).max(100) },
822
+ outputSchema: toolOutputSchema,
823
+ annotations: {
824
+ readOnlyHint: false,
825
+ destructiveHint: false,
826
+ idempotentHint: false,
827
+ openWorldHint: false
828
+ }
829
+ },
830
+ ({ operations }) => callStudio(bridge, CliMsg.V2_TRANSFORM_INSTANCES, { operations }, 6e4)
831
+ );
832
+ server.registerTool(
833
+ "studio_create_welds",
834
+ {
835
+ title: "Create Roblox Welds",
836
+ description: "Create up to 200 WeldConstraints between explicit BasePart refs in one undoable operation.",
837
+ inputSchema: {
838
+ welds: z.array(
839
+ z.object({
840
+ part0: instanceRefSchema,
841
+ part1: instanceRefSchema,
842
+ name: z.string().min(1).max(100).optional(),
843
+ parent: instanceRefSchema.optional()
844
+ })
845
+ ).min(1).max(200)
846
+ },
847
+ outputSchema: toolOutputSchema,
848
+ annotations: {
849
+ readOnlyHint: false,
850
+ destructiveHint: false,
851
+ idempotentHint: false,
852
+ openWorldHint: false
853
+ }
854
+ },
855
+ ({ welds }) => callStudio(bridge, CliMsg.V2_CREATE_WELDS, { welds }, 6e4)
856
+ );
857
+ server.registerTool(
858
+ "studio_query_parts",
859
+ {
860
+ title: "Query Parts in 3D Space",
861
+ description: "Find BaseParts intersecting an oriented box or radius using WorldRoot spatial queries. Returns stable refs, positions, and sizes for inspection or later mutations.",
862
+ inputSchema: {
863
+ root: instanceRefSchema.optional(),
864
+ mode: z.enum(["box", "radius"]).optional().default("box"),
865
+ position: vector3Schema,
866
+ orientation: vector3Schema.optional(),
867
+ size: vector3Schema.optional(),
868
+ radius: z.number().finite().positive().optional(),
869
+ maxParts: z.number().int().min(1).max(500).optional().default(200),
870
+ filter: z.array(instanceRefSchema).max(100).optional(),
871
+ filterType: z.enum(["Include", "Exclude"]).optional().default("Exclude"),
872
+ respectCanCollide: z.boolean().optional().default(false)
873
+ },
874
+ outputSchema: toolOutputSchema,
875
+ annotations: {
876
+ readOnlyHint: true,
877
+ destructiveHint: false,
878
+ idempotentHint: true,
879
+ openWorldHint: false
880
+ }
881
+ },
882
+ ({
883
+ root,
884
+ mode,
885
+ position,
886
+ orientation,
887
+ size,
888
+ radius,
889
+ maxParts,
890
+ filter,
891
+ filterType,
892
+ respectCanCollide
893
+ }) => callStudio(bridge, CliMsg.V2_QUERY_PARTS, {
894
+ root,
895
+ mode,
896
+ position,
897
+ orientation,
898
+ size,
899
+ radius,
900
+ maxParts,
901
+ filter,
902
+ filterType,
903
+ respectCanCollide
904
+ })
905
+ );
906
+ server.registerTool(
907
+ "studio_set_selection",
908
+ {
909
+ title: "Set Roblox Studio Selection",
910
+ description: "Replace the current Studio selection with up to 200 stable instance refs. Pass an empty array to clear selection.",
911
+ inputSchema: { targets: z.array(instanceRefSchema).max(200) },
912
+ outputSchema: toolOutputSchema,
913
+ annotations: {
914
+ readOnlyHint: false,
915
+ destructiveHint: false,
916
+ idempotentHint: true,
917
+ openWorldHint: false
918
+ }
919
+ },
920
+ ({ targets }) => callStudio(bridge, CliMsg.V2_SET_SELECTION, { targets })
921
+ );
922
+ }
998
923
  function registerConnectionTools(server, bridge) {
999
924
  server.registerTool(
1000
925
  "dominus_status",
@@ -1164,10 +1089,10 @@ async function readReferenceYaml(name, kind, opts) {
1164
1089
  const folder = KIND_FOLDERS[kind];
1165
1090
  const fileName = `${name}.yaml`;
1166
1091
  if (localRoot) {
1167
- const localPath = path3.join(localRoot, folder, fileName);
1168
- if (fs3.existsSync(localPath)) {
1092
+ const localPath = path4.join(localRoot, folder, fileName);
1093
+ if (fs4.existsSync(localPath)) {
1169
1094
  return {
1170
- content: fs3.readFileSync(localPath, "utf-8"),
1095
+ content: fs4.readFileSync(localPath, "utf-8"),
1171
1096
  sourceUrl: toCreatorDocsUrl(kind, name)
1172
1097
  };
1173
1098
  }
@@ -1187,11 +1112,11 @@ async function listReferenceEntries(opts) {
1187
1112
  if (localRoot) {
1188
1113
  const entries = [];
1189
1114
  for (const [kind, folder] of Object.entries(KIND_FOLDERS)) {
1190
- const dir = path3.join(localRoot, folder);
1191
- if (!fs3.existsSync(dir)) continue;
1192
- for (const file of fs3.readdirSync(dir)) {
1115
+ const dir = path4.join(localRoot, folder);
1116
+ if (!fs4.existsSync(dir)) continue;
1117
+ for (const file of fs4.readdirSync(dir)) {
1193
1118
  if (!file.endsWith(".yaml")) continue;
1194
- const name = path3.basename(file, ".yaml");
1119
+ const name = path4.basename(file, ".yaml");
1195
1120
  entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
1196
1121
  }
1197
1122
  }
@@ -1204,7 +1129,7 @@ async function listReferenceEntries(opts) {
1204
1129
  const [folder, file] = rest.split("/");
1205
1130
  const kind = folderToKind(folder);
1206
1131
  if (!kind || !file) return [];
1207
- const name = path3.basename(file, ".yaml");
1132
+ const name = path4.basename(file, ".yaml");
1208
1133
  return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
1209
1134
  });
1210
1135
  }
@@ -1224,19 +1149,19 @@ function resolveDocsRoot(explicitRoot) {
1224
1149
  const candidates = [
1225
1150
  explicitRoot,
1226
1151
  process.env.ROBLOX_CREATOR_DOCS_PATH,
1227
- path3.join(process.cwd(), ".tmp", "creator-docs")
1152
+ path4.join(process.cwd(), ".tmp", "creator-docs")
1228
1153
  ].filter(Boolean);
1229
1154
  for (const candidate of candidates) {
1230
1155
  const normalized = normalizeDocsRoot(candidate);
1231
- if (normalized && fs3.existsSync(normalized)) return normalized;
1156
+ if (normalized && fs4.existsSync(normalized)) return normalized;
1232
1157
  }
1233
1158
  return null;
1234
1159
  }
1235
1160
  function normalizeDocsRoot(root) {
1236
- const engineRoot = path3.join(root, "content", "en-us", "reference", "engine");
1237
- if (fs3.existsSync(engineRoot)) return engineRoot;
1238
- const directClasses = path3.join(root, "classes");
1239
- if (fs3.existsSync(directClasses)) return root;
1161
+ const engineRoot = path4.join(root, "content", "en-us", "reference", "engine");
1162
+ if (fs4.existsSync(engineRoot)) return engineRoot;
1163
+ const directClasses = path4.join(root, "classes");
1164
+ if (fs4.existsSync(directClasses)) return root;
1240
1165
  return null;
1241
1166
  }
1242
1167
  function normalizeLookupName(name) {
@@ -1522,7 +1447,7 @@ function registerDocsTools(server) {
1522
1447
  }
1523
1448
  );
1524
1449
  }
1525
- var propertiesSchema = z.record(z.unknown()).refine(
1450
+ var propertiesSchema2 = z.record(z.unknown()).refine(
1526
1451
  (properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
1527
1452
  "At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
1528
1453
  );
@@ -1530,19 +1455,27 @@ var mutationOperationSchema = z.discriminatedUnion("op", [
1530
1455
  z.object({
1531
1456
  op: z.literal("setProperties"),
1532
1457
  target: instanceRefSchema,
1533
- properties: propertiesSchema
1458
+ properties: propertiesSchema2
1534
1459
  }),
1535
1460
  z.object({
1536
1461
  op: z.literal("create"),
1537
1462
  parent: instanceRefSchema,
1538
1463
  className: z.string().min(1).max(100),
1539
1464
  name: z.string().min(1).max(256),
1540
- properties: propertiesSchema.optional()
1465
+ properties: propertiesSchema2.optional()
1541
1466
  }),
1542
1467
  z.object({
1543
1468
  op: z.literal("move"),
1544
1469
  target: instanceRefSchema,
1545
1470
  parent: instanceRefSchema
1471
+ }),
1472
+ z.object({
1473
+ op: z.literal("clone"),
1474
+ target: instanceRefSchema,
1475
+ parent: instanceRefSchema,
1476
+ name: z.string().min(1).max(100),
1477
+ offset: z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).optional(),
1478
+ properties: propertiesSchema2.optional()
1546
1479
  })
1547
1480
  ]);
1548
1481
  var workerProposalSchema = z.object({
@@ -1771,7 +1704,7 @@ async function runWorker(server, bridge, goal, assignment, constraints, taskType
1771
1704
  systemPrompt: [
1772
1705
  "You are a read-only Dominus worker. Inspect only the Studio evidence in the request.",
1773
1706
  "You cannot write to Studio. Return one strict JSON WorkerProposal and no Markdown.",
1774
- "Only propose setProperties, create, or move operations using exact instance refs copied from evidence.",
1707
+ "Only propose setProperties, create, clone, or move operations using exact instance refs copied from evidence.",
1775
1708
  "Never invent instanceId values. Never propose delete, script edits, or arbitrary execution."
1776
1709
  ].join(" "),
1777
1710
  messages: [
@@ -1802,6 +1735,13 @@ async function runWorker(server, bridge, goal, assignment, constraints, taskType
1802
1735
  op: "move",
1803
1736
  target: { instanceId: "uuid", pathSegments: ["..."] },
1804
1737
  parent: { instanceId: "uuid", pathSegments: ["..."] }
1738
+ },
1739
+ {
1740
+ op: "clone",
1741
+ target: { instanceId: "uuid", pathSegments: ["..."] },
1742
+ parent: { instanceId: "uuid", pathSegments: ["..."] },
1743
+ name: "CloneName",
1744
+ offset: { x: 0, y: 0, z: 0 }
1805
1745
  }
1806
1746
  ],
1807
1747
  risks: ["string"],
@@ -1886,7 +1826,7 @@ function validateWorkerResults(results) {
1886
1826
  function validateOperations(assignment, operations) {
1887
1827
  const errors = [];
1888
1828
  for (const [index, operation] of operations.entries()) {
1889
- const refs = operation.op === "create" ? [operation.parent] : operation.op === "move" ? [operation.target, operation.parent] : [operation.target];
1829
+ const refs = operation.op === "create" ? [operation.parent] : operation.op === "move" || operation.op === "clone" ? [operation.target, operation.parent] : [operation.target];
1890
1830
  for (const ref2 of refs) {
1891
1831
  if (!isKnownParallelRef(ref2, assignment.knownRefKeys)) {
1892
1832
  errors.push(`Operation ${index} references an instance absent from worker evidence`);
@@ -1905,7 +1845,7 @@ function rejectProposalConflicts(accepted) {
1905
1845
  const targets = item.proposal.operations.map(operationTarget);
1906
1846
  const conflict = claims.find(
1907
1847
  (claim) => targets.some(
1908
- (target) => target.path && claim.paths.some((path4) => parallelPathsOverlap(target.path, path4)) || claim.refKeys.includes(target.key)
1848
+ (target) => target.path && claim.paths.some((path5) => parallelPathsOverlap(target.path, path5)) || claim.refKeys.includes(target.key)
1909
1849
  )
1910
1850
  );
1911
1851
  if (conflict) {
@@ -2032,12 +1972,14 @@ async function verifyPlan(bridge, rootRef, originalRoot, operations) {
2032
1972
  );
2033
1973
  const knownPaths = collectInstancePaths(originalRoot);
2034
1974
  const expectedCreates = operations.flatMap((operation) => {
2035
- if (operation.op !== "create") return [];
1975
+ if (operation.op !== "create" && operation.op !== "clone") return [];
2036
1976
  const parentPath = resolveRefPath(operation.parent, knownPaths);
2037
1977
  return parentPath ? [pathKey([...parentPath, operation.name])] : [];
2038
1978
  });
2039
- const missingCreates = expectedCreates.filter((path4) => !paths.has(path4));
2040
- const inspectOperations = operations.filter((operation) => operation.op !== "create");
1979
+ const missingCreates = expectedCreates.filter((path5) => !paths.has(path5));
1980
+ const inspectOperations = operations.filter(
1981
+ (operation) => operation.op !== "create" && operation.op !== "clone"
1982
+ );
2041
1983
  const inspectFailures = [];
2042
1984
  const propertyMismatches = [];
2043
1985
  const moveMismatches = [];
@@ -2107,6 +2049,7 @@ function findRepairOperations(operations, verification, originalRoot) {
2107
2049
  const parentPath = resolveRefPath(operation.parent, knownPaths);
2108
2050
  return Boolean(parentPath && missingCreates.has(pathKey([...parentPath, operation.name])));
2109
2051
  }
2052
+ if (operation.op === "clone") return false;
2110
2053
  if (operation.op === "setProperties") return mismatchedTargets.has(refLabel(operation.target));
2111
2054
  return mismatchedMoves.has(refLabel(operation.target));
2112
2055
  });
@@ -2176,7 +2119,7 @@ function isParallelRefWithinScopes(ref2, scopes) {
2176
2119
  return scopes.some((scope) => Boolean(ref2.instanceId && scope.instanceId === ref2.instanceId));
2177
2120
  }
2178
2121
  function operationTarget(operation) {
2179
- if (operation.op === "create") {
2122
+ if (operation.op === "create" || operation.op === "clone") {
2180
2123
  const parentPath = operation.parent.pathSegments;
2181
2124
  return {
2182
2125
  key: `${refKey(operation.parent)}/create:${operation.name}`,
@@ -2204,8 +2147,8 @@ function resolveRefPath(ref2, known) {
2204
2147
  if (ref2.pathSegments) return ref2.pathSegments;
2205
2148
  return ref2.instanceId ? known.get(`id:${ref2.instanceId}`) : void 0;
2206
2149
  }
2207
- function pathKey(path4) {
2208
- return path4.join("\0");
2150
+ function pathKey(path5) {
2151
+ return path5.join("\0");
2209
2152
  }
2210
2153
  function parallelPathIsWithin(target, scope) {
2211
2154
  return target.length >= scope.length && scope.every((segment, index) => target[index] === segment);
@@ -2218,8 +2161,8 @@ function samePath(left, right) {
2218
2161
  left && right && left.length === right.length && left.every((part, index) => part === right[index])
2219
2162
  );
2220
2163
  }
2221
- function parallelRootPathToSegments(path4) {
2222
- const trimmed = path4.trim();
2164
+ function parallelRootPathToSegments(path5) {
2165
+ const trimmed = path5.trim();
2223
2166
  const bracketSegments = [...trimmed.matchAll(/\["((?:\\.|[^"])*)"\]/g)].map(
2224
2167
  (match) => JSON.parse(`"${match[1]}"`)
2225
2168
  );
@@ -2281,37 +2224,46 @@ function emit(server, type, data) {
2281
2224
  function toErrorMessage(error) {
2282
2225
  return error instanceof Error ? error.message : String(error);
2283
2226
  }
2284
- var propertiesSchema2 = z.record(z.unknown()).refine(
2227
+ var propertiesSchema3 = z.record(z.unknown()).refine(
2285
2228
  (properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
2286
2229
  "At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
2287
2230
  );
2288
2231
  var setPropertiesOperation = z.object({
2289
2232
  op: z.literal("setProperties"),
2290
2233
  target: instanceRefSchema,
2291
- properties: propertiesSchema2
2234
+ properties: propertiesSchema3
2292
2235
  });
2293
2236
  var createOperation = z.object({
2294
2237
  op: z.literal("create"),
2295
2238
  parent: instanceRefSchema,
2296
2239
  className: z.string().min(1).max(100),
2297
2240
  name: z.string().min(1).max(256),
2298
- properties: propertiesSchema2.optional()
2241
+ properties: propertiesSchema3.optional()
2299
2242
  });
2300
2243
  var moveOperation = z.object({
2301
2244
  op: z.literal("move"),
2302
2245
  target: instanceRefSchema,
2303
2246
  parent: instanceRefSchema
2304
2247
  });
2248
+ var cloneOperation = z.object({
2249
+ op: z.literal("clone"),
2250
+ target: instanceRefSchema,
2251
+ parent: instanceRefSchema.optional(),
2252
+ name: z.string().min(1).max(100).optional(),
2253
+ offset: z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).optional(),
2254
+ properties: propertiesSchema3.optional()
2255
+ });
2305
2256
  var mutationOperation = z.discriminatedUnion("op", [
2306
2257
  setPropertiesOperation,
2307
2258
  createOperation,
2259
+ cloneOperation,
2308
2260
  moveOperation
2309
2261
  ]);
2310
2262
  var uiNodeSchema = z.lazy(
2311
2263
  () => z.object({
2312
2264
  ClassName: z.string().min(1).max(100),
2313
2265
  Name: z.string().min(1).max(256).optional(),
2314
- properties: propertiesSchema2.optional(),
2266
+ properties: propertiesSchema3.optional(),
2315
2267
  Children: z.array(uiNodeSchema).max(1e3).optional()
2316
2268
  })
2317
2269
  );
@@ -2415,7 +2367,7 @@ function registerStudioTools(server, bridge) {
2415
2367
  "studio_apply",
2416
2368
  {
2417
2369
  title: "Apply Atomic Studio Mutations",
2418
- description: "Atomically set properties, create instances, and move instances. Any failed operation cancels and undoes the entire batch.",
2370
+ description: "Atomically set properties, create instances, deep-clone instances, and move instances. Any failed operation cancels and undoes the entire batch.",
2419
2371
  inputSchema: { operations: z.array(mutationOperation).min(1).max(100) },
2420
2372
  outputSchema: toolOutputSchema,
2421
2373
  annotations: {
@@ -2556,7 +2508,7 @@ function registerStudioTools(server, bridge) {
2556
2508
  }
2557
2509
 
2558
2510
  // src/mcp/server.ts
2559
- var VERSION = "2.2.0";
2511
+ var VERSION = "2.3.0";
2560
2512
  function createDominusMcpServer(bridge) {
2561
2513
  const server = new McpServer(
2562
2514
  {
@@ -2569,28 +2521,14 @@ function createDominusMcpServer(bridge) {
2569
2521
  );
2570
2522
  registerConnectionTools(server, bridge);
2571
2523
  registerStudioTools(server, bridge);
2524
+ registerBuildingTools(server, bridge);
2572
2525
  registerParallelTaskTool(server, bridge);
2573
2526
  registerDocsTools(server);
2574
2527
  registerDominusResources(server, bridge);
2575
2528
  return server;
2576
2529
  }
2577
2530
  async function createStudioBridge(port) {
2578
- if (await isPortInUse(port)) {
2579
- const client = new DominusClient(port);
2580
- await client.connect();
2581
- return client;
2582
- }
2583
- const server = new DominusServer(port);
2584
- try {
2585
- await server.start();
2586
- return server;
2587
- } catch (err) {
2588
- const error = err;
2589
- if (error.code !== "EADDRINUSE") throw err;
2590
- const client = new DominusClient(port);
2591
- await client.connect();
2592
- return client;
2593
- }
2531
+ return ensureBridgeDaemon(port);
2594
2532
  }
2595
2533
  async function startMcpServer() {
2596
2534
  const config = loadConfig();