dominus-cli 2.1.1 → 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 {
@@ -76,6 +77,32 @@ var ControllerMsg = {
76
77
  SET_ACTIVE_CONNECTION: "controller:set_active_connection"
77
78
  };
78
79
  var CliMsg = {
80
+ GET_EXPLORER: "cli:get:explorer",
81
+ GET_SCRIPT: "cli:get:script",
82
+ SET_SCRIPT: "cli:set:script",
83
+ RUN_CODE: "cli:run",
84
+ GET_PROPERTIES: "cli:get:properties",
85
+ GET_DESCENDANTS_PROPERTIES: "cli:get:descendants:properties",
86
+ SET_PROPERTIES: "cli:set:properties",
87
+ INSERT_INSTANCE: "cli:insert",
88
+ DELETE_INSTANCE: "cli:delete",
89
+ GET_SELECTION: "cli:get:selection",
90
+ SELECT: "cli:select",
91
+ SEARCH_SCRIPTS: "cli:search:scripts",
92
+ GET_OUTPUT: "cli:get:output",
93
+ RUN_TESTS: "cli:run:tests",
94
+ EXECUTE_RUN_TEST: "cli:test:run",
95
+ EXECUTE_PLAY_TEST: "cli:test:play",
96
+ END_TEST: "cli:test:end",
97
+ BUILD_UI: "cli:build:ui",
98
+ GET_REFLECTION: "cli:get:reflection",
99
+ UPLOAD_ASSET: "cli:upload:asset",
100
+ SERIALIZE_UI: "cli:serialize:ui",
101
+ MOVE_INSTANCE: "cli:move:instance",
102
+ UNDO: "cli:undo",
103
+ REDO: "cli:redo",
104
+ BULK_SET_PROPERTIES: "cli:bulk:set:properties",
105
+ FIND_REPLACE_SCRIPTS: "cli:find:replace:scripts",
79
106
  V2_GET_TREE: "studio:v2:get_tree",
80
107
  V2_INSPECT: "studio:v2:inspect",
81
108
  V2_GET_SELECTION: "studio:v2:get_selection",
@@ -118,27 +145,21 @@ var wsMessageSchema = z.object({
118
145
  }).strict();
119
146
  var TOKEN_FILE = "bridge-token";
120
147
  function loadOrCreateBridgeToken() {
121
- const tokenPath = path3.join(getDominusDir(), TOKEN_FILE);
148
+ const tokenPath = path4.join(getDominusDir(), TOKEN_FILE);
122
149
  try {
123
- const existing = fs3.readFileSync(tokenPath, "utf8").trim();
150
+ const existing = fs4.readFileSync(tokenPath, "utf8").trim();
124
151
  if (existing.length >= 32) return existing;
125
152
  } catch {
126
153
  }
127
154
  const token = crypto.randomBytes(32).toString("base64url");
128
- fs3.writeFileSync(tokenPath, `${token}
155
+ fs4.writeFileSync(tokenPath, `${token}
129
156
  `, { encoding: "utf8", mode: 384 });
130
157
  try {
131
- fs3.chmodSync(tokenPath, 384);
158
+ fs4.chmodSync(tokenPath, 384);
132
159
  } catch {
133
160
  }
134
161
  return token;
135
162
  }
136
- function tokensEqual(actual, expected) {
137
- if (typeof actual !== "string") return false;
138
- const actualBuffer = Buffer.from(actual);
139
- const expectedBuffer = Buffer.from(expected);
140
- return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
141
- }
142
163
 
143
164
  // src/transport/ws-client.ts
144
165
  var DominusClient = class extends EventEmitter {
@@ -171,10 +192,14 @@ var DominusClient = class extends EventEmitter {
171
192
  }, this.connectTimeoutMs);
172
193
  ws.on("open", () => {
173
194
  this.ws = ws;
174
- ws.send(serializeMessage(createMessage(ControllerMsg.HELLO, {
175
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
176
- token: this.token
177
- })));
195
+ ws.send(
196
+ serializeMessage(
197
+ createMessage(ControllerMsg.HELLO, {
198
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
199
+ token: this.token
200
+ })
201
+ )
202
+ );
178
203
  });
179
204
  ws.on("message", (raw) => {
180
205
  try {
@@ -315,7 +340,11 @@ var DominusClient = class extends EventEmitter {
315
340
  sendRequest(type, payload, timeoutMs = 15e3) {
316
341
  const target = this.resolveTargetInfo();
317
342
  if (!target || !this.activeConnectionId) {
318
- 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
+ );
319
348
  }
320
349
  const request = createMessage(type, payload);
321
350
  const relay = createMessage(ControllerMsg.RELAY, {
@@ -344,10 +373,12 @@ var DominusClient = class extends EventEmitter {
344
373
  sendNotification(type, payload) {
345
374
  if (!this.activeConnectionId) return;
346
375
  const request = createMessage(type, payload);
347
- this.sendMessage(createMessage(ControllerMsg.RELAY, {
348
- connectionId: this.activeConnectionId,
349
- request
350
- }));
376
+ this.sendMessage(
377
+ createMessage(ControllerMsg.RELAY, {
378
+ connectionId: this.activeConnectionId,
379
+ request
380
+ })
381
+ );
351
382
  }
352
383
  isConnected() {
353
384
  return this.resolveTargetInfo() !== null;
@@ -386,9 +417,13 @@ var DominusClient = class extends EventEmitter {
386
417
  void this.setActiveConnectionId(null);
387
418
  return;
388
419
  }
389
- const matches = [...this.connections.values()].filter((connection) => connection.placeId === placeId);
420
+ const matches = [...this.connections.values()].filter(
421
+ (connection) => connection.placeId === placeId
422
+ );
390
423
  if (matches.length !== 1) {
391
- 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
+ );
392
427
  }
393
428
  void this.setActiveConnectionId(matches[0].connectionId);
394
429
  }
@@ -449,413 +484,74 @@ async function isPortInUse(port) {
449
484
  });
450
485
  });
451
486
  }
452
- var HANDSHAKE_TIMEOUT_MS = 8e3;
453
- var RATE_WINDOW_MS = 1e4;
454
- var RATE_LIMIT = 250;
455
- var DominusServer = class extends EventEmitter {
456
- wss = null;
457
- studioClients = /* @__PURE__ */ new Map();
458
- wsToConnectionId = /* @__PURE__ */ new Map();
459
- controllers = /* @__PURE__ */ new Set();
460
- socketRoles = /* @__PURE__ */ new Map();
461
- pendingRequests = /* @__PURE__ */ new Map();
462
- relayRequests = /* @__PURE__ */ new Map();
463
- rateState = /* @__PURE__ */ new Map();
464
- token;
465
- host;
466
- maxPayloadBytes;
467
- handshakeTimeoutMs;
468
- activeConnectionId = null;
469
- port;
470
- constructor(port = 18088, options = {}) {
471
- super();
472
- this.port = port;
473
- this.token = options.token ?? loadOrCreateBridgeToken();
474
- this.host = options.host ?? "127.0.0.1";
475
- this.maxPayloadBytes = options.maxPayloadBytes ?? MAX_MESSAGE_BYTES;
476
- this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
477
- }
478
- start(maxRetries = 0) {
479
- return new Promise((resolve, reject) => {
480
- const tryPort = (port, attempt) => {
481
- const wss = new WebSocketServer({
482
- port,
483
- host: this.host,
484
- maxPayload: this.maxPayloadBytes,
485
- perMessageDeflate: false
486
- });
487
- wss.on("listening", () => {
488
- this.wss = wss;
489
- this.port = port;
490
- wss.on("connection", (ws) => this.handleConnection(ws));
491
- this.emit("server:started", { port, host: this.host });
492
- resolve();
493
- });
494
- wss.on("error", (err) => {
495
- if (err.code === "EADDRINUSE" && attempt < maxRetries) {
496
- wss.close();
497
- tryPort(port + 1, attempt + 1);
498
- return;
499
- }
500
- this.emit("server:error", err);
501
- reject(err);
502
- });
503
- };
504
- tryPort(this.port, 0);
505
- });
506
- }
507
- handleConnection(ws) {
508
- this.socketRoles.set(ws, "unknown");
509
- this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
510
- const handshakeTimer = setTimeout(() => {
511
- if (this.socketRoles.get(ws) === "unknown") {
512
- ws.close(1008, "Dominus handshake timed out");
513
- }
514
- }, this.handshakeTimeoutMs);
515
- ws.on("message", (raw) => {
516
- try {
517
- if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
518
- ws.close(1009, "Dominus message limit exceeded");
519
- return;
520
- }
521
- const msg = parseMessage(raw.toString());
522
- const role = this.socketRoles.get(ws) ?? "unknown";
523
- if (role === "unknown") {
524
- clearTimeout(handshakeTimer);
525
- this.handleHandshake(ws, msg);
526
- } else if (role === "controller") {
527
- this.handleControllerMessage(ws, msg);
528
- } else if (role === "studio") {
529
- this.handleStudioMessage(ws, msg);
530
- }
531
- } catch (err) {
532
- this.emit("server:error", err);
533
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
534
- error: err instanceof Error ? err.message : String(err)
535
- }));
536
- }
537
- });
538
- ws.on("close", () => {
539
- clearTimeout(handshakeTimer);
540
- this.cleanupSocket(ws);
541
- });
542
- ws.on("error", (err) => this.emit("server:error", err));
543
- }
544
- consumeRateBudget(ws) {
545
- const now = Date.now();
546
- const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
547
- if (now - state.startedAt >= RATE_WINDOW_MS) {
548
- state.startedAt = now;
549
- state.count = 0;
550
- }
551
- state.count += 1;
552
- this.rateState.set(ws, state);
553
- return state.count <= RATE_LIMIT;
554
- }
555
- handleHandshake(ws, msg) {
556
- if (msg.type === StudioMsg.HELLO) {
557
- const hello = validateStudioHello(msg.payload);
558
- if (tokensEqual(hello.token, this.token)) {
559
- this.authorizeStudio(ws, hello, randomUUID());
560
- } else {
561
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
562
- error: "Studio plugin credentials are missing or stale. Run dominus-install-plugin and restart Studio."
563
- }));
564
- ws.close(1008, "Unauthorized Studio plugin");
565
- }
566
- return;
567
- }
568
- if (msg.type === ControllerMsg.HELLO) {
569
- const payload = msg.payload;
570
- if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
571
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, { error: "Invalid Dominus controller credentials" }));
572
- ws.close(1008, "Unauthorized controller");
573
- return;
574
- }
575
- this.socketRoles.set(ws, "controller");
576
- this.controllers.add(ws);
577
- this.sendSafe(ws, createMessage(ControllerMsg.WELCOME, {
578
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
579
- port: this.port,
580
- connections: this.listConnections(),
581
- activeConnectionId: this.activeConnectionId
582
- }));
583
- return;
584
- }
585
- this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
586
- error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
587
- }));
588
- ws.close(1008, "Invalid Dominus handshake");
589
- }
590
- authorizeStudio(ws, hello, connectionId) {
591
- const connection = {
592
- ws,
593
- connectionId,
594
- clientId: hello.clientId,
595
- connectedAt: Date.now(),
596
- studioVersion: hello.studioVersion,
597
- placeId: hello.placeId ?? 0,
598
- placeName: hello.placeName
599
- };
600
- this.studioClients.set(connectionId, connection);
601
- this.wsToConnectionId.set(ws, connectionId);
602
- this.socketRoles.set(ws, "studio");
603
- if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
604
- this.activeConnectionId = connectionId;
605
- }
606
- this.sendSafe(ws, createMessage(StudioMsg.AUTHENTICATED, {
607
- protocolVersion: DOMINUS_PROTOCOL_VERSION,
608
- connectionId,
609
- token: this.token
610
- }));
611
- const info = this.toPlaceInfo(connection);
612
- this.emit("studio:connected", info);
613
- this.broadcastToControllers(StudioMsg.CONNECTED, info);
614
- this.broadcastTargetState();
615
- return connection;
616
- }
617
- handleControllerMessage(controllerWs, msg) {
618
- if (msg.type === ControllerMsg.RELAY) {
619
- const payload = msg.payload;
620
- const request = parseMessage(JSON.stringify(payload.request));
621
- const target = this.resolveConnection(typeof payload.connectionId === "string" ? payload.connectionId : null);
622
- if (!target) {
623
- this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
624
- return;
625
- }
626
- this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
627
- this.sendSafe(target.ws, request);
628
- return;
629
- }
630
- if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
631
- const payload = msg.payload;
632
- const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
633
- if (connectionId && !this.studioClients.has(connectionId)) {
634
- this.sendResponse(controllerWs, msg.id, { success: false, error: `Studio connection ${connectionId} is not connected` });
635
- return;
636
- }
637
- this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
638
- return;
639
- }
640
- this.sendResponse(controllerWs, msg.id, { error: `Unsupported controller message: ${msg.type}` });
641
- }
642
- handleStudioMessage(ws, msg) {
643
- if (msg.type === StudioMsg.RESPONSE) {
644
- const relay = this.relayRequests.get(msg.id);
645
- if (relay) {
646
- if (relay.targetWs !== ws) {
647
- this.emit("server:error", new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`));
648
- return;
649
- }
650
- this.sendSafe(relay.controllerWs, msg);
651
- this.relayRequests.delete(msg.id);
652
- return;
653
- }
654
- const pending = this.pendingRequests.get(msg.id);
655
- if (pending) {
656
- if (pending.targetWs !== ws) {
657
- this.emit("server:error", new Error(`Ignored response ${msg.id} from the wrong Studio session`));
658
- return;
659
- }
660
- clearTimeout(pending.timer);
661
- this.pendingRequests.delete(msg.id);
662
- pending.resolve(msg);
663
- }
664
- return;
665
- }
666
- this.emit(msg.type, msg.payload);
667
- 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;
668
516
  }
669
- cleanupSocket(ws) {
670
- const role = this.socketRoles.get(ws);
671
- this.socketRoles.delete(ws);
672
- this.rateState.delete(ws);
673
- if (role === "controller") {
674
- this.controllers.delete(ws);
675
- for (const [id, relay] of this.relayRequests) {
676
- if (relay.controllerWs === ws) this.relayRequests.delete(id);
677
- }
678
- return;
679
- }
680
- const connectionId = this.wsToConnectionId.get(ws);
681
- if (!connectionId) return;
682
- const connection = this.studioClients.get(connectionId);
683
- this.wsToConnectionId.delete(ws);
684
- this.studioClients.delete(connectionId);
685
- for (const [id, pending] of this.pendingRequests) {
686
- if (pending.targetWs === ws) {
687
- clearTimeout(pending.timer);
688
- pending.reject(new Error(`Studio disconnected (${connectionId})`));
689
- this.pendingRequests.delete(id);
690
- }
691
- }
692
- for (const [id, relay] of this.relayRequests) {
693
- if (relay.targetWs === ws) {
694
- this.sendResponse(relay.controllerWs, id, { error: `Studio disconnected (${connectionId})` });
695
- this.relayRequests.delete(id);
696
- }
697
- }
698
- if (this.activeConnectionId === connectionId) {
699
- this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
700
- }
701
- const info = connection ? this.toPlaceInfo(connection) : { connectionId };
702
- this.emit("studio:disconnected", info);
703
- this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
704
- this.broadcastTargetState();
705
- }
706
- resolveConnection(connectionId = this.activeConnectionId) {
707
- if (connectionId) {
708
- const connection = this.studioClients.get(connectionId);
709
- return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
710
- }
711
- if (this.studioClients.size === 1) {
712
- const connection = this.studioClients.values().next().value;
713
- 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
+ );
714
529
  }
715
- return null;
716
530
  }
717
- getTargetError() {
718
- if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
719
- if (this.activeConnectionId) return `Studio connection ${this.activeConnectionId} is unavailable`;
720
- return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
721
- }
722
- toPlaceInfo(connection) {
723
- return {
724
- placeId: connection.placeId ?? 0,
725
- connectionId: connection.connectionId,
726
- clientId: connection.clientId,
727
- placeName: connection.placeName,
728
- studioVersion: connection.studioVersion,
729
- connectedAt: connection.connectedAt,
730
- active: connection.connectionId === this.activeConnectionId
731
- };
732
- }
733
- broadcastToControllers(type, payload) {
734
- const message = createMessage(type, payload);
735
- for (const controller of this.controllers) this.sendSafe(controller, message);
736
- }
737
- broadcastTargetState() {
738
- this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
739
- connections: this.listConnections(),
740
- defaultActiveConnectionId: this.activeConnectionId
741
- });
742
- }
743
- sendResponse(ws, id, payload) {
744
- this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
745
- }
746
- sendSafe(ws, message) {
747
- 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));
748
536
  try {
749
- ws.send(serializeMessage(message));
750
- } catch (err) {
751
- this.emit("server:error", err);
537
+ return await connectController(port, connectTimeoutMs, options.token);
538
+ } catch (error) {
539
+ lastError = error;
752
540
  }
753
541
  }
754
- sendRequest(type, payload, timeoutMs = 15e3) {
755
- return new Promise((resolve, reject) => {
756
- const target = this.resolveConnection();
757
- if (!target) {
758
- reject(new Error(this.getTargetError()));
759
- return;
760
- }
761
- const message = createMessage(type, payload);
762
- const timer = setTimeout(() => {
763
- this.pendingRequests.delete(message.id);
764
- reject(new Error(`Studio request timed out: ${type}`));
765
- }, timeoutMs);
766
- this.pendingRequests.set(message.id, {
767
- resolve,
768
- reject,
769
- timer,
770
- targetWs: target.ws
771
- });
772
- this.sendSafe(target.ws, message);
773
- });
774
- }
775
- sendNotification(type, payload) {
776
- const target = this.resolveConnection();
777
- if (target) this.sendSafe(target.ws, createMessage(type, payload));
778
- }
779
- isConnected() {
780
- return this.resolveConnection() !== null;
781
- }
782
- getConnectionInfo() {
783
- return this.resolveConnection();
784
- }
785
- listConnections() {
786
- return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
787
- }
788
- getActiveConnectionId() {
789
- return this.activeConnectionId;
790
- }
791
- async setActiveConnectionId(connectionId) {
792
- if (connectionId && !this.studioClients.has(connectionId)) {
793
- throw new Error(`Studio connection ${connectionId} is not connected`);
794
- }
795
- this.activeConnectionId = connectionId;
796
- this.broadcastTargetState();
797
- }
798
- getActivePlaceId() {
799
- return this.resolveConnection()?.placeId ?? 0;
800
- }
801
- setActivePlaceId(placeId) {
802
- if (placeId === 0) {
803
- this.activeConnectionId = null;
804
- return;
805
- }
806
- const matches = [...this.studioClients.values()].filter((connection) => connection.placeId === placeId);
807
- if (matches.length !== 1) {
808
- throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
809
- }
810
- this.activeConnectionId = matches[0].connectionId;
811
- }
812
- getPort() {
813
- return this.port;
814
- }
815
- stop() {
816
- return new Promise((resolve) => {
817
- for (const pending of this.pendingRequests.values()) {
818
- clearTimeout(pending.timer);
819
- pending.reject(new Error("Dominus bridge is shutting down"));
820
- }
821
- this.pendingRequests.clear();
822
- this.relayRequests.clear();
823
- for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
824
- this.studioClients.clear();
825
- this.controllers.clear();
826
- this.socketRoles.clear();
827
- this.wsToConnectionId.clear();
828
- if (this.wss) this.wss.close(() => resolve());
829
- else resolve();
830
- });
831
- }
832
- };
833
- function validateStudioHello(payload) {
834
- if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
835
- const hello = payload;
836
- if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
837
- throw new Error(`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`);
838
- }
839
- if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
840
- throw new Error("Studio hello is missing a valid clientId");
841
- }
842
- if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
843
- throw new Error("Studio hello is missing studioVersion");
844
- }
845
- if (hello.placeName && hello.placeName.length > 256) throw new Error("Studio placeName is too long");
846
- return hello;
542
+ throw new Error(
543
+ `Dominus could not start its local Studio bridge on port ${port}: ${formatError(lastError)}`
544
+ );
847
545
  }
848
- function rawByteLength(raw) {
849
- if (typeof raw === "string") return Buffer.byteLength(raw);
850
- if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
851
- return raw.byteLength;
546
+ function formatError(error) {
547
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
852
548
  }
853
549
 
854
550
  // src/mcp/instructions.ts
855
551
  var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
856
552
 
857
553
  Required workflow for Studio changes:
858
- 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.
859
555
  2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
860
556
  3. Form a short plan with explicit acceptance checks.
861
557
  4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
@@ -863,6 +559,8 @@ Required workflow for Studio changes:
863
559
  6. Read the affected instances back and compare exact values, hierarchy, and source revisions.
864
560
  7. Run an appropriate Studio test when behavior changed. Report verification evidence and any remaining uncertainty.
865
561
 
562
+ 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.
563
+
866
564
  Safety rules:
867
565
  - Never invent instance IDs or Roblox API members.
868
566
  - Do not retry a failed write unchanged. Inspect the error and current state first.
@@ -873,30 +571,47 @@ Safety rules:
873
571
 
874
572
  // src/mcp/resources.ts
875
573
  function registerDominusResources(server, bridge) {
876
- server.registerResource("dominus-status", "dominus://status", {
877
- title: "Dominus 2 Studio Status",
878
- description: "Current authenticated Roblox Studio sessions and selected target.",
879
- mimeType: "application/json"
880
- }, async (uri) => ({
881
- contents: [{
882
- uri: uri.href,
883
- mimeType: "application/json",
884
- text: JSON.stringify({
885
- protocolVersion: 2,
886
- activeConnectionId: bridge.getActiveConnectionId(),
887
- connections: bridge.listConnections()
888
- }, null, 2)
889
- }]
890
- }));
891
- server.registerPrompt("dominus-workflow", {
892
- title: "Dominus 2 Engineering Workflow",
893
- description: "Inspect, plan, apply, verify, and test a Roblox Studio change safely."
894
- }, async () => ({
895
- messages: [{
896
- role: "user",
897
- content: { type: "text", text: DOMINUS_MCP_INSTRUCTIONS }
898
- }]
899
- }));
574
+ server.registerResource(
575
+ "dominus-status",
576
+ "dominus://status",
577
+ {
578
+ title: "Dominus 2 Studio Status",
579
+ description: "Current authenticated Roblox Studio sessions and selected target.",
580
+ mimeType: "application/json"
581
+ },
582
+ async (uri) => ({
583
+ contents: [
584
+ {
585
+ uri: uri.href,
586
+ mimeType: "application/json",
587
+ text: JSON.stringify(
588
+ {
589
+ protocolVersion: 2,
590
+ activeConnectionId: bridge.getActiveConnectionId(),
591
+ connections: bridge.listConnections()
592
+ },
593
+ null,
594
+ 2
595
+ )
596
+ }
597
+ ]
598
+ })
599
+ );
600
+ server.registerPrompt(
601
+ "dominus-workflow",
602
+ {
603
+ title: "Dominus 2 Engineering Workflow",
604
+ description: "Inspect, plan, apply, verify, and test a Roblox Studio change safely."
605
+ },
606
+ async () => ({
607
+ messages: [
608
+ {
609
+ role: "user",
610
+ content: { type: "text", text: DOMINUS_MCP_INSTRUCTIONS }
611
+ }
612
+ ]
613
+ })
614
+ );
900
615
  }
901
616
 
902
617
  // src/mcp/result.ts
@@ -930,55 +645,77 @@ var toolOutputSchema = {
930
645
  };
931
646
  async function callStudio(bridge, command, payload, timeoutMs = 15e3) {
932
647
  try {
933
- const response = await bridge.sendRequest(command, payload, timeoutMs);
934
- const result = response.payload;
935
- if (!result || typeof result !== "object") return failure("Studio returned an invalid response");
936
- if (result.success === false || typeof result.error === "string") {
937
- return failure(result.error ?? "Studio command failed", result);
938
- }
648
+ const result = await requestStudio(bridge, command, payload, timeoutMs);
939
649
  return success(result);
940
650
  } catch (err) {
941
651
  return failure(err);
942
652
  }
943
653
  }
654
+ async function requestStudio(bridge, command, payload, timeoutMs = 15e3) {
655
+ const response = await bridge.sendRequest(command, payload, timeoutMs);
656
+ const result = response.payload;
657
+ if (!result || typeof result !== "object") throw new Error("Studio returned an invalid response");
658
+ if (result.success === false || typeof result.error === "string") {
659
+ throw new Error(typeof result.error === "string" ? result.error : "Studio command failed");
660
+ }
661
+ return result;
662
+ }
944
663
  function ref(value) {
945
664
  return value;
946
665
  }
947
666
 
948
667
  // src/mcp/tools/connections.ts
949
668
  function registerConnectionTools(server, bridge) {
950
- server.registerTool("dominus_status", {
951
- title: "Dominus Studio Status",
952
- description: "List authenticated Roblox Studio sessions, the active target, and bridge health. Call this before Studio work.",
953
- inputSchema: {},
954
- outputSchema: toolOutputSchema,
955
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
956
- }, async () => {
957
- try {
958
- return success({
959
- protocolVersion: 2,
960
- port: bridge.getPort(),
961
- activeConnectionId: bridge.getActiveConnectionId(),
962
- connections: bridge.listConnections()
963
- });
964
- } catch (err) {
965
- return failure(err);
669
+ server.registerTool(
670
+ "dominus_status",
671
+ {
672
+ title: "Dominus Studio Status",
673
+ description: "List authenticated Roblox Studio sessions, the active target, and bridge health. Call this before Studio work.",
674
+ inputSchema: {},
675
+ outputSchema: toolOutputSchema,
676
+ annotations: {
677
+ readOnlyHint: true,
678
+ destructiveHint: false,
679
+ idempotentHint: true,
680
+ openWorldHint: false
681
+ }
682
+ },
683
+ async () => {
684
+ try {
685
+ return success({
686
+ protocolVersion: 2,
687
+ port: bridge.getPort(),
688
+ activeConnectionId: bridge.getActiveConnectionId(),
689
+ connections: bridge.listConnections()
690
+ });
691
+ } catch (err) {
692
+ return failure(err);
693
+ }
966
694
  }
967
- });
968
- server.registerTool("dominus_select_studio", {
969
- title: "Select Studio Session",
970
- description: "Choose the exact Studio connection targeted by subsequent tools. Uses connectionId, not placeId, so duplicate windows are safe.",
971
- inputSchema: { connectionId: z.string().uuid().nullable() },
972
- outputSchema: toolOutputSchema,
973
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
974
- }, async ({ connectionId }) => {
975
- try {
976
- await bridge.setActiveConnectionId(connectionId);
977
- return success({ activeConnectionId: bridge.getActiveConnectionId() });
978
- } catch (err) {
979
- return failure(err);
695
+ );
696
+ server.registerTool(
697
+ "dominus_select_studio",
698
+ {
699
+ title: "Select Studio Session",
700
+ description: "Choose the exact Studio connection targeted by subsequent tools. Uses connectionId, not placeId, so duplicate windows are safe.",
701
+ inputSchema: { connectionId: z.string().uuid().nullable() },
702
+ outputSchema: toolOutputSchema,
703
+ annotations: {
704
+ readOnlyHint: false,
705
+ destructiveHint: false,
706
+ idempotentHint: true,
707
+ openWorldHint: false
708
+ }
709
+ },
710
+ async ({ connectionId }) => {
711
+ try {
712
+ await bridge.setActiveConnectionId(connectionId);
713
+ return success({ activeConnectionId: bridge.getActiveConnectionId() });
714
+ } catch (err) {
715
+ return failure(err);
716
+ }
980
717
  }
981
- });
718
+ );
982
719
  }
983
720
  var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/Roblox/creator-docs/main/content/en-us/reference/engine";
984
721
  var GITHUB_TREE_URL = "https://api.github.com/repos/Roblox/creator-docs/git/trees/main?recursive=1";
@@ -1097,10 +834,10 @@ async function readReferenceYaml(name, kind, opts) {
1097
834
  const folder = KIND_FOLDERS[kind];
1098
835
  const fileName = `${name}.yaml`;
1099
836
  if (localRoot) {
1100
- const localPath = path3.join(localRoot, folder, fileName);
1101
- if (fs3.existsSync(localPath)) {
837
+ const localPath = path4.join(localRoot, folder, fileName);
838
+ if (fs4.existsSync(localPath)) {
1102
839
  return {
1103
- content: fs3.readFileSync(localPath, "utf-8"),
840
+ content: fs4.readFileSync(localPath, "utf-8"),
1104
841
  sourceUrl: toCreatorDocsUrl(kind, name)
1105
842
  };
1106
843
  }
@@ -1120,11 +857,11 @@ async function listReferenceEntries(opts) {
1120
857
  if (localRoot) {
1121
858
  const entries = [];
1122
859
  for (const [kind, folder] of Object.entries(KIND_FOLDERS)) {
1123
- const dir = path3.join(localRoot, folder);
1124
- if (!fs3.existsSync(dir)) continue;
1125
- 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)) {
1126
863
  if (!file.endsWith(".yaml")) continue;
1127
- const name = path3.basename(file, ".yaml");
864
+ const name = path4.basename(file, ".yaml");
1128
865
  entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
1129
866
  }
1130
867
  }
@@ -1137,7 +874,7 @@ async function listReferenceEntries(opts) {
1137
874
  const [folder, file] = rest.split("/");
1138
875
  const kind = folderToKind(folder);
1139
876
  if (!kind || !file) return [];
1140
- const name = path3.basename(file, ".yaml");
877
+ const name = path4.basename(file, ".yaml");
1141
878
  return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
1142
879
  });
1143
880
  }
@@ -1157,19 +894,19 @@ function resolveDocsRoot(explicitRoot) {
1157
894
  const candidates = [
1158
895
  explicitRoot,
1159
896
  process.env.ROBLOX_CREATOR_DOCS_PATH,
1160
- path3.join(process.cwd(), ".tmp", "creator-docs")
897
+ path4.join(process.cwd(), ".tmp", "creator-docs")
1161
898
  ].filter(Boolean);
1162
899
  for (const candidate of candidates) {
1163
900
  const normalized = normalizeDocsRoot(candidate);
1164
- if (normalized && fs3.existsSync(normalized)) return normalized;
901
+ if (normalized && fs4.existsSync(normalized)) return normalized;
1165
902
  }
1166
903
  return null;
1167
904
  }
1168
905
  function normalizeDocsRoot(root) {
1169
- const engineRoot = path3.join(root, "content", "en-us", "reference", "engine");
1170
- if (fs3.existsSync(engineRoot)) return engineRoot;
1171
- const directClasses = path3.join(root, "classes");
1172
- 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;
1173
910
  return null;
1174
911
  }
1175
912
  function normalizeLookupName(name) {
@@ -1401,57 +1138,834 @@ function escapeRegExp(value) {
1401
1138
  // src/mcp/tools/docs.ts
1402
1139
  var kindSchema = z.enum(["any", "class", "datatype", "enum", "global", "library"]);
1403
1140
  function registerDocsTools(server) {
1404
- server.registerTool("roblox_search_api", {
1405
- title: "Search Roblox API",
1406
- description: "Search the official Roblox Creator Docs engine API mirror by exact or partial class, datatype, enum, global, or library name.",
1407
- inputSchema: {
1408
- query: z.string().min(1).max(100),
1409
- kind: kindSchema.optional().default("any"),
1410
- limit: z.number().int().min(1).max(25).optional().default(10)
1141
+ server.registerTool(
1142
+ "roblox_search_api",
1143
+ {
1144
+ title: "Search Roblox API",
1145
+ description: "Search the official Roblox Creator Docs engine API mirror by exact or partial class, datatype, enum, global, or library name.",
1146
+ inputSchema: {
1147
+ query: z.string().min(1).max(100),
1148
+ kind: kindSchema.optional().default("any"),
1149
+ limit: z.number().int().min(1).max(25).optional().default(10)
1150
+ },
1151
+ outputSchema: toolOutputSchema,
1152
+ annotations: {
1153
+ readOnlyHint: true,
1154
+ destructiveHint: false,
1155
+ idempotentHint: true,
1156
+ openWorldHint: true
1157
+ }
1411
1158
  },
1412
- outputSchema: toolOutputSchema,
1413
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1414
- }, async ({ query, kind, limit }) => {
1415
- try {
1416
- return success({ results: await searchRobloxApi(query, { kind, limit }) });
1417
- } catch (err) {
1418
- return failure(err);
1159
+ async ({ query, kind, limit }) => {
1160
+ try {
1161
+ return success({ results: await searchRobloxApi(query, { kind, limit }) });
1162
+ } catch (err) {
1163
+ return failure(err);
1164
+ }
1419
1165
  }
1166
+ );
1167
+ server.registerTool(
1168
+ "roblox_get_api",
1169
+ {
1170
+ title: "Get Roblox API Reference",
1171
+ description: "Read an official Roblox engine API reference including members, types, security, thread safety, inheritance, and source URL.",
1172
+ inputSchema: {
1173
+ name: z.string().min(1).max(100),
1174
+ kind: kindSchema.optional().default("any"),
1175
+ includeInherited: z.boolean().optional().default(true)
1176
+ },
1177
+ outputSchema: toolOutputSchema,
1178
+ annotations: {
1179
+ readOnlyHint: true,
1180
+ destructiveHint: false,
1181
+ idempotentHint: true,
1182
+ openWorldHint: true
1183
+ }
1184
+ },
1185
+ async ({ name, kind, includeInherited }) => {
1186
+ try {
1187
+ const reference = await getRobloxApiReference(name, { kind, includeInherited });
1188
+ return success({ reference });
1189
+ } catch (err) {
1190
+ return failure(err);
1191
+ }
1192
+ }
1193
+ );
1194
+ }
1195
+ var propertiesSchema = z.record(z.unknown()).refine(
1196
+ (properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
1197
+ "At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
1198
+ );
1199
+ var mutationOperationSchema = z.discriminatedUnion("op", [
1200
+ z.object({
1201
+ op: z.literal("setProperties"),
1202
+ target: instanceRefSchema,
1203
+ properties: propertiesSchema
1204
+ }),
1205
+ z.object({
1206
+ op: z.literal("create"),
1207
+ parent: instanceRefSchema,
1208
+ className: z.string().min(1).max(100),
1209
+ name: z.string().min(1).max(256),
1210
+ properties: propertiesSchema.optional()
1211
+ }),
1212
+ z.object({
1213
+ op: z.literal("move"),
1214
+ target: instanceRefSchema,
1215
+ parent: instanceRefSchema
1216
+ })
1217
+ ]);
1218
+ var workerProposalSchema = z.object({
1219
+ workerId: z.string().min(1).max(64),
1220
+ summary: z.string().min(1).max(4e3),
1221
+ evidence: z.array(
1222
+ z.object({
1223
+ source: z.string().min(1).max(500),
1224
+ summary: z.string().min(1).max(2e3)
1225
+ })
1226
+ ).max(30).default([]),
1227
+ operations: z.array(mutationOperationSchema).max(50).default([]),
1228
+ risks: z.array(z.string().max(1e3)).max(30).default([]),
1229
+ verificationSuggestions: z.array(z.string().max(1e3)).max(30).default([])
1230
+ });
1231
+ var coordinatorDecisionSchema = z.object({
1232
+ summary: z.string().min(1).max(4e3),
1233
+ acceptedWorkerIds: z.array(z.string()).max(5),
1234
+ rejected: z.array(
1235
+ z.object({
1236
+ workerId: z.string(),
1237
+ reason: z.string().min(1).max(2e3)
1238
+ })
1239
+ ).max(5).default([]),
1240
+ operationOrder: z.array(
1241
+ z.object({
1242
+ workerId: z.string(),
1243
+ operationIndex: z.number().int().min(0).max(49)
1244
+ })
1245
+ ).max(100),
1246
+ verificationChecks: z.array(z.string().max(1e3)).max(50).default([])
1247
+ });
1248
+ function registerParallelTaskTool(server, bridge) {
1249
+ server.registerTool(
1250
+ "run_parallel_task",
1251
+ {
1252
+ title: "Run Coordinated Parallel Studio Task",
1253
+ description: "Partition a broad Studio goal into non-overlapping scopes, run proposal-only workers concurrently through MCP Sampling, reject unsafe or conflicting proposals, atomically apply coordinator-approved writes, and verify the result.",
1254
+ inputSchema: {
1255
+ goal: z.string().min(1).max(2e4),
1256
+ root: instanceRefSchema.optional(),
1257
+ rootPath: z.string().min(1).max(2e3).optional(),
1258
+ maxWorkers: z.number().int().min(1).max(5).optional().default(3),
1259
+ constraints: z.string().max(1e4).optional(),
1260
+ taskType: z.string().max(100).optional(),
1261
+ dryRun: z.boolean().optional().default(false)
1262
+ },
1263
+ outputSchema: toolOutputSchema,
1264
+ annotations: {
1265
+ readOnlyHint: false,
1266
+ destructiveHint: false,
1267
+ idempotentHint: false,
1268
+ openWorldHint: false
1269
+ }
1270
+ },
1271
+ async ({ goal, root, rootPath, maxWorkers, constraints, taskType, dryRun }) => {
1272
+ try {
1273
+ const capabilities = server.server.getClientCapabilities();
1274
+ if (!capabilities?.sampling) {
1275
+ return failure(
1276
+ "run_parallel_task requires an MCP client that supports Sampling. Use the normal Dominus inspect-plan-apply workflow with this client.",
1277
+ { requiredCapability: "sampling" }
1278
+ );
1279
+ }
1280
+ const rootRef = root ?? {
1281
+ pathSegments: parallelRootPathToSegments(rootPath ?? "Workspace")
1282
+ };
1283
+ const tree = await getTree(bridge, rootRef, 5, 1500);
1284
+ const rootNode = tree.roots[0];
1285
+ if (!rootNode) return failure("The requested root did not return a Studio tree");
1286
+ if (tree.truncated) {
1287
+ return failure(
1288
+ "The requested root exceeded the parallel evidence limit. Narrow root/rootPath so workers receive a complete hierarchy.",
1289
+ { nodeCount: tree.nodeCount, maxNodes: 1500 }
1290
+ );
1291
+ }
1292
+ const assignments = createAssignments(goal, rootNode, maxWorkers, constraints, taskType);
1293
+ emit(server, "multi_agent_start", { goal, workerCount: assignments.length });
1294
+ const settled = await Promise.allSettled(
1295
+ assignments.map(async (assignment) => {
1296
+ emit(server, "multi_agent_worker_start", {
1297
+ workerId: assignment.id,
1298
+ role: assignment.role,
1299
+ scopes: assignment.ownedScopes
1300
+ });
1301
+ const result = await runWorker(server, bridge, goal, assignment, constraints, taskType);
1302
+ emit(server, "multi_agent_worker_done", {
1303
+ workerId: assignment.id,
1304
+ summary: result.proposal?.summary ?? result.error ?? "Worker failed",
1305
+ proposedOperationCount: result.proposal?.operations.length ?? 0
1306
+ });
1307
+ return result;
1308
+ })
1309
+ );
1310
+ const workerResults = settled.map(
1311
+ (result, index) => result.status === "fulfilled" ? result.value : { assignment: assignments[index], error: toErrorMessage(result.reason) }
1312
+ );
1313
+ const validated = validateWorkerResults(workerResults);
1314
+ const conflictChecked = rejectProposalConflicts(validated.accepted);
1315
+ const accepted = conflictChecked.accepted;
1316
+ const rejected = [...validated.rejected, ...conflictChecked.rejected];
1317
+ for (const item of accepted) {
1318
+ emit(server, "multi_agent_proposal", {
1319
+ workerId: item.assignment.id,
1320
+ accepted: true,
1321
+ operationCount: item.proposal.operations.length
1322
+ });
1323
+ }
1324
+ for (const item of rejected) {
1325
+ emit(server, "multi_agent_proposal", {
1326
+ workerId: item.workerId,
1327
+ accepted: false,
1328
+ reason: item.reason
1329
+ });
1330
+ }
1331
+ const decision = accepted.length > 0 ? await runCoordinator(server, goal, accepted, rejected, constraints) : emptyDecision(rejected);
1332
+ const finalPlan = buildFinalPlan(decision, accepted, rejected);
1333
+ if (finalPlan.operations.length > 100) {
1334
+ return failure("Coordinator plan exceeds the 100-operation atomic limit", {
1335
+ operationCount: finalPlan.operations.length
1336
+ });
1337
+ }
1338
+ emit(server, "multi_agent_decision", {
1339
+ acceptedCount: finalPlan.acceptedWorkerIds.length,
1340
+ rejectedCount: finalPlan.rejected.length,
1341
+ finalOperationCount: finalPlan.operations.length
1342
+ });
1343
+ let applied = null;
1344
+ let verification = null;
1345
+ let repairApplied = false;
1346
+ if (!dryRun && finalPlan.operations.length > 0) {
1347
+ emit(server, "multi_agent_apply_start", { operationCount: finalPlan.operations.length });
1348
+ applied = await requestStudio(
1349
+ bridge,
1350
+ CliMsg.V2_APPLY,
1351
+ { operations: finalPlan.operations },
1352
+ 12e4
1353
+ );
1354
+ verification = await verifyPlan(bridge, rootRef, rootNode, finalPlan.operations);
1355
+ if (!verification.passed) {
1356
+ const repairOperations = findRepairOperations(
1357
+ finalPlan.operations,
1358
+ verification,
1359
+ rootNode
1360
+ );
1361
+ if (repairOperations.length > 0) {
1362
+ await requestStudio(
1363
+ bridge,
1364
+ CliMsg.V2_APPLY,
1365
+ { operations: repairOperations },
1366
+ 12e4
1367
+ );
1368
+ repairApplied = true;
1369
+ verification = await verifyPlan(bridge, rootRef, rootNode, finalPlan.operations);
1370
+ }
1371
+ }
1372
+ emit(server, "multi_agent_apply_done", {
1373
+ appliedCount: finalPlan.operations.length,
1374
+ verificationPassed: verification.passed,
1375
+ repairApplied
1376
+ });
1377
+ } else {
1378
+ verification = await verifyPlan(bridge, rootRef, rootNode, []);
1379
+ }
1380
+ return success({
1381
+ goal,
1382
+ root: rootRef,
1383
+ dryRun,
1384
+ assignments: assignments.map(publicAssignment),
1385
+ workers: workerResults.map(publicWorkerResult),
1386
+ decision: {
1387
+ summary: decision.summary,
1388
+ acceptedWorkerIds: finalPlan.acceptedWorkerIds,
1389
+ rejected: finalPlan.rejected,
1390
+ verificationChecks: decision.verificationChecks
1391
+ },
1392
+ finalOperations: finalPlan.operations,
1393
+ applied,
1394
+ repairApplied,
1395
+ verification
1396
+ });
1397
+ } catch (err) {
1398
+ return failure(err);
1399
+ }
1400
+ }
1401
+ );
1402
+ }
1403
+ async function getTree(bridge, root, maxDepth, maxNodes) {
1404
+ const result = await requestStudio(bridge, CliMsg.V2_GET_TREE, { root, maxDepth, maxNodes });
1405
+ return {
1406
+ roots: Array.isArray(result.roots) ? result.roots : [],
1407
+ nodeCount: typeof result.nodeCount === "number" ? result.nodeCount : 0,
1408
+ truncated: result.truncated === true
1409
+ };
1410
+ }
1411
+ function createAssignments(goal, root, maxWorkers, constraints, taskType) {
1412
+ const candidates = root.children?.length ? root.children : [root];
1413
+ const workerCount = Math.max(1, Math.min(maxWorkers, candidates.length));
1414
+ const groups = Array.from({ length: workerCount }, () => []);
1415
+ candidates.forEach((node, index) => groups[index % workerCount].push(node));
1416
+ return groups.map((scopeNodes, index) => {
1417
+ const ownedScopes = scopeNodes.map((node) => node.ref);
1418
+ const knownRefKeys = /* @__PURE__ */ new Set();
1419
+ scopeNodes.forEach((node) => collectRefKeys(node, knownRefKeys));
1420
+ return {
1421
+ id: `worker-${index + 1}`,
1422
+ role: `${taskType ?? "general"} scope analyst ${index + 1}`,
1423
+ objective: `Inspect the assigned Studio branches and propose the smallest changes that advance: ${goal}`,
1424
+ ownedScopes,
1425
+ acceptanceCriteria: [
1426
+ "Every referenced existing instance must come from the supplied evidence.",
1427
+ "Every proposed mutation must remain inside an owned scope.",
1428
+ "Do not propose deletion, arbitrary code execution, or script replacement.",
1429
+ constraints?.trim() || "Preserve unrelated instances and behavior."
1430
+ ],
1431
+ scopeNodes,
1432
+ knownRefKeys
1433
+ };
1420
1434
  });
1421
- server.registerTool("roblox_get_api", {
1422
- title: "Get Roblox API Reference",
1423
- description: "Read an official Roblox engine API reference including members, types, security, thread safety, inheritance, and source URL.",
1424
- inputSchema: {
1425
- name: z.string().min(1).max(100),
1426
- kind: kindSchema.optional().default("any"),
1427
- includeInherited: z.boolean().optional().default(true)
1435
+ }
1436
+ async function runWorker(server, bridge, goal, assignment, constraints, taskType) {
1437
+ try {
1438
+ const studioInspection = await inspectScopeRoots(bridge, assignment.ownedScopes);
1439
+ const response = await server.server.createMessage(
1440
+ {
1441
+ systemPrompt: [
1442
+ "You are a read-only Dominus worker. Inspect only the Studio evidence in the request.",
1443
+ "You cannot write to Studio. Return one strict JSON WorkerProposal and no Markdown.",
1444
+ "Only propose setProperties, create, or move operations using exact instance refs copied from evidence.",
1445
+ "Never invent instanceId values. Never propose delete, script edits, or arbitrary execution."
1446
+ ].join(" "),
1447
+ messages: [
1448
+ {
1449
+ role: "user",
1450
+ content: {
1451
+ type: "text",
1452
+ text: JSON.stringify(
1453
+ {
1454
+ schema: {
1455
+ workerId: assignment.id,
1456
+ summary: "string",
1457
+ evidence: [{ source: "string", summary: "string" }],
1458
+ operations: [
1459
+ {
1460
+ op: "setProperties",
1461
+ target: { instanceId: "uuid", pathSegments: ["..."] },
1462
+ properties: {}
1463
+ },
1464
+ {
1465
+ op: "create",
1466
+ parent: { instanceId: "uuid", pathSegments: ["..."] },
1467
+ className: "Part",
1468
+ name: "Name",
1469
+ properties: {}
1470
+ },
1471
+ {
1472
+ op: "move",
1473
+ target: { instanceId: "uuid", pathSegments: ["..."] },
1474
+ parent: { instanceId: "uuid", pathSegments: ["..."] }
1475
+ }
1476
+ ],
1477
+ risks: ["string"],
1478
+ verificationSuggestions: ["string"]
1479
+ },
1480
+ globalGoal: goal,
1481
+ taskType: taskType ?? "general",
1482
+ constraints: constraints ?? "",
1483
+ assignment: publicAssignment(assignment),
1484
+ studioEvidence: {
1485
+ hierarchy: assignment.scopeNodes,
1486
+ typedRootInspection: studioInspection
1487
+ }
1488
+ },
1489
+ null,
1490
+ 2
1491
+ )
1492
+ }
1493
+ }
1494
+ ],
1495
+ maxTokens: 5e3,
1496
+ temperature: 0.2,
1497
+ modelPreferences: {
1498
+ intelligencePriority: 0.9,
1499
+ speedPriority: 0.3,
1500
+ costPriority: 0.2
1501
+ }
1502
+ },
1503
+ { timeout: 12e4 }
1504
+ );
1505
+ const proposal = workerProposalSchema.parse(parseJson(extractSamplingText(response.content)));
1506
+ return { assignment, proposal };
1507
+ } catch (err) {
1508
+ return { assignment, error: toErrorMessage(err) };
1509
+ }
1510
+ }
1511
+ async function inspectScopeRoots(bridge, targets) {
1512
+ const results = [];
1513
+ for (let offset = 0; offset < targets.length; offset += 20) {
1514
+ const inspected = await requestStudio(bridge, CliMsg.V2_INSPECT, {
1515
+ targets: targets.slice(offset, offset + 20),
1516
+ compact: true
1517
+ });
1518
+ if (Array.isArray(inspected.results)) {
1519
+ results.push(...inspected.results);
1520
+ }
1521
+ }
1522
+ return results;
1523
+ }
1524
+ function validateWorkerResults(results) {
1525
+ const accepted = [];
1526
+ const rejected = [];
1527
+ for (const result of results) {
1528
+ if (!result.proposal) {
1529
+ rejected.push({
1530
+ workerId: result.assignment.id,
1531
+ reason: result.error ?? "Worker returned no proposal"
1532
+ });
1533
+ continue;
1534
+ }
1535
+ if (result.proposal.workerId !== result.assignment.id) {
1536
+ rejected.push({
1537
+ workerId: result.assignment.id,
1538
+ reason: `Proposal workerId ${result.proposal.workerId} did not match its assignment`,
1539
+ summary: result.proposal.summary
1540
+ });
1541
+ continue;
1542
+ }
1543
+ const errors = validateOperations(result.assignment, result.proposal.operations);
1544
+ if (errors.length > 0) {
1545
+ rejected.push({
1546
+ workerId: result.assignment.id,
1547
+ reason: errors.join("; "),
1548
+ summary: result.proposal.summary
1549
+ });
1550
+ continue;
1551
+ }
1552
+ accepted.push({ assignment: result.assignment, proposal: result.proposal });
1553
+ }
1554
+ return { accepted, rejected };
1555
+ }
1556
+ function validateOperations(assignment, operations) {
1557
+ const errors = [];
1558
+ for (const [index, operation] of operations.entries()) {
1559
+ const refs = operation.op === "create" ? [operation.parent] : operation.op === "move" ? [operation.target, operation.parent] : [operation.target];
1560
+ for (const ref2 of refs) {
1561
+ if (!isKnownParallelRef(ref2, assignment.knownRefKeys)) {
1562
+ errors.push(`Operation ${index} references an instance absent from worker evidence`);
1563
+ } else if (!isParallelRefWithinScopes(ref2, assignment.ownedScopes)) {
1564
+ errors.push(`Operation ${index} targets outside the worker owned scopes`);
1565
+ }
1566
+ }
1567
+ }
1568
+ return errors;
1569
+ }
1570
+ function rejectProposalConflicts(accepted) {
1571
+ const finalAccepted = [];
1572
+ const rejected = [];
1573
+ const claims = [];
1574
+ for (const item of accepted) {
1575
+ const targets = item.proposal.operations.map(operationTarget);
1576
+ const conflict = claims.find(
1577
+ (claim) => targets.some(
1578
+ (target) => target.path && claim.paths.some((path5) => parallelPathsOverlap(target.path, path5)) || claim.refKeys.includes(target.key)
1579
+ )
1580
+ );
1581
+ if (conflict) {
1582
+ rejected.push({
1583
+ workerId: item.assignment.id,
1584
+ reason: `Proposal overlaps writes owned by ${conflict.workerId}`,
1585
+ summary: item.proposal.summary
1586
+ });
1587
+ continue;
1588
+ }
1589
+ finalAccepted.push(item);
1590
+ claims.push({
1591
+ workerId: item.assignment.id,
1592
+ paths: targets.flatMap((target) => target.path ? [target.path] : []),
1593
+ refKeys: targets.map((target) => target.key)
1594
+ });
1595
+ }
1596
+ return { accepted: finalAccepted, rejected };
1597
+ }
1598
+ async function runCoordinator(server, goal, accepted, rejected, constraints) {
1599
+ const response = await server.server.createMessage(
1600
+ {
1601
+ systemPrompt: [
1602
+ "You are the Dominus coordinator. Return strict JSON only.",
1603
+ "Workers are proposal-only. Select whole non-conflicting proposals and order their existing operations.",
1604
+ "Do not invent, edit, merge, or partially accept operations. The server will reject any unknown index.",
1605
+ "Prefer the smallest plan that satisfies the goal and preserves unrelated Studio state."
1606
+ ].join(" "),
1607
+ messages: [
1608
+ {
1609
+ role: "user",
1610
+ content: {
1611
+ type: "text",
1612
+ text: JSON.stringify(
1613
+ {
1614
+ schema: {
1615
+ summary: "string",
1616
+ acceptedWorkerIds: ["worker-1"],
1617
+ rejected: [{ workerId: "worker-2", reason: "string" }],
1618
+ operationOrder: [{ workerId: "worker-1", operationIndex: 0 }],
1619
+ verificationChecks: ["string"]
1620
+ },
1621
+ goal,
1622
+ constraints: constraints ?? "",
1623
+ validProposals: accepted.map(({ assignment, proposal }) => ({
1624
+ workerId: assignment.id,
1625
+ summary: proposal.summary,
1626
+ evidence: proposal.evidence,
1627
+ operations: proposal.operations,
1628
+ risks: proposal.risks,
1629
+ verificationSuggestions: proposal.verificationSuggestions
1630
+ })),
1631
+ alreadyRejected: rejected
1632
+ },
1633
+ null,
1634
+ 2
1635
+ )
1636
+ }
1637
+ }
1638
+ ],
1639
+ maxTokens: 5e3,
1640
+ temperature: 0.1,
1641
+ modelPreferences: {
1642
+ intelligencePriority: 1,
1643
+ speedPriority: 0.2,
1644
+ costPriority: 0.1
1645
+ }
1428
1646
  },
1429
- outputSchema: toolOutputSchema,
1430
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1431
- }, async ({ name, kind, includeInherited }) => {
1432
- try {
1433
- const reference = await getRobloxApiReference(name, { kind, includeInherited });
1434
- return success({ reference });
1435
- } catch (err) {
1436
- return failure(err);
1647
+ { timeout: 12e4 }
1648
+ );
1649
+ return coordinatorDecisionSchema.parse(parseJson(extractSamplingText(response.content)));
1650
+ }
1651
+ function buildFinalPlan(decision, accepted, alreadyRejected) {
1652
+ const byId = new Map(accepted.map((item) => [item.assignment.id, item]));
1653
+ const acceptedIds = [...new Set(decision.acceptedWorkerIds)];
1654
+ for (const workerId of acceptedIds) {
1655
+ if (!byId.has(workerId)) throw new Error(`Coordinator accepted unknown worker ${workerId}`);
1656
+ }
1657
+ const operations = [];
1658
+ const seen = /* @__PURE__ */ new Set();
1659
+ for (const entry of decision.operationOrder) {
1660
+ if (!acceptedIds.includes(entry.workerId)) {
1661
+ throw new Error(`Coordinator ordered an operation from unaccepted worker ${entry.workerId}`);
1662
+ }
1663
+ const proposal = byId.get(entry.workerId).proposal;
1664
+ const operation = proposal.operations[entry.operationIndex];
1665
+ if (!operation)
1666
+ throw new Error(
1667
+ `Coordinator selected missing operation ${entry.workerId}:${entry.operationIndex}`
1668
+ );
1669
+ const key = `${entry.workerId}:${entry.operationIndex}`;
1670
+ if (seen.has(key)) throw new Error(`Coordinator selected duplicate operation ${key}`);
1671
+ seen.add(key);
1672
+ operations.push(operation);
1673
+ }
1674
+ for (const workerId of acceptedIds) {
1675
+ const proposal = byId.get(workerId).proposal;
1676
+ for (let index = 0; index < proposal.operations.length; index++) {
1677
+ if (!seen.has(`${workerId}:${index}`)) {
1678
+ throw new Error(
1679
+ `Coordinator partially accepted ${workerId}; operation ${index} was omitted`
1680
+ );
1681
+ }
1682
+ }
1683
+ }
1684
+ const coordinatorReasons = new Map(decision.rejected.map((item) => [item.workerId, item.reason]));
1685
+ const rejected = [...alreadyRejected];
1686
+ for (const item of accepted) {
1687
+ if (!acceptedIds.includes(item.assignment.id)) {
1688
+ rejected.push({
1689
+ workerId: item.assignment.id,
1690
+ reason: coordinatorReasons.get(item.assignment.id) ?? "Coordinator did not accept this proposal",
1691
+ summary: item.proposal.summary
1692
+ });
1437
1693
  }
1694
+ }
1695
+ return { acceptedWorkerIds: acceptedIds, rejected, operations };
1696
+ }
1697
+ async function verifyPlan(bridge, rootRef, originalRoot, operations) {
1698
+ const tree = await getTree(bridge, rootRef, 12, 5e3);
1699
+ const nodes = flattenTree(tree.roots);
1700
+ const paths = new Set(
1701
+ nodes.flatMap((node) => node.ref.pathSegments ? [pathKey(node.ref.pathSegments)] : [])
1702
+ );
1703
+ const knownPaths = collectInstancePaths(originalRoot);
1704
+ const expectedCreates = operations.flatMap((operation) => {
1705
+ if (operation.op !== "create") return [];
1706
+ const parentPath = resolveRefPath(operation.parent, knownPaths);
1707
+ return parentPath ? [pathKey([...parentPath, operation.name])] : [];
1438
1708
  });
1709
+ const missingCreates = expectedCreates.filter((path5) => !paths.has(path5));
1710
+ const inspectOperations = operations.filter((operation) => operation.op !== "create");
1711
+ const inspectFailures = [];
1712
+ const propertyMismatches = [];
1713
+ const moveMismatches = [];
1714
+ for (let offset = 0; offset < inspectOperations.length; offset += 20) {
1715
+ const batch = inspectOperations.slice(offset, offset + 20);
1716
+ const targets = batch.map((operation) => operation.target);
1717
+ const inspected = await requestStudio(bridge, CliMsg.V2_INSPECT, { targets, compact: true });
1718
+ const results = Array.isArray(inspected.results) ? inspected.results : [];
1719
+ batch.forEach((operation, index) => {
1720
+ const result = results[index];
1721
+ const targetLabel = refLabel(operation.target);
1722
+ if (!result || result.success === false) {
1723
+ inspectFailures.push(
1724
+ `${targetLabel}: ${String(result?.error ?? "missing inspection result")}`
1725
+ );
1726
+ return;
1727
+ }
1728
+ if (operation.op === "setProperties") {
1729
+ const entries = Array.isArray(result.properties) ? result.properties : [];
1730
+ const actual = new Map(entries.map((entry) => [String(entry.name), entry.value]));
1731
+ for (const [property, expected] of Object.entries(operation.properties)) {
1732
+ const actualValue = actual.get(property);
1733
+ if (!actual.has(property) || !valuesEquivalent(expected, actualValue)) {
1734
+ propertyMismatches.push({
1735
+ target: targetLabel,
1736
+ property,
1737
+ expected,
1738
+ actual: actualValue
1739
+ });
1740
+ }
1741
+ }
1742
+ } else {
1743
+ const instanceId = operation.target.instanceId;
1744
+ const current = nodes.find((node) => instanceId && node.ref.instanceId === instanceId) ?? nodes.find((node) => samePath(node.ref.pathSegments, operation.target.pathSegments));
1745
+ const expectedParent = resolveRefPath(operation.parent, knownPaths);
1746
+ const actualPath = current?.ref.pathSegments;
1747
+ const actualParent = actualPath?.slice(0, -1);
1748
+ if (!expectedParent || !actualParent || !samePath(expectedParent, actualParent)) {
1749
+ moveMismatches.push({
1750
+ target: targetLabel,
1751
+ expectedParent: expectedParent ? expectedParent.join(".") : refLabel(operation.parent),
1752
+ actualParent: actualParent?.join(".")
1753
+ });
1754
+ }
1755
+ }
1756
+ });
1757
+ }
1758
+ return {
1759
+ passed: !tree.truncated && missingCreates.length === 0 && propertyMismatches.length === 0 && moveMismatches.length === 0 && inspectFailures.length === 0,
1760
+ treeNodeCount: tree.nodeCount,
1761
+ treeTruncated: tree.truncated,
1762
+ expectedCreates: expectedCreates.length,
1763
+ foundCreates: expectedCreates.length - missingCreates.length,
1764
+ missingCreates,
1765
+ propertyMismatches,
1766
+ moveMismatches,
1767
+ inspectFailures
1768
+ };
1439
1769
  }
1440
- var propertiesSchema = z.record(z.unknown()).refine(
1441
- (properties) => Object.keys(properties).length <= 100,
1442
- "At most 100 properties are allowed per operation"
1770
+ function findRepairOperations(operations, verification, originalRoot) {
1771
+ const knownPaths = collectInstancePaths(originalRoot);
1772
+ const missingCreates = new Set(verification.missingCreates);
1773
+ const mismatchedTargets = new Set(verification.propertyMismatches.map((item) => item.target));
1774
+ const mismatchedMoves = new Set(verification.moveMismatches.map((item) => item.target));
1775
+ return operations.filter((operation) => {
1776
+ if (operation.op === "create") {
1777
+ const parentPath = resolveRefPath(operation.parent, knownPaths);
1778
+ return Boolean(parentPath && missingCreates.has(pathKey([...parentPath, operation.name])));
1779
+ }
1780
+ if (operation.op === "setProperties") return mismatchedTargets.has(refLabel(operation.target));
1781
+ return mismatchedMoves.has(refLabel(operation.target));
1782
+ });
1783
+ }
1784
+ function emptyDecision(rejected) {
1785
+ return {
1786
+ summary: rejected.length > 0 ? "No valid worker proposal was available to apply." : "Workers found no mutations necessary.",
1787
+ acceptedWorkerIds: [],
1788
+ rejected: [],
1789
+ operationOrder: [],
1790
+ verificationChecks: ["Read the requested root back and confirm no mutation was needed."]
1791
+ };
1792
+ }
1793
+ function publicAssignment(assignment) {
1794
+ return {
1795
+ id: assignment.id,
1796
+ role: assignment.role,
1797
+ objective: assignment.objective,
1798
+ ownedScopes: assignment.ownedScopes,
1799
+ acceptanceCriteria: assignment.acceptanceCriteria
1800
+ };
1801
+ }
1802
+ function publicWorkerResult(result) {
1803
+ return {
1804
+ workerId: result.assignment.id,
1805
+ summary: result.proposal?.summary,
1806
+ evidence: result.proposal?.evidence ?? [],
1807
+ proposedOperationCount: result.proposal?.operations.length ?? 0,
1808
+ risks: result.proposal?.risks ?? [],
1809
+ verificationSuggestions: result.proposal?.verificationSuggestions ?? [],
1810
+ error: result.error
1811
+ };
1812
+ }
1813
+ function collectRefKeys(node, output) {
1814
+ for (const key of parallelRefKeys(node.ref)) output.add(key);
1815
+ node.children?.forEach((child) => collectRefKeys(child, output));
1816
+ }
1817
+ function collectInstancePaths(root) {
1818
+ const result = /* @__PURE__ */ new Map();
1819
+ for (const node of flattenTree([root])) {
1820
+ if (node.ref.pathSegments) {
1821
+ if (node.ref.instanceId) result.set(`id:${node.ref.instanceId}`, node.ref.pathSegments);
1822
+ result.set(`path:${pathKey(node.ref.pathSegments)}`, node.ref.pathSegments);
1823
+ }
1824
+ }
1825
+ return result;
1826
+ }
1827
+ function flattenTree(roots) {
1828
+ const result = [];
1829
+ const visit = (node) => {
1830
+ result.push(node);
1831
+ node.children?.forEach(visit);
1832
+ };
1833
+ roots.forEach(visit);
1834
+ return result;
1835
+ }
1836
+ function isKnownParallelRef(ref2, known) {
1837
+ const keys = parallelRefKeys(ref2);
1838
+ return keys.length > 0 && keys.every((key) => known.has(key));
1839
+ }
1840
+ function isParallelRefWithinScopes(ref2, scopes) {
1841
+ if (ref2.pathSegments) {
1842
+ return scopes.some(
1843
+ (scope) => scope.pathSegments && parallelPathIsWithin(ref2.pathSegments, scope.pathSegments)
1844
+ );
1845
+ }
1846
+ return scopes.some((scope) => Boolean(ref2.instanceId && scope.instanceId === ref2.instanceId));
1847
+ }
1848
+ function operationTarget(operation) {
1849
+ if (operation.op === "create") {
1850
+ const parentPath = operation.parent.pathSegments;
1851
+ return {
1852
+ key: `${refKey(operation.parent)}/create:${operation.name}`,
1853
+ path: parentPath ? [...parentPath, operation.name] : void 0
1854
+ };
1855
+ }
1856
+ return { key: refKey(operation.target), path: operation.target.pathSegments };
1857
+ }
1858
+ function parallelRefKeys(ref2) {
1859
+ const keys = [];
1860
+ if (ref2.instanceId) keys.push(`id:${ref2.instanceId}`);
1861
+ if (ref2.pathSegments) keys.push(`path:${pathKey(ref2.pathSegments)}`);
1862
+ if (ref2.instanceId && ref2.pathSegments) {
1863
+ keys.push(`pair:${ref2.instanceId}:${pathKey(ref2.pathSegments)}`);
1864
+ }
1865
+ return keys;
1866
+ }
1867
+ function refKey(ref2) {
1868
+ return parallelRefKeys(ref2)[0] ?? "invalid-ref";
1869
+ }
1870
+ function refLabel(ref2) {
1871
+ return ref2.pathSegments?.join(".") ?? ref2.instanceId ?? "unknown-instance";
1872
+ }
1873
+ function resolveRefPath(ref2, known) {
1874
+ if (ref2.pathSegments) return ref2.pathSegments;
1875
+ return ref2.instanceId ? known.get(`id:${ref2.instanceId}`) : void 0;
1876
+ }
1877
+ function pathKey(path5) {
1878
+ return path5.join("\0");
1879
+ }
1880
+ function parallelPathIsWithin(target, scope) {
1881
+ return target.length >= scope.length && scope.every((segment, index) => target[index] === segment);
1882
+ }
1883
+ function parallelPathsOverlap(left, right) {
1884
+ return parallelPathIsWithin(left, right) || parallelPathIsWithin(right, left);
1885
+ }
1886
+ function samePath(left, right) {
1887
+ return Boolean(
1888
+ left && right && left.length === right.length && left.every((part, index) => part === right[index])
1889
+ );
1890
+ }
1891
+ function parallelRootPathToSegments(path5) {
1892
+ const trimmed = path5.trim();
1893
+ const bracketSegments = [...trimmed.matchAll(/\["((?:\\.|[^"])*)"\]/g)].map(
1894
+ (match) => JSON.parse(`"${match[1]}"`)
1895
+ );
1896
+ if (bracketSegments.length > 0) return bracketSegments;
1897
+ const segments = trimmed.replace(/^game\.?/i, "").split(".").map((part) => part.trim()).filter(Boolean);
1898
+ if (segments.length === 0) throw new Error("rootPath must identify a Studio instance");
1899
+ return segments;
1900
+ }
1901
+ function parseJson(text) {
1902
+ const trimmed = text.trim();
1903
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1] ?? trimmed;
1904
+ try {
1905
+ return JSON.parse(fenced);
1906
+ } catch {
1907
+ const start = fenced.indexOf("{");
1908
+ const end = fenced.lastIndexOf("}");
1909
+ if (start >= 0 && end > start) return JSON.parse(fenced.slice(start, end + 1));
1910
+ throw new Error("Sampling response did not contain a JSON object");
1911
+ }
1912
+ }
1913
+ function extractSamplingText(content) {
1914
+ const blocks = Array.isArray(content) ? content : [content];
1915
+ const text = blocks.flatMap((block) => {
1916
+ if (block && typeof block === "object" && block.type === "text") {
1917
+ const value = block.text;
1918
+ return typeof value === "string" ? [value] : [];
1919
+ }
1920
+ return [];
1921
+ }).join("\n");
1922
+ if (!text) throw new Error("Sampling response did not contain text");
1923
+ return text;
1924
+ }
1925
+ function valuesEquivalent(expected, actual) {
1926
+ if (typeof expected === "string" && typeof actual === "string") {
1927
+ if (expected.startsWith("#") && actual.startsWith("#"))
1928
+ return expected.toUpperCase() === actual.toUpperCase();
1929
+ if (actual.startsWith("Enum.")) return actual.endsWith(`.${expected.split(".").at(-1)}`);
1930
+ return expected === actual;
1931
+ }
1932
+ if (typeof expected === "number" && typeof actual === "number")
1933
+ return Math.abs(expected - actual) < 1e-6;
1934
+ if (Array.isArray(expected) && Array.isArray(actual)) {
1935
+ return expected.length === actual.length && expected.every((item, index) => valuesEquivalent(item, actual[index]));
1936
+ }
1937
+ if (expected && actual && typeof expected === "object" && typeof actual === "object") {
1938
+ const left = expected;
1939
+ const right = actual;
1940
+ return Object.keys(left).every((key) => valuesEquivalent(left[key], right[key]));
1941
+ }
1942
+ return Object.is(expected, actual);
1943
+ }
1944
+ function emit(server, type, data) {
1945
+ void server.sendLoggingMessage({
1946
+ level: "info",
1947
+ logger: "dominus.multi_agent",
1948
+ data: { type, ...data }
1949
+ }).catch(() => void 0);
1950
+ }
1951
+ function toErrorMessage(error) {
1952
+ return error instanceof Error ? error.message : String(error);
1953
+ }
1954
+ var propertiesSchema2 = z.record(z.unknown()).refine(
1955
+ (properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
1956
+ "At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
1443
1957
  );
1444
1958
  var setPropertiesOperation = z.object({
1445
1959
  op: z.literal("setProperties"),
1446
1960
  target: instanceRefSchema,
1447
- properties: propertiesSchema
1961
+ properties: propertiesSchema2
1448
1962
  });
1449
1963
  var createOperation = z.object({
1450
1964
  op: z.literal("create"),
1451
1965
  parent: instanceRefSchema,
1452
1966
  className: z.string().min(1).max(100),
1453
1967
  name: z.string().min(1).max(256),
1454
- properties: propertiesSchema.optional()
1968
+ properties: propertiesSchema2.optional()
1455
1969
  });
1456
1970
  var moveOperation = z.object({
1457
1971
  op: z.literal("move"),
@@ -1463,181 +1977,275 @@ var mutationOperation = z.discriminatedUnion("op", [
1463
1977
  createOperation,
1464
1978
  moveOperation
1465
1979
  ]);
1466
- var uiNodeSchema = z.lazy(() => z.object({
1467
- ClassName: z.string().min(1).max(100),
1468
- Name: z.string().min(1).max(256).optional(),
1469
- properties: propertiesSchema.optional(),
1470
- Children: z.array(uiNodeSchema).max(1e3).optional()
1471
- }));
1980
+ var uiNodeSchema = z.lazy(
1981
+ () => z.object({
1982
+ ClassName: z.string().min(1).max(100),
1983
+ Name: z.string().min(1).max(256).optional(),
1984
+ properties: propertiesSchema2.optional(),
1985
+ Children: z.array(uiNodeSchema).max(1e3).optional()
1986
+ })
1987
+ );
1472
1988
  function registerStudioTools(server, bridge) {
1473
- server.registerTool("studio_get_tree", {
1474
- title: "Inspect Studio Tree",
1475
- description: "Read a deterministic Studio hierarchy and receive stable instance refs. Use refs from this result in later calls.",
1476
- inputSchema: {
1477
- root: instanceRefSchema.optional(),
1478
- maxDepth: z.number().int().min(0).max(12).optional().default(3),
1479
- maxNodes: z.number().int().min(1).max(5e3).optional().default(1e3)
1989
+ server.registerTool(
1990
+ "studio_get_tree",
1991
+ {
1992
+ title: "Inspect Studio Tree",
1993
+ description: "Read a deterministic Studio hierarchy and receive stable instance refs. Use refs from this result in later calls.",
1994
+ inputSchema: {
1995
+ root: instanceRefSchema.optional(),
1996
+ maxDepth: z.number().int().min(0).max(12).optional().default(3),
1997
+ maxNodes: z.number().int().min(1).max(5e3).optional().default(1e3)
1998
+ },
1999
+ outputSchema: toolOutputSchema,
2000
+ annotations: {
2001
+ readOnlyHint: true,
2002
+ destructiveHint: false,
2003
+ idempotentHint: true,
2004
+ openWorldHint: false
2005
+ }
1480
2006
  },
1481
- outputSchema: toolOutputSchema,
1482
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1483
- }, ({ root, maxDepth, maxNodes }) => callStudio(bridge, CliMsg.V2_GET_TREE, {
1484
- root: root ? ref(root) : void 0,
1485
- maxDepth,
1486
- maxNodes
1487
- }));
1488
- server.registerTool("studio_inspect", {
1489
- title: "Inspect Studio Instances",
1490
- description: "Read typed property values and immediate children for up to 20 instance refs. Returns writable metadata from live ReflectionService.",
1491
- inputSchema: {
1492
- targets: z.array(instanceRefSchema).min(1).max(20),
1493
- compact: z.boolean().optional().default(true)
2007
+ ({ root, maxDepth, maxNodes }) => callStudio(bridge, CliMsg.V2_GET_TREE, {
2008
+ root: root ? ref(root) : void 0,
2009
+ maxDepth,
2010
+ maxNodes
2011
+ })
2012
+ );
2013
+ server.registerTool(
2014
+ "studio_inspect",
2015
+ {
2016
+ title: "Inspect Studio Instances",
2017
+ description: "Read typed property values and immediate children for up to 20 instance refs. Returns writable metadata from live ReflectionService.",
2018
+ inputSchema: {
2019
+ targets: z.array(instanceRefSchema).min(1).max(20),
2020
+ compact: z.boolean().optional().default(true)
2021
+ },
2022
+ outputSchema: toolOutputSchema,
2023
+ annotations: {
2024
+ readOnlyHint: true,
2025
+ destructiveHint: false,
2026
+ idempotentHint: true,
2027
+ openWorldHint: false
2028
+ }
2029
+ },
2030
+ ({ targets, compact }) => callStudio(bridge, CliMsg.V2_INSPECT, { targets, compact })
2031
+ );
2032
+ server.registerTool(
2033
+ "studio_get_selection",
2034
+ {
2035
+ title: "Get Studio Selection",
2036
+ description: "Return the user current Roblox Studio selection as stable instance refs.",
2037
+ inputSchema: {},
2038
+ outputSchema: toolOutputSchema,
2039
+ annotations: {
2040
+ readOnlyHint: true,
2041
+ destructiveHint: false,
2042
+ idempotentHint: true,
2043
+ openWorldHint: false
2044
+ }
2045
+ },
2046
+ () => callStudio(bridge, CliMsg.V2_GET_SELECTION, {})
2047
+ );
2048
+ server.registerTool(
2049
+ "studio_read_script",
2050
+ {
2051
+ title: "Read Studio Script",
2052
+ description: "Read a Script, LocalScript, or ModuleScript and receive its source plus a revision required for conflict-safe updates.",
2053
+ inputSchema: { target: instanceRefSchema },
2054
+ outputSchema: toolOutputSchema,
2055
+ annotations: {
2056
+ readOnlyHint: true,
2057
+ destructiveHint: false,
2058
+ idempotentHint: true,
2059
+ openWorldHint: false
2060
+ }
1494
2061
  },
1495
- outputSchema: toolOutputSchema,
1496
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1497
- }, ({ targets, compact }) => callStudio(bridge, CliMsg.V2_INSPECT, { targets, compact }));
1498
- server.registerTool("studio_get_selection", {
1499
- title: "Get Studio Selection",
1500
- description: "Return the user current Roblox Studio selection as stable instance refs.",
1501
- inputSchema: {},
1502
- outputSchema: toolOutputSchema,
1503
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1504
- }, () => callStudio(bridge, CliMsg.V2_GET_SELECTION, {}));
1505
- server.registerTool("studio_read_script", {
1506
- title: "Read Studio Script",
1507
- description: "Read a Script, LocalScript, or ModuleScript and receive its source plus a revision required for conflict-safe updates.",
1508
- inputSchema: { target: instanceRefSchema },
1509
- outputSchema: toolOutputSchema,
1510
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1511
- }, ({ target }) => callStudio(bridge, CliMsg.V2_READ_SCRIPT, { target }));
1512
- server.registerTool("studio_update_script", {
1513
- title: "Update Studio Script",
1514
- description: "Replace script source only if expectedRevision still matches the editor. Read the script again after updating to verify it.",
1515
- inputSchema: {
1516
- target: instanceRefSchema,
1517
- source: z.string().max(75e4),
1518
- expectedRevision: z.string().regex(/^\d+:[0-9a-f]{8}$/)
2062
+ ({ target }) => callStudio(bridge, CliMsg.V2_READ_SCRIPT, { target })
2063
+ );
2064
+ server.registerTool(
2065
+ "studio_update_script",
2066
+ {
2067
+ title: "Update Studio Script",
2068
+ description: "Replace script source only if expectedRevision still matches the editor. Read the script again after updating to verify it.",
2069
+ inputSchema: {
2070
+ target: instanceRefSchema,
2071
+ source: z.string().max(75e4),
2072
+ expectedRevision: z.string().regex(/^\d+:[0-9a-f]{8}$/)
2073
+ },
2074
+ outputSchema: toolOutputSchema,
2075
+ annotations: {
2076
+ readOnlyHint: false,
2077
+ destructiveHint: true,
2078
+ idempotentHint: true,
2079
+ openWorldHint: false
2080
+ }
1519
2081
  },
1520
- outputSchema: toolOutputSchema,
1521
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
1522
- }, ({ target, source, expectedRevision }) => callStudio(
1523
- bridge,
1524
- CliMsg.V2_UPDATE_SCRIPT,
1525
- { target, source, expectedRevision },
1526
- 6e4
1527
- ));
1528
- server.registerTool("studio_apply", {
1529
- title: "Apply Atomic Studio Mutations",
1530
- description: "Atomically set properties, create instances, and move instances. Any failed operation cancels and undoes the entire batch.",
1531
- inputSchema: { operations: z.array(mutationOperation).min(1).max(100) },
1532
- outputSchema: toolOutputSchema,
1533
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
1534
- }, ({ operations }) => callStudio(bridge, CliMsg.V2_APPLY, { operations }, 6e4));
1535
- server.registerTool("studio_delete_instances", {
1536
- title: "Delete Studio Instances",
1537
- description: "Destructively delete up to 50 instances in one undoable recording. Only call when the user explicitly requested deletion or cleanup.",
1538
- inputSchema: {
1539
- targets: z.array(instanceRefSchema).min(1).max(50),
1540
- confirm: z.literal(true)
2082
+ ({ target, source, expectedRevision }) => callStudio(bridge, CliMsg.V2_UPDATE_SCRIPT, { target, source, expectedRevision }, 6e4)
2083
+ );
2084
+ server.registerTool(
2085
+ "studio_apply",
2086
+ {
2087
+ title: "Apply Atomic Studio Mutations",
2088
+ description: "Atomically set properties, create instances, and move instances. Any failed operation cancels and undoes the entire batch.",
2089
+ inputSchema: { operations: z.array(mutationOperation).min(1).max(100) },
2090
+ outputSchema: toolOutputSchema,
2091
+ annotations: {
2092
+ readOnlyHint: false,
2093
+ destructiveHint: false,
2094
+ idempotentHint: false,
2095
+ openWorldHint: false
2096
+ }
1541
2097
  },
1542
- outputSchema: toolOutputSchema,
1543
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
1544
- }, ({ targets, confirm }) => callStudio(bridge, CliMsg.V2_DELETE, { targets, confirm }, 6e4));
1545
- server.registerTool("studio_build_ui", {
1546
- title: "Build Atomic Roblox UI",
1547
- description: "Build and validate a declarative UI tree off-tree, then commit it in one undoable operation. Existing UI is replaced only when replaceExisting is true.",
1548
- inputSchema: {
1549
- parent: instanceRefSchema.optional(),
1550
- tree: uiNodeSchema,
1551
- replaceExisting: z.boolean().optional().default(false),
1552
- maxDepth: z.number().int().min(1).max(50).optional().default(30),
1553
- maxNodes: z.number().int().min(1).max(3e3).optional().default(1e3)
2098
+ ({ operations }) => callStudio(bridge, CliMsg.V2_APPLY, { operations }, 6e4)
2099
+ );
2100
+ server.registerTool(
2101
+ "studio_delete_instances",
2102
+ {
2103
+ title: "Delete Studio Instances",
2104
+ description: "Destructively delete up to 50 instances in one undoable recording. Only call when the user explicitly requested deletion or cleanup.",
2105
+ inputSchema: {
2106
+ targets: z.array(instanceRefSchema).min(1).max(50),
2107
+ confirm: z.literal(true)
2108
+ },
2109
+ outputSchema: toolOutputSchema,
2110
+ annotations: {
2111
+ readOnlyHint: false,
2112
+ destructiveHint: true,
2113
+ idempotentHint: false,
2114
+ openWorldHint: false
2115
+ }
1554
2116
  },
1555
- outputSchema: toolOutputSchema,
1556
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
1557
- }, ({ parent, tree, replaceExisting, maxDepth, maxNodes }) => callStudio(
1558
- bridge,
1559
- CliMsg.V2_BUILD_UI,
1560
- { parent, tree, replaceExisting, maxDepth, maxNodes },
1561
- 12e4
1562
- ));
1563
- server.registerTool("studio_snapshot_ui", {
1564
- title: "Snapshot Roblox UI",
1565
- description: "Serialize a live UI subtree into typed, studio_build_ui-compatible data for fidelity checks and read-back verification.",
1566
- inputSchema: {
1567
- target: instanceRefSchema,
1568
- maxDepth: z.number().int().min(0).max(50).optional().default(30)
2117
+ ({ targets, confirm }) => callStudio(bridge, CliMsg.V2_DELETE, { targets, confirm }, 6e4)
2118
+ );
2119
+ server.registerTool(
2120
+ "studio_build_ui",
2121
+ {
2122
+ title: "Build Atomic Roblox UI",
2123
+ description: "Build and validate a declarative UI tree off-tree, then commit it in one undoable operation. Existing UI is replaced only when replaceExisting is true.",
2124
+ inputSchema: {
2125
+ parent: instanceRefSchema.optional(),
2126
+ tree: uiNodeSchema,
2127
+ replaceExisting: z.boolean().optional().default(false),
2128
+ maxDepth: z.number().int().min(1).max(50).optional().default(30),
2129
+ maxNodes: z.number().int().min(1).max(3e3).optional().default(1e3)
2130
+ },
2131
+ outputSchema: toolOutputSchema,
2132
+ annotations: {
2133
+ readOnlyHint: false,
2134
+ destructiveHint: true,
2135
+ idempotentHint: false,
2136
+ openWorldHint: false
2137
+ }
1569
2138
  },
1570
- outputSchema: toolOutputSchema,
1571
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1572
- }, ({ target, maxDepth }) => callStudio(bridge, CliMsg.V2_SNAPSHOT_UI, { target, maxDepth }, 12e4));
1573
- server.registerTool("studio_run_test", {
1574
- title: "Run Roblox Studio Test",
1575
- description: "Run a bounded StudioTestService run, play, or multiplayer scenario. The game test harness must call StudioTestService:EndTest with its result.",
1576
- inputSchema: {
1577
- mode: z.enum(["run", "play", "multiplayer"]).optional().default("run"),
1578
- args: z.unknown().optional(),
1579
- players: z.number().int().min(1).max(8).optional().default(1),
1580
- timeoutMs: z.number().int().min(5e3).max(6e5).optional().default(12e4)
2139
+ ({ parent, tree, replaceExisting, maxDepth, maxNodes }) => callStudio(
2140
+ bridge,
2141
+ CliMsg.V2_BUILD_UI,
2142
+ { parent, tree, replaceExisting, maxDepth, maxNodes },
2143
+ 12e4
2144
+ )
2145
+ );
2146
+ server.registerTool(
2147
+ "studio_snapshot_ui",
2148
+ {
2149
+ title: "Snapshot Roblox UI",
2150
+ description: "Serialize a live UI subtree into typed, studio_build_ui-compatible data for fidelity checks and read-back verification.",
2151
+ inputSchema: {
2152
+ target: instanceRefSchema,
2153
+ maxDepth: z.number().int().min(0).max(50).optional().default(30)
2154
+ },
2155
+ outputSchema: toolOutputSchema,
2156
+ annotations: {
2157
+ readOnlyHint: true,
2158
+ destructiveHint: false,
2159
+ idempotentHint: true,
2160
+ openWorldHint: false
2161
+ }
1581
2162
  },
1582
- outputSchema: toolOutputSchema,
1583
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
1584
- }, ({ mode, args, players, timeoutMs }) => callStudio(
1585
- bridge,
1586
- CliMsg.V2_RUN_TEST,
1587
- { mode, args, players, timeoutMs },
1588
- timeoutMs + 15e3
1589
- ));
1590
- server.registerTool("studio_get_output", {
1591
- title: "Read Studio Output",
1592
- description: "Read the bounded Studio output buffer, optionally filtered by severity. Dominus transport messages are excluded.",
1593
- inputSchema: {
1594
- limit: z.number().int().min(1).max(500).optional().default(100),
1595
- level: z.enum(["info", "warn", "error"]).optional()
2163
+ ({ target, maxDepth }) => callStudio(bridge, CliMsg.V2_SNAPSHOT_UI, { target, maxDepth }, 12e4)
2164
+ );
2165
+ server.registerTool(
2166
+ "studio_run_test",
2167
+ {
2168
+ title: "Run Roblox Studio Test",
2169
+ description: "Run a bounded StudioTestService run, play, or multiplayer scenario. The game test harness must call StudioTestService:EndTest with its result.",
2170
+ inputSchema: {
2171
+ mode: z.enum(["run", "play", "multiplayer"]).optional().default("run"),
2172
+ args: z.unknown().optional(),
2173
+ players: z.number().int().min(1).max(8).optional().default(1),
2174
+ timeoutMs: z.number().int().min(5e3).max(6e5).optional().default(12e4)
2175
+ },
2176
+ outputSchema: toolOutputSchema,
2177
+ annotations: {
2178
+ readOnlyHint: false,
2179
+ destructiveHint: false,
2180
+ idempotentHint: false,
2181
+ openWorldHint: false
2182
+ }
2183
+ },
2184
+ ({ mode, args, players, timeoutMs }) => callStudio(
2185
+ bridge,
2186
+ CliMsg.V2_RUN_TEST,
2187
+ { mode, args, players, timeoutMs },
2188
+ timeoutMs + 15e3
2189
+ )
2190
+ );
2191
+ server.registerTool(
2192
+ "studio_get_output",
2193
+ {
2194
+ title: "Read Studio Output",
2195
+ description: "Read the bounded Studio output buffer, optionally filtered by severity. Dominus transport messages are excluded.",
2196
+ inputSchema: {
2197
+ limit: z.number().int().min(1).max(500).optional().default(100),
2198
+ level: z.enum(["info", "warn", "error"]).optional()
2199
+ },
2200
+ outputSchema: toolOutputSchema,
2201
+ annotations: {
2202
+ readOnlyHint: true,
2203
+ destructiveHint: false,
2204
+ idempotentHint: true,
2205
+ openWorldHint: false
2206
+ }
2207
+ },
2208
+ ({ limit, level }) => callStudio(bridge, CliMsg.V2_GET_OUTPUT, { limit, level })
2209
+ );
2210
+ server.registerTool(
2211
+ "studio_get_reflection",
2212
+ {
2213
+ title: "Read Live Roblox Reflection",
2214
+ description: "Read current ReflectionService fields for a class in the connected Studio, including Type, Display category, and permission metadata.",
2215
+ inputSchema: { className: z.string().min(1).max(100) },
2216
+ outputSchema: toolOutputSchema,
2217
+ annotations: {
2218
+ readOnlyHint: true,
2219
+ destructiveHint: false,
2220
+ idempotentHint: true,
2221
+ openWorldHint: false
2222
+ }
1596
2223
  },
1597
- outputSchema: toolOutputSchema,
1598
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1599
- }, ({ limit, level }) => callStudio(bridge, CliMsg.V2_GET_OUTPUT, { limit, level }));
1600
- server.registerTool("studio_get_reflection", {
1601
- title: "Read Live Roblox Reflection",
1602
- description: "Read current ReflectionService fields for a class in the connected Studio, including Type, Display category, and permission metadata.",
1603
- inputSchema: { className: z.string().min(1).max(100) },
1604
- outputSchema: toolOutputSchema,
1605
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1606
- }, ({ className }) => callStudio(bridge, CliMsg.V2_GET_REFLECTION, { className }));
2224
+ ({ className }) => callStudio(bridge, CliMsg.V2_GET_REFLECTION, { className })
2225
+ );
1607
2226
  }
1608
2227
 
1609
2228
  // src/mcp/server.ts
1610
- var VERSION = "2.1.1";
2229
+ var VERSION = "2.2.1";
1611
2230
  function createDominusMcpServer(bridge) {
1612
- const server = new McpServer({
1613
- name: "dominus-2",
1614
- version: VERSION
1615
- }, {
1616
- instructions: DOMINUS_MCP_INSTRUCTIONS
1617
- });
2231
+ const server = new McpServer(
2232
+ {
2233
+ name: "dominus-2",
2234
+ version: VERSION
2235
+ },
2236
+ {
2237
+ instructions: DOMINUS_MCP_INSTRUCTIONS
2238
+ }
2239
+ );
1618
2240
  registerConnectionTools(server, bridge);
1619
2241
  registerStudioTools(server, bridge);
2242
+ registerParallelTaskTool(server, bridge);
1620
2243
  registerDocsTools(server);
1621
2244
  registerDominusResources(server, bridge);
1622
2245
  return server;
1623
2246
  }
1624
2247
  async function createStudioBridge(port) {
1625
- if (await isPortInUse(port)) {
1626
- const client = new DominusClient(port);
1627
- await client.connect();
1628
- return client;
1629
- }
1630
- const server = new DominusServer(port);
1631
- try {
1632
- await server.start();
1633
- return server;
1634
- } catch (err) {
1635
- const error = err;
1636
- if (error.code !== "EADDRINUSE") throw err;
1637
- const client = new DominusClient(port);
1638
- await client.connect();
1639
- return client;
1640
- }
2248
+ return ensureBridgeDaemon(port);
1641
2249
  }
1642
2250
  async function startMcpServer() {
1643
2251
  const config = loadConfig();