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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.1 (2026-07-13)
4
+
5
+ ### Stable Studio bridge architecture
6
+
7
+ - Moved WebSocket ownership out of individual MCP stdio processes and into one
8
+ detached local bridge daemon. MCP hosts now act as controllers, so closing or
9
+ restarting one agent no longer disconnects Studio or other agents.
10
+ - Added race-safe bridge startup and reuse. Concurrent MCP launches converge on
11
+ the same loopback bridge instead of creating separate connection state.
12
+ - The plugin installer now starts the bridge automatically and provisions the
13
+ user-specific 256-bit credential into the local plugin. Manual connection IDs
14
+ and six-digit pairing codes are no longer part of setup or recovery.
15
+ - Built the plugin root with `RunContext.Plugin` and added an entry-point guard.
16
+ Starting a Play test can no longer launch duplicate Client and Server Dominus
17
+ sessions from one Studio window.
18
+ - Added regressions for plugin run context, seamless credential provisioning,
19
+ unauthorized plugin rejection, and bridge survival across MCP controller exits.
20
+
3
21
  ## 2.2.0 (2026-07-13)
4
22
 
5
23
  ### MCP-native multi-agent coordinator
package/README.md CHANGED
@@ -15,7 +15,10 @@ Luau execution.
15
15
  sibling names for the life of the Studio session.
16
16
  - Script writes require a revision from `studio_read_script`.
17
17
  - Mutation batches and UI replacement are undoable and roll back on failure.
18
- - The installer provisions a private bridge token into the local Studio plugin.
18
+ - The installer provisions a local bridge credential automatically; users never
19
+ copy connection IDs or pairing codes.
20
+ - A single background bridge keeps Studio connected while MCP hosts and agents
21
+ start, stop, or run in parallel.
19
22
  - Multiple Studio windows are selected by unique connection ID, not place ID.
20
23
  - Roblox Creator Docs API lookup is built in.
21
24
 
@@ -65,7 +68,7 @@ For a version-pinned configuration that does not require a global install:
65
68
  "mcpServers": {
66
69
  "dominus": {
67
70
  "command": "npx",
68
- "args": ["-y", "-p", "dominus-cli@2.2.0", "dominus-mcp"]
71
+ "args": ["-y", "-p", "dominus-cli@2.2.1", "dominus-mcp"]
69
72
  }
70
73
  }
71
74
  }
@@ -75,9 +78,13 @@ Restart the MCP client after changing its configuration.
75
78
 
76
79
  ## First connection
77
80
 
78
- 1. Run `dominus-install-plugin`; restart Roblox Studio only if it does not reload.
79
- 2. Start the MCP client so `dominus-mcp` is running.
80
- 3. Open Roblox Studio. The plugin connects and authenticates automatically.
81
+ 1. Run `dominus-install-plugin`; it installs the personalized plugin and starts
82
+ the local Studio bridge in the background.
83
+ 2. Open Roblox Studio and start the MCP client. Dominus authenticates
84
+ automatically with no connection ID or pairing code.
85
+
86
+ If the local credential is replaced or lost, run `dominus-install-plugin` again.
87
+ Studio normally reloads it without a restart.
81
88
 
82
89
  If several Studio windows are open, call `dominus_select_studio` with the exact
83
90
  connection ID before editing. Two windows on the same place ID remain distinct.
@@ -139,12 +146,13 @@ Roblox documentation:
139
146
 
140
147
  ## Security model
141
148
 
142
- The MCP transport is stdio. The separate Studio WebSocket bridge binds only to
143
- `127.0.0.1`, limits messages to one MiB, rate-limits sockets, times out incomplete
144
- handshakes, and authenticates both Studio plugins and relay controllers. The local
145
- installer injects a user-specific token into the plugin, so no manual pairing flow
146
- is needed. The plugin only accepts an explicit command allowlist and contains no
147
- `loadstring` executor.
149
+ The MCP transport is stdio. A single background Studio WebSocket bridge binds
150
+ only to `127.0.0.1`, limits messages to one MiB, rate-limits sockets, times out
151
+ incomplete handshakes, and authenticates both Studio plugins and MCP controllers.
152
+ The distributed plugin contains only a credential placeholder;
153
+ `dominus-install-plugin` writes the user-specific token into the local copy. The
154
+ plugin only accepts an explicit command allowlist and contains no `loadstring`
155
+ executor.
148
156
 
149
157
  The bridge token protects against accidental or opportunistic access by unrelated
150
158
  local processes. Like other user-level credentials, it cannot protect against a
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import * as crypto from 'crypto';
6
+ import { randomUUID } from 'crypto';
7
+ import { EventEmitter } from 'events';
8
+ import { WebSocket, WebSocketServer } from 'ws';
9
+ import { nanoid } from 'nanoid';
10
+ import { z } from 'zod';
11
+
12
+ var DOMINUS_DIR = path.join(os.homedir(), ".dominus");
13
+ var CONFIG_PATH = path.join(DOMINUS_DIR, "config.json");
14
+ path.join(DOMINUS_DIR, "dominus.db");
15
+ path.join(DOMINUS_DIR, "rules.json");
16
+ var DEFAULTS = {
17
+ provider: "openai",
18
+ apiBaseUrl: "",
19
+ model: "",
20
+ port: 18088,
21
+ maxToolIterations: 25,
22
+ maxVerifyRetries: 3,
23
+ tokenBudget: 128e3,
24
+ autoIndex: true,
25
+ autoLearn: true,
26
+ userName: "Developer"
27
+ };
28
+ function ensureDir() {
29
+ if (!fs.existsSync(DOMINUS_DIR)) {
30
+ fs.mkdirSync(DOMINUS_DIR, { recursive: true });
31
+ }
32
+ }
33
+ function readJson(filePath, defaults) {
34
+ try {
35
+ if (fs.existsSync(filePath)) {
36
+ const raw = fs.readFileSync(filePath, "utf-8");
37
+ return { ...defaults, ...JSON.parse(raw) };
38
+ }
39
+ } catch {
40
+ }
41
+ return { ...defaults };
42
+ }
43
+ function loadConfig() {
44
+ ensureDir();
45
+ return readJson(CONFIG_PATH, DEFAULTS);
46
+ }
47
+ function getDominusDir() {
48
+ ensureDir();
49
+ return DOMINUS_DIR;
50
+ }
51
+ var DOMINUS_PROTOCOL_VERSION = 2;
52
+ var MAX_MESSAGE_BYTES = 1024 * 1024;
53
+ var StudioMsg = {
54
+ HELLO: "studio:hello",
55
+ CONNECTED: "studio:connected",
56
+ DISCONNECTED: "studio:disconnected",
57
+ RESPONSE: "studio:response",
58
+ AUTHENTICATED: "server:authenticated",
59
+ AUTH_REJECTED: "server:auth_rejected"
60
+ };
61
+ var ControllerMsg = {
62
+ HELLO: "controller:hello",
63
+ WELCOME: "controller:welcome",
64
+ RELAY: "controller:relay",
65
+ TARGET_STATE: "controller:target_state",
66
+ SET_ACTIVE_CONNECTION: "controller:set_active_connection"
67
+ };
68
+ function createMessage(type, payload) {
69
+ return { id: nanoid(), type, payload, ts: Date.now() };
70
+ }
71
+ function parseMessage(raw) {
72
+ if (Buffer.byteLength(raw, "utf8") > MAX_MESSAGE_BYTES) {
73
+ throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
74
+ }
75
+ try {
76
+ const parsed = JSON.parse(raw);
77
+ return wsMessageSchema.parse(parsed);
78
+ } catch {
79
+ throw new Error(`Failed to parse WebSocket message: ${raw.slice(0, 100)}`);
80
+ }
81
+ }
82
+ function serializeMessage(msg) {
83
+ const serialized = JSON.stringify(msg);
84
+ if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
85
+ throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
86
+ }
87
+ return serialized;
88
+ }
89
+ var wsMessageSchema = z.object({
90
+ id: z.string().min(1).max(128),
91
+ type: z.string().min(1).max(128),
92
+ payload: z.unknown(),
93
+ ts: z.number().finite()
94
+ }).strict();
95
+ var TOKEN_FILE = "bridge-token";
96
+ function loadOrCreateBridgeToken() {
97
+ const tokenPath = path.join(getDominusDir(), TOKEN_FILE);
98
+ try {
99
+ const existing = fs.readFileSync(tokenPath, "utf8").trim();
100
+ if (existing.length >= 32) return existing;
101
+ } catch {
102
+ }
103
+ const token = crypto.randomBytes(32).toString("base64url");
104
+ fs.writeFileSync(tokenPath, `${token}
105
+ `, { encoding: "utf8", mode: 384 });
106
+ try {
107
+ fs.chmodSync(tokenPath, 384);
108
+ } catch {
109
+ }
110
+ return token;
111
+ }
112
+ function tokensEqual(actual, expected) {
113
+ if (typeof actual !== "string") return false;
114
+ const actualBuffer = Buffer.from(actual);
115
+ const expectedBuffer = Buffer.from(expected);
116
+ return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
117
+ }
118
+
119
+ // src/transport/ws-server.ts
120
+ var HANDSHAKE_TIMEOUT_MS = 8e3;
121
+ var RATE_WINDOW_MS = 1e4;
122
+ var RATE_LIMIT = 250;
123
+ var DominusServer = class extends EventEmitter {
124
+ wss = null;
125
+ studioClients = /* @__PURE__ */ new Map();
126
+ wsToConnectionId = /* @__PURE__ */ new Map();
127
+ controllers = /* @__PURE__ */ new Set();
128
+ socketRoles = /* @__PURE__ */ new Map();
129
+ pendingRequests = /* @__PURE__ */ new Map();
130
+ relayRequests = /* @__PURE__ */ new Map();
131
+ rateState = /* @__PURE__ */ new Map();
132
+ token;
133
+ host;
134
+ maxPayloadBytes;
135
+ handshakeTimeoutMs;
136
+ activeConnectionId = null;
137
+ port;
138
+ constructor(port = 18088, options = {}) {
139
+ super();
140
+ this.port = port;
141
+ this.token = options.token ?? loadOrCreateBridgeToken();
142
+ this.host = options.host ?? "127.0.0.1";
143
+ this.maxPayloadBytes = options.maxPayloadBytes ?? MAX_MESSAGE_BYTES;
144
+ this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
145
+ }
146
+ start(maxRetries = 0) {
147
+ return new Promise((resolve, reject) => {
148
+ const tryPort = (port, attempt) => {
149
+ const wss = new WebSocketServer({
150
+ port,
151
+ host: this.host,
152
+ maxPayload: this.maxPayloadBytes,
153
+ perMessageDeflate: false
154
+ });
155
+ wss.on("listening", () => {
156
+ this.wss = wss;
157
+ this.port = port;
158
+ wss.on("connection", (ws) => this.handleConnection(ws));
159
+ this.emit("server:started", { port, host: this.host });
160
+ resolve();
161
+ });
162
+ wss.on("error", (err) => {
163
+ if (err.code === "EADDRINUSE" && attempt < maxRetries) {
164
+ wss.close();
165
+ tryPort(port + 1, attempt + 1);
166
+ return;
167
+ }
168
+ this.emit("server:error", err);
169
+ reject(err);
170
+ });
171
+ };
172
+ tryPort(this.port, 0);
173
+ });
174
+ }
175
+ handleConnection(ws) {
176
+ this.socketRoles.set(ws, "unknown");
177
+ this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
178
+ const handshakeTimer = setTimeout(() => {
179
+ if (this.socketRoles.get(ws) === "unknown") {
180
+ ws.close(1008, "Dominus handshake timed out");
181
+ }
182
+ }, this.handshakeTimeoutMs);
183
+ ws.on("message", (raw) => {
184
+ try {
185
+ if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
186
+ ws.close(1009, "Dominus message limit exceeded");
187
+ return;
188
+ }
189
+ const msg = parseMessage(raw.toString());
190
+ const role = this.socketRoles.get(ws) ?? "unknown";
191
+ if (role === "unknown") {
192
+ clearTimeout(handshakeTimer);
193
+ this.handleHandshake(ws, msg);
194
+ } else if (role === "controller") {
195
+ this.handleControllerMessage(ws, msg);
196
+ } else if (role === "studio") {
197
+ this.handleStudioMessage(ws, msg);
198
+ }
199
+ } catch (err) {
200
+ this.emit("server:error", err);
201
+ this.sendSafe(
202
+ ws,
203
+ createMessage(StudioMsg.AUTH_REJECTED, {
204
+ error: err instanceof Error ? err.message : String(err)
205
+ })
206
+ );
207
+ }
208
+ });
209
+ ws.on("close", () => {
210
+ clearTimeout(handshakeTimer);
211
+ this.cleanupSocket(ws);
212
+ });
213
+ ws.on("error", (err) => this.emit("server:error", err));
214
+ }
215
+ consumeRateBudget(ws) {
216
+ const now = Date.now();
217
+ const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
218
+ if (now - state.startedAt >= RATE_WINDOW_MS) {
219
+ state.startedAt = now;
220
+ state.count = 0;
221
+ }
222
+ state.count += 1;
223
+ this.rateState.set(ws, state);
224
+ return state.count <= RATE_LIMIT;
225
+ }
226
+ handleHandshake(ws, msg) {
227
+ if (msg.type === StudioMsg.HELLO) {
228
+ const hello = validateStudioHello(msg.payload);
229
+ if (tokensEqual(hello.token, this.token)) {
230
+ this.authorizeStudio(ws, hello, randomUUID());
231
+ } else {
232
+ this.sendSafe(
233
+ ws,
234
+ createMessage(StudioMsg.AUTH_REJECTED, {
235
+ error: "Studio credentials are missing or stale. Run dominus-install-plugin."
236
+ })
237
+ );
238
+ ws.close(1008, "Unauthorized Studio plugin");
239
+ }
240
+ return;
241
+ }
242
+ if (msg.type === ControllerMsg.HELLO) {
243
+ const payload = msg.payload;
244
+ if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
245
+ this.sendSafe(
246
+ ws,
247
+ createMessage(StudioMsg.AUTH_REJECTED, {
248
+ error: "Invalid Dominus controller credentials"
249
+ })
250
+ );
251
+ ws.close(1008, "Unauthorized controller");
252
+ return;
253
+ }
254
+ this.socketRoles.set(ws, "controller");
255
+ this.controllers.add(ws);
256
+ this.sendSafe(
257
+ ws,
258
+ createMessage(ControllerMsg.WELCOME, {
259
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
260
+ port: this.port,
261
+ connections: this.listConnections(),
262
+ activeConnectionId: this.activeConnectionId
263
+ })
264
+ );
265
+ return;
266
+ }
267
+ this.sendSafe(
268
+ ws,
269
+ createMessage(StudioMsg.AUTH_REJECTED, {
270
+ error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
271
+ })
272
+ );
273
+ ws.close(1008, "Invalid Dominus handshake");
274
+ }
275
+ authorizeStudio(ws, hello, connectionId) {
276
+ const connection = {
277
+ ws,
278
+ connectionId,
279
+ clientId: hello.clientId,
280
+ connectedAt: Date.now(),
281
+ studioVersion: hello.studioVersion,
282
+ placeId: hello.placeId ?? 0,
283
+ placeName: hello.placeName
284
+ };
285
+ this.studioClients.set(connectionId, connection);
286
+ this.wsToConnectionId.set(ws, connectionId);
287
+ this.socketRoles.set(ws, "studio");
288
+ if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
289
+ this.activeConnectionId = connectionId;
290
+ }
291
+ this.sendSafe(
292
+ ws,
293
+ createMessage(StudioMsg.AUTHENTICATED, {
294
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
295
+ connectionId,
296
+ token: this.token
297
+ })
298
+ );
299
+ const info = this.toPlaceInfo(connection);
300
+ this.emit("studio:connected", info);
301
+ this.broadcastToControllers(StudioMsg.CONNECTED, info);
302
+ this.broadcastTargetState();
303
+ return connection;
304
+ }
305
+ handleControllerMessage(controllerWs, msg) {
306
+ if (msg.type === ControllerMsg.RELAY) {
307
+ const payload = msg.payload;
308
+ const request = parseMessage(JSON.stringify(payload.request));
309
+ const target = this.resolveConnection(
310
+ typeof payload.connectionId === "string" ? payload.connectionId : null
311
+ );
312
+ if (!target) {
313
+ this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
314
+ return;
315
+ }
316
+ this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
317
+ this.sendSafe(target.ws, request);
318
+ return;
319
+ }
320
+ if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
321
+ const payload = msg.payload;
322
+ const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
323
+ if (connectionId && !this.studioClients.has(connectionId)) {
324
+ this.sendResponse(controllerWs, msg.id, {
325
+ success: false,
326
+ error: `Studio connection ${connectionId} is not connected`
327
+ });
328
+ return;
329
+ }
330
+ this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
331
+ return;
332
+ }
333
+ this.sendResponse(controllerWs, msg.id, {
334
+ error: `Unsupported controller message: ${msg.type}`
335
+ });
336
+ }
337
+ handleStudioMessage(ws, msg) {
338
+ if (msg.type === StudioMsg.RESPONSE) {
339
+ const relay = this.relayRequests.get(msg.id);
340
+ if (relay) {
341
+ if (relay.targetWs !== ws) {
342
+ this.emit(
343
+ "server:error",
344
+ new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`)
345
+ );
346
+ return;
347
+ }
348
+ this.sendSafe(relay.controllerWs, msg);
349
+ this.relayRequests.delete(msg.id);
350
+ return;
351
+ }
352
+ const pending = this.pendingRequests.get(msg.id);
353
+ if (pending) {
354
+ if (pending.targetWs !== ws) {
355
+ this.emit(
356
+ "server:error",
357
+ new Error(`Ignored response ${msg.id} from the wrong Studio session`)
358
+ );
359
+ return;
360
+ }
361
+ clearTimeout(pending.timer);
362
+ this.pendingRequests.delete(msg.id);
363
+ pending.resolve(msg);
364
+ }
365
+ return;
366
+ }
367
+ this.emit(msg.type, msg.payload);
368
+ this.emit("message", msg);
369
+ }
370
+ cleanupSocket(ws) {
371
+ const role = this.socketRoles.get(ws);
372
+ this.socketRoles.delete(ws);
373
+ this.rateState.delete(ws);
374
+ if (role === "controller") {
375
+ this.controllers.delete(ws);
376
+ for (const [id, relay] of this.relayRequests) {
377
+ if (relay.controllerWs === ws) this.relayRequests.delete(id);
378
+ }
379
+ return;
380
+ }
381
+ const connectionId = this.wsToConnectionId.get(ws);
382
+ if (!connectionId) return;
383
+ const connection = this.studioClients.get(connectionId);
384
+ this.wsToConnectionId.delete(ws);
385
+ this.studioClients.delete(connectionId);
386
+ for (const [id, pending] of this.pendingRequests) {
387
+ if (pending.targetWs === ws) {
388
+ clearTimeout(pending.timer);
389
+ pending.reject(new Error(`Studio disconnected (${connectionId})`));
390
+ this.pendingRequests.delete(id);
391
+ }
392
+ }
393
+ for (const [id, relay] of this.relayRequests) {
394
+ if (relay.targetWs === ws) {
395
+ this.sendResponse(relay.controllerWs, id, {
396
+ error: `Studio disconnected (${connectionId})`
397
+ });
398
+ this.relayRequests.delete(id);
399
+ }
400
+ }
401
+ if (this.activeConnectionId === connectionId) {
402
+ this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
403
+ }
404
+ const info = connection ? this.toPlaceInfo(connection) : { connectionId };
405
+ this.emit("studio:disconnected", info);
406
+ this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
407
+ this.broadcastTargetState();
408
+ }
409
+ resolveConnection(connectionId = this.activeConnectionId) {
410
+ if (connectionId) {
411
+ const connection = this.studioClients.get(connectionId);
412
+ return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
413
+ }
414
+ if (this.studioClients.size === 1) {
415
+ const connection = this.studioClients.values().next().value;
416
+ return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
417
+ }
418
+ return null;
419
+ }
420
+ getTargetError() {
421
+ if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
422
+ if (this.activeConnectionId)
423
+ return `Studio connection ${this.activeConnectionId} is unavailable`;
424
+ return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
425
+ }
426
+ toPlaceInfo(connection) {
427
+ return {
428
+ placeId: connection.placeId ?? 0,
429
+ connectionId: connection.connectionId,
430
+ clientId: connection.clientId,
431
+ placeName: connection.placeName,
432
+ studioVersion: connection.studioVersion,
433
+ connectedAt: connection.connectedAt,
434
+ active: connection.connectionId === this.activeConnectionId
435
+ };
436
+ }
437
+ broadcastToControllers(type, payload) {
438
+ const message = createMessage(type, payload);
439
+ for (const controller of this.controllers) this.sendSafe(controller, message);
440
+ }
441
+ broadcastTargetState() {
442
+ this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
443
+ connections: this.listConnections(),
444
+ defaultActiveConnectionId: this.activeConnectionId
445
+ });
446
+ }
447
+ sendResponse(ws, id, payload) {
448
+ this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
449
+ }
450
+ sendSafe(ws, message) {
451
+ if (ws.readyState !== WebSocket.OPEN) return;
452
+ try {
453
+ ws.send(serializeMessage(message));
454
+ } catch (err) {
455
+ this.emit("server:error", err);
456
+ }
457
+ }
458
+ sendRequest(type, payload, timeoutMs = 15e3) {
459
+ return new Promise((resolve, reject) => {
460
+ const target = this.resolveConnection();
461
+ if (!target) {
462
+ reject(new Error(this.getTargetError()));
463
+ return;
464
+ }
465
+ const message = createMessage(type, payload);
466
+ const timer = setTimeout(() => {
467
+ this.pendingRequests.delete(message.id);
468
+ reject(new Error(`Studio request timed out: ${type}`));
469
+ }, timeoutMs);
470
+ this.pendingRequests.set(message.id, {
471
+ resolve,
472
+ reject,
473
+ timer,
474
+ targetWs: target.ws
475
+ });
476
+ this.sendSafe(target.ws, message);
477
+ });
478
+ }
479
+ sendNotification(type, payload) {
480
+ const target = this.resolveConnection();
481
+ if (target) this.sendSafe(target.ws, createMessage(type, payload));
482
+ }
483
+ isConnected() {
484
+ return this.resolveConnection() !== null;
485
+ }
486
+ getConnectionInfo() {
487
+ return this.resolveConnection();
488
+ }
489
+ listConnections() {
490
+ return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
491
+ }
492
+ getActiveConnectionId() {
493
+ return this.activeConnectionId;
494
+ }
495
+ async setActiveConnectionId(connectionId) {
496
+ if (connectionId && !this.studioClients.has(connectionId)) {
497
+ throw new Error(`Studio connection ${connectionId} is not connected`);
498
+ }
499
+ this.activeConnectionId = connectionId;
500
+ this.broadcastTargetState();
501
+ }
502
+ getActivePlaceId() {
503
+ return this.resolveConnection()?.placeId ?? 0;
504
+ }
505
+ setActivePlaceId(placeId) {
506
+ if (placeId === 0) {
507
+ this.activeConnectionId = null;
508
+ return;
509
+ }
510
+ const matches = [...this.studioClients.values()].filter(
511
+ (connection) => connection.placeId === placeId
512
+ );
513
+ if (matches.length !== 1) {
514
+ throw new Error(
515
+ matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
516
+ );
517
+ }
518
+ this.activeConnectionId = matches[0].connectionId;
519
+ }
520
+ getPort() {
521
+ return this.port;
522
+ }
523
+ stop() {
524
+ return new Promise((resolve) => {
525
+ for (const pending of this.pendingRequests.values()) {
526
+ clearTimeout(pending.timer);
527
+ pending.reject(new Error("Dominus bridge is shutting down"));
528
+ }
529
+ this.pendingRequests.clear();
530
+ this.relayRequests.clear();
531
+ for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
532
+ this.studioClients.clear();
533
+ this.controllers.clear();
534
+ this.socketRoles.clear();
535
+ this.wsToConnectionId.clear();
536
+ if (this.wss) this.wss.close(() => resolve());
537
+ else resolve();
538
+ });
539
+ }
540
+ };
541
+ function validateStudioHello(payload) {
542
+ if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
543
+ const hello = payload;
544
+ if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
545
+ throw new Error(
546
+ `Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`
547
+ );
548
+ }
549
+ if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
550
+ throw new Error("Studio hello is missing a valid clientId");
551
+ }
552
+ if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
553
+ throw new Error("Studio hello is missing studioVersion");
554
+ }
555
+ if (hello.placeName && hello.placeName.length > 256)
556
+ throw new Error("Studio placeName is too long");
557
+ return hello;
558
+ }
559
+ function rawByteLength(raw) {
560
+ if (typeof raw === "string") return Buffer.byteLength(raw);
561
+ if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
562
+ return raw.byteLength;
563
+ }
564
+
565
+ // src/transport/bridge-daemon.ts
566
+ var configuredPort = Number(process.env.DOMINUS_BRIDGE_PORT ?? loadConfig().port);
567
+ if (!Number.isInteger(configuredPort) || configuredPort < 1 || configuredPort > 65535) {
568
+ throw new Error(`Invalid Dominus bridge port: ${configuredPort}`);
569
+ }
570
+ var bridge = new DominusServer(configuredPort);
571
+ await bridge.start();
572
+ var stopping = false;
573
+ async function stop() {
574
+ if (stopping) return;
575
+ stopping = true;
576
+ await bridge.stop();
577
+ }
578
+ process.once("SIGINT", () => void stop().finally(() => process.exit(0)));
579
+ process.once("SIGTERM", () => void stop().finally(() => process.exit(0)));
580
+ //# sourceMappingURL=bridge-daemon.js.map
581
+ //# sourceMappingURL=bridge-daemon.js.map