dominus-cli 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {
@@ -144,27 +145,21 @@ var wsMessageSchema = z.object({
144
145
  }).strict();
145
146
  var TOKEN_FILE = "bridge-token";
146
147
  function loadOrCreateBridgeToken() {
147
- const tokenPath = path3.join(getDominusDir(), TOKEN_FILE);
148
+ const tokenPath = path4.join(getDominusDir(), TOKEN_FILE);
148
149
  try {
149
- const existing = fs3.readFileSync(tokenPath, "utf8").trim();
150
+ const existing = fs4.readFileSync(tokenPath, "utf8").trim();
150
151
  if (existing.length >= 32) return existing;
151
152
  } catch {
152
153
  }
153
154
  const token = crypto.randomBytes(32).toString("base64url");
154
- fs3.writeFileSync(tokenPath, `${token}
155
+ fs4.writeFileSync(tokenPath, `${token}
155
156
  `, { encoding: "utf8", mode: 384 });
156
157
  try {
157
- fs3.chmodSync(tokenPath, 384);
158
+ fs4.chmodSync(tokenPath, 384);
158
159
  } catch {
159
160
  }
160
161
  return token;
161
162
  }
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
163
 
169
164
  // src/transport/ws-client.ts
170
165
  var DominusClient = class extends EventEmitter {
@@ -197,10 +192,14 @@ var DominusClient = class extends EventEmitter {
197
192
  }, this.connectTimeoutMs);
198
193
  ws.on("open", () => {
199
194
  this.ws = ws;
200
- ws.send(serializeMessage(createMessage(ControllerMsg.HELLO, {
201
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
202
- token: this.token
203
- })));
195
+ ws.send(
196
+ serializeMessage(
197
+ createMessage(ControllerMsg.HELLO, {
198
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
199
+ token: this.token
200
+ })
201
+ )
202
+ );
204
203
  });
205
204
  ws.on("message", (raw) => {
206
205
  try {
@@ -341,7 +340,11 @@ var DominusClient = class extends EventEmitter {
341
340
  sendRequest(type, payload, timeoutMs = 15e3) {
342
341
  const target = this.resolveTargetInfo();
343
342
  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"));
343
+ return Promise.reject(
344
+ new Error(
345
+ this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"
346
+ )
347
+ );
345
348
  }
346
349
  const request = createMessage(type, payload);
347
350
  const relay = createMessage(ControllerMsg.RELAY, {
@@ -370,10 +373,12 @@ var DominusClient = class extends EventEmitter {
370
373
  sendNotification(type, payload) {
371
374
  if (!this.activeConnectionId) return;
372
375
  const request = createMessage(type, payload);
373
- this.sendMessage(createMessage(ControllerMsg.RELAY, {
374
- connectionId: this.activeConnectionId,
375
- request
376
- }));
376
+ this.sendMessage(
377
+ createMessage(ControllerMsg.RELAY, {
378
+ connectionId: this.activeConnectionId,
379
+ request
380
+ })
381
+ );
377
382
  }
378
383
  isConnected() {
379
384
  return this.resolveTargetInfo() !== null;
@@ -412,9 +417,13 @@ var DominusClient = class extends EventEmitter {
412
417
  void this.setActiveConnectionId(null);
413
418
  return;
414
419
  }
415
- const matches = [...this.connections.values()].filter((connection) => connection.placeId === placeId);
420
+ const matches = [...this.connections.values()].filter(
421
+ (connection) => connection.placeId === placeId
422
+ );
416
423
  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`);
424
+ throw new Error(
425
+ matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
426
+ );
418
427
  }
419
428
  void this.setActiveConnectionId(matches[0].connectionId);
420
429
  }
@@ -475,413 +484,74 @@ async function isPortInUse(port) {
475
484
  });
476
485
  });
477
486
  }
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);
487
+
488
+ // src/transport/daemon-launcher.ts
489
+ function resolveDaemonPath() {
490
+ const besideEntry = fileURLToPath(new URL("./bridge-daemon.js", import.meta.url));
491
+ if (fs4.existsSync(besideEntry)) return besideEntry;
492
+ const projectBuild = path4.resolve(
493
+ path4.dirname(fileURLToPath(import.meta.url)),
494
+ "../../dist/bridge-daemon.js"
495
+ );
496
+ if (fs4.existsSync(projectBuild)) return projectBuild;
497
+ throw new Error("Dominus bridge daemon is missing. Reinstall or rebuild dominus-cli.");
498
+ }
499
+ function launchBridgeDaemon(port, daemonPath = resolveDaemonPath()) {
500
+ const child = spawn(process.execPath, [daemonPath], {
501
+ detached: true,
502
+ env: { ...process.env, DOMINUS_BRIDGE_PORT: String(port) },
503
+ stdio: "ignore",
504
+ windowsHide: true
505
+ });
506
+ child.unref();
507
+ }
508
+ async function connectController(port, timeoutMs, token) {
509
+ const client = new DominusClient(port, { connectTimeoutMs: timeoutMs, token });
510
+ try {
511
+ await client.connect();
512
+ return client;
513
+ } catch (error) {
514
+ await client.stop().catch(() => void 0);
515
+ throw error;
694
516
  }
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;
517
+ }
518
+ async function ensureBridgeDaemon(port, options = {}) {
519
+ const attempts = options.attempts ?? 30;
520
+ const connectTimeoutMs = options.connectTimeoutMs ?? 750;
521
+ const retryDelayMs = options.retryDelayMs ?? 100;
522
+ try {
523
+ return await connectController(port, connectTimeoutMs, options.token);
524
+ } catch (initialError) {
525
+ if (await isPortInUse(port)) {
526
+ throw new Error(
527
+ `Port ${port} is occupied by a process that is not this Dominus bridge: ${formatError(initialError)}`
528
+ );
740
529
  }
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
530
  }
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
- }
772
- sendSafe(ws, message) {
773
- if (ws.readyState !== WebSocket.OPEN) return;
531
+ const launch = options.launch ?? ((targetPort) => launchBridgeDaemon(targetPort, options.daemonPath));
532
+ launch(port);
533
+ let lastError;
534
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
535
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
774
536
  try {
775
- ws.send(serializeMessage(message));
776
- } catch (err) {
777
- this.emit("server:error", err);
537
+ return await connectController(port, connectTimeoutMs, options.token);
538
+ } catch (error) {
539
+ lastError = error;
778
540
  }
779
541
  }
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`);
835
- }
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
- }
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;
542
+ throw new Error(
543
+ `Dominus could not start its local Studio bridge on port ${port}: ${formatError(lastError)}`
544
+ );
873
545
  }
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;
546
+ function formatError(error) {
547
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
878
548
  }
879
549
 
880
550
  // src/mcp/instructions.ts
881
551
  var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
882
552
 
883
553
  Required workflow for Studio changes:
884
- 1. Call dominus_status and select the intended Studio connection when more than one is open.
554
+ 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
555
  2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
886
556
  3. Form a short plan with explicit acceptance checks.
887
557
  4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
@@ -1164,10 +834,10 @@ async function readReferenceYaml(name, kind, opts) {
1164
834
  const folder = KIND_FOLDERS[kind];
1165
835
  const fileName = `${name}.yaml`;
1166
836
  if (localRoot) {
1167
- const localPath = path3.join(localRoot, folder, fileName);
1168
- if (fs3.existsSync(localPath)) {
837
+ const localPath = path4.join(localRoot, folder, fileName);
838
+ if (fs4.existsSync(localPath)) {
1169
839
  return {
1170
- content: fs3.readFileSync(localPath, "utf-8"),
840
+ content: fs4.readFileSync(localPath, "utf-8"),
1171
841
  sourceUrl: toCreatorDocsUrl(kind, name)
1172
842
  };
1173
843
  }
@@ -1187,11 +857,11 @@ async function listReferenceEntries(opts) {
1187
857
  if (localRoot) {
1188
858
  const entries = [];
1189
859
  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)) {
860
+ const dir = path4.join(localRoot, folder);
861
+ if (!fs4.existsSync(dir)) continue;
862
+ for (const file of fs4.readdirSync(dir)) {
1193
863
  if (!file.endsWith(".yaml")) continue;
1194
- const name = path3.basename(file, ".yaml");
864
+ const name = path4.basename(file, ".yaml");
1195
865
  entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
1196
866
  }
1197
867
  }
@@ -1204,7 +874,7 @@ async function listReferenceEntries(opts) {
1204
874
  const [folder, file] = rest.split("/");
1205
875
  const kind = folderToKind(folder);
1206
876
  if (!kind || !file) return [];
1207
- const name = path3.basename(file, ".yaml");
877
+ const name = path4.basename(file, ".yaml");
1208
878
  return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
1209
879
  });
1210
880
  }
@@ -1224,19 +894,19 @@ function resolveDocsRoot(explicitRoot) {
1224
894
  const candidates = [
1225
895
  explicitRoot,
1226
896
  process.env.ROBLOX_CREATOR_DOCS_PATH,
1227
- path3.join(process.cwd(), ".tmp", "creator-docs")
897
+ path4.join(process.cwd(), ".tmp", "creator-docs")
1228
898
  ].filter(Boolean);
1229
899
  for (const candidate of candidates) {
1230
900
  const normalized = normalizeDocsRoot(candidate);
1231
- if (normalized && fs3.existsSync(normalized)) return normalized;
901
+ if (normalized && fs4.existsSync(normalized)) return normalized;
1232
902
  }
1233
903
  return null;
1234
904
  }
1235
905
  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;
906
+ const engineRoot = path4.join(root, "content", "en-us", "reference", "engine");
907
+ if (fs4.existsSync(engineRoot)) return engineRoot;
908
+ const directClasses = path4.join(root, "classes");
909
+ if (fs4.existsSync(directClasses)) return root;
1240
910
  return null;
1241
911
  }
1242
912
  function normalizeLookupName(name) {
@@ -1905,7 +1575,7 @@ function rejectProposalConflicts(accepted) {
1905
1575
  const targets = item.proposal.operations.map(operationTarget);
1906
1576
  const conflict = claims.find(
1907
1577
  (claim) => targets.some(
1908
- (target) => target.path && claim.paths.some((path4) => parallelPathsOverlap(target.path, path4)) || claim.refKeys.includes(target.key)
1578
+ (target) => target.path && claim.paths.some((path5) => parallelPathsOverlap(target.path, path5)) || claim.refKeys.includes(target.key)
1909
1579
  )
1910
1580
  );
1911
1581
  if (conflict) {
@@ -2036,7 +1706,7 @@ async function verifyPlan(bridge, rootRef, originalRoot, operations) {
2036
1706
  const parentPath = resolveRefPath(operation.parent, knownPaths);
2037
1707
  return parentPath ? [pathKey([...parentPath, operation.name])] : [];
2038
1708
  });
2039
- const missingCreates = expectedCreates.filter((path4) => !paths.has(path4));
1709
+ const missingCreates = expectedCreates.filter((path5) => !paths.has(path5));
2040
1710
  const inspectOperations = operations.filter((operation) => operation.op !== "create");
2041
1711
  const inspectFailures = [];
2042
1712
  const propertyMismatches = [];
@@ -2204,8 +1874,8 @@ function resolveRefPath(ref2, known) {
2204
1874
  if (ref2.pathSegments) return ref2.pathSegments;
2205
1875
  return ref2.instanceId ? known.get(`id:${ref2.instanceId}`) : void 0;
2206
1876
  }
2207
- function pathKey(path4) {
2208
- return path4.join("\0");
1877
+ function pathKey(path5) {
1878
+ return path5.join("\0");
2209
1879
  }
2210
1880
  function parallelPathIsWithin(target, scope) {
2211
1881
  return target.length >= scope.length && scope.every((segment, index) => target[index] === segment);
@@ -2218,8 +1888,8 @@ function samePath(left, right) {
2218
1888
  left && right && left.length === right.length && left.every((part, index) => part === right[index])
2219
1889
  );
2220
1890
  }
2221
- function parallelRootPathToSegments(path4) {
2222
- const trimmed = path4.trim();
1891
+ function parallelRootPathToSegments(path5) {
1892
+ const trimmed = path5.trim();
2223
1893
  const bracketSegments = [...trimmed.matchAll(/\["((?:\\.|[^"])*)"\]/g)].map(
2224
1894
  (match) => JSON.parse(`"${match[1]}"`)
2225
1895
  );
@@ -2556,7 +2226,7 @@ function registerStudioTools(server, bridge) {
2556
2226
  }
2557
2227
 
2558
2228
  // src/mcp/server.ts
2559
- var VERSION = "2.2.0";
2229
+ var VERSION = "2.2.1";
2560
2230
  function createDominusMcpServer(bridge) {
2561
2231
  const server = new McpServer(
2562
2232
  {
@@ -2575,22 +2245,7 @@ function createDominusMcpServer(bridge) {
2575
2245
  return server;
2576
2246
  }
2577
2247
  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
- }
2248
+ return ensureBridgeDaemon(port);
2594
2249
  }
2595
2250
  async function startMcpServer() {
2596
2251
  const config = loadConfig();