@quenty/studio-bridge 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +2 -2
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
@@ -14,7 +14,11 @@ vi.mock('../../../process/studio-process-closer.js', async (importOriginal) => {
14
14
  import * as fs from 'fs/promises';
15
15
  import * as os from 'os';
16
16
  import * as path from 'path';
17
- import { closeDeployTargetAsync, processCloseHandlerAsync } from './close.js';
17
+ import {
18
+ closeAllStudiosAsync,
19
+ closeDeployTargetAsync,
20
+ processCloseHandlerAsync,
21
+ } from './close.js';
18
22
  import {
19
23
  closeStudioProcessAsync,
20
24
  listStudioProcessesAsync,
@@ -124,13 +128,13 @@ describe('processCloseHandlerAsync', () => {
124
128
  expect(closeStudioProcessAsync).toHaveBeenCalledWith(10, { force: true });
125
129
  });
126
130
 
127
- it('suggests --force when Studio stays open', async () => {
131
+ it('says Studio is asking to save when it stays open', async () => {
128
132
  vi.mocked(closeStudioProcessAsync).mockResolvedValue(false);
129
133
 
130
134
  const result = await processCloseHandlerAsync(session);
131
135
 
132
136
  expect(result.success).toBe(false);
133
- expect(result.summary).toContain('--force');
137
+ expect(result.summary).toContain('likely asking to save');
134
138
  });
135
139
 
136
140
  it('fails without closing when no window shows the place', async () => {
@@ -248,3 +252,48 @@ describe('closeDeployTargetAsync', () => {
248
252
  expect(result).toMatchObject({ success: true, pid: 50 });
249
253
  });
250
254
  });
255
+
256
+ describe('closeAllStudiosAsync', () => {
257
+ beforeEach(() => {
258
+ vi.mocked(closeStudioProcessAsync).mockReset().mockResolvedValue(true);
259
+ });
260
+
261
+ it('closes every running Studio without saving', async () => {
262
+ vi.mocked(listStudioProcessesAsync).mockResolvedValue([
263
+ { pid: 10, windowTitle: 'Place1 - Roblox Studio' },
264
+ { pid: 20, windowTitle: 'Place2 - Roblox Studio' },
265
+ ]);
266
+
267
+ const result = await closeAllStudiosAsync({ force: true });
268
+
269
+ expect(closeStudioProcessAsync).toHaveBeenCalledWith(10, { force: true });
270
+ expect(closeStudioProcessAsync).toHaveBeenCalledWith(20, { force: true });
271
+ expect(result).toMatchObject({ success: true, pids: [10, 20] });
272
+ expect(result.summary).toBe('Closed 2 Studios (pids 10, 20)');
273
+ });
274
+
275
+ it('succeeds when no Studio is running', async () => {
276
+ vi.mocked(listStudioProcessesAsync).mockResolvedValue([]);
277
+
278
+ const result = await closeAllStudiosAsync({ force: true });
279
+
280
+ expect(closeStudioProcessAsync).not.toHaveBeenCalled();
281
+ expect(result).toMatchObject({ success: true, pids: [] });
282
+ });
283
+
284
+ it('names the Studios that stayed open', async () => {
285
+ vi.mocked(listStudioProcessesAsync).mockResolvedValue([
286
+ { pid: 10, windowTitle: 'Place1 - Roblox Studio' },
287
+ { pid: 20, windowTitle: 'Place2 - Roblox Studio' },
288
+ ]);
289
+ vi.mocked(closeStudioProcessAsync).mockImplementation(
290
+ async (pid) => pid === 10
291
+ );
292
+
293
+ const result = await closeAllStudiosAsync({ force: false });
294
+
295
+ expect(result.success).toBe(false);
296
+ expect(result.pids).toEqual([10]);
297
+ expect(result.summary).toContain('still open (pids 20)');
298
+ });
299
+ });
@@ -18,6 +18,7 @@ import {
18
18
  resolveDeployPlaceAsync,
19
19
  } from '../../../deploy/deploy-places.js';
20
20
  import { explainMissingPlaytestContextAsync } from '../../playtest/playtest-sessions.js';
21
+ import { resolveSessionPidsAsync } from '../../../process/studio-process-identity.js';
21
22
  import { describePlaytest } from '../info/info.js';
22
23
 
23
24
  export interface ProcessCloseResult {
@@ -26,6 +27,7 @@ export interface ProcessCloseResult {
26
27
  /** The `deploy.nevermore.json` target whose Studio was closed, if one was named. */
27
28
  deployTarget?: string;
28
29
  pid?: number;
30
+ pids?: number[];
29
31
  summary: string;
30
32
  }
31
33
 
@@ -36,6 +38,8 @@ export interface ProcessCloseOptions {
36
38
 
37
39
  interface ProcessCloseArgs {
38
40
  deployTarget?: string;
41
+ all?: boolean;
42
+ askToSave?: boolean;
39
43
  force?: boolean;
40
44
  pid?: number;
41
45
  }
@@ -74,6 +78,15 @@ export async function processCloseHandlerAsync(
74
78
  ?.placeId;
75
79
  candidates = matchStudioProcesses(processes, placeName, placeId);
76
80
  }
81
+ if (candidates.length > 1 && options.pid === undefined && connection) {
82
+ const pid = (
83
+ await resolveSessionPidsAsync(connection, processes, [instanceId])
84
+ ).get(instanceId);
85
+ const own = candidates.filter((proc) => proc.pid === pid);
86
+ if (own.length === 1) {
87
+ candidates = own;
88
+ }
89
+ }
77
90
 
78
91
  return closeCandidateAsync(candidates, `'${placeName}'`, options, {
79
92
  sessionId,
@@ -129,6 +142,47 @@ export async function closeDeployTargetAsync(
129
142
  return closeCandidateAsync(candidates, showing, options, base);
130
143
  }
131
144
 
145
+ export async function closeAllStudiosAsync(
146
+ options: Pick<ProcessCloseOptions, 'force'> = {}
147
+ ): Promise<ProcessCloseResult> {
148
+ const processes = await listStudioProcessesAsync();
149
+ if (processes.length === 0) {
150
+ return { success: true, pids: [], summary: 'No Studio is running' };
151
+ }
152
+
153
+ const results = await Promise.all(
154
+ processes.map(async ({ pid }) => ({
155
+ pid,
156
+ exited: await closeStudioProcessAsync(pid, { force: options.force }),
157
+ }))
158
+ );
159
+ const closed = results.filter((r) => r.exited).map((r) => r.pid);
160
+ const open = results.filter((r) => !r.exited).map((r) => r.pid);
161
+ const plural = (n: number) => (n === 1 ? 'Studio' : 'Studios');
162
+
163
+ if (open.length > 0) {
164
+ return {
165
+ success: false,
166
+ pids: closed,
167
+ summary:
168
+ `Closed ${closed.length} ${plural(closed.length)}; ${
169
+ open.length
170
+ } ${plural(open.length)} still open (pids ${open.join(', ')})` +
171
+ (options.force
172
+ ? ''
173
+ : ', likely asking to save. Run without --ask-to-save to close without saving.'),
174
+ };
175
+ }
176
+
177
+ return {
178
+ success: true,
179
+ pids: closed,
180
+ summary: `Closed ${closed.length} ${plural(
181
+ closed.length
182
+ )} (pids ${closed.join(', ')})`,
183
+ };
184
+ }
185
+
132
186
  async function closeCandidateAsync(
133
187
  candidates: RunningStudioProcess[],
134
188
  showing: string,
@@ -163,7 +217,7 @@ async function closeCandidateAsync(
163
217
  return fail(
164
218
  options.force
165
219
  ? `Studio (pid ${pid}) did not exit`
166
- : `Studio (pid ${pid}) is still open, likely asking to save. Use --force to close without saving.`,
220
+ : `Studio (pid ${pid}) is still open, likely asking to save. Run without --ask-to-save to close without saving.`,
167
221
  pid
168
222
  );
169
223
  }
@@ -183,7 +237,7 @@ export const processCloseCommand = defineCommand<
183
237
  group: 'process',
184
238
  name: 'close',
185
239
  description:
186
- "Close a Studio: the one a session belongs to, or the one showing a deploy.nevermore.json target's place",
240
+ "Close a Studio without saving: the one a session belongs to, the one showing a deploy.nevermore.json target's place, or with --all every Studio",
187
241
  category: 'infrastructure',
188
242
  safety: 'mutate',
189
243
  scope: 'connection',
@@ -193,8 +247,16 @@ export const processCloseCommand = defineCommand<
193
247
  'deploy.nevermore.json target whose Studio to close, e.g. test or integration.places.hub',
194
248
  required: false,
195
249
  }),
250
+ all: arg.flag({
251
+ description: 'Close every running Studio, connected or not',
252
+ }),
253
+ askToSave: arg.flag({
254
+ description:
255
+ 'Let Studio prompt to save first, instead of closing without saving',
256
+ }),
196
257
  force: arg.flag({
197
- description: 'Kill Studio without letting it prompt to save',
258
+ description:
259
+ 'Close without saving. The default, accepted for scripts that pass it',
198
260
  alias: 'f',
199
261
  }),
200
262
  pid: arg.option({
@@ -203,7 +265,19 @@ export const processCloseCommand = defineCommand<
203
265
  }),
204
266
  },
205
267
  handler: async (connection, args, targeting) => {
206
- const options = { force: args.force, pid: args.pid };
268
+ const options = { force: !args.askToSave, pid: args.pid };
269
+ if (args.all) {
270
+ if (
271
+ args.deployTarget !== undefined ||
272
+ args.pid !== undefined ||
273
+ targeting.target !== undefined
274
+ ) {
275
+ throw new Error(
276
+ '--all closes every Studio; drop the target, --target and --pid.'
277
+ );
278
+ }
279
+ return closeAllStudiosAsync(options);
280
+ }
207
281
  if (args.deployTarget !== undefined) {
208
282
  return closeDeployTargetAsync(connection, args.deployTarget, options);
209
283
  }
@@ -228,3 +228,79 @@ describe('describePlaytest', () => {
228
228
  expect(playtest?.sessions).toHaveLength(3);
229
229
  });
230
230
  });
231
+
232
+ describe('queryStateHandlerAsync with MCP', () => {
233
+ const ok = (studios: any[]) => async () => ({
234
+ status: 'ok' as const,
235
+ studios,
236
+ checkedAt: Date.now(),
237
+ latencyMs: 500,
238
+ });
239
+
240
+ it('reports the studio_id MCP reaches this Studio by', async () => {
241
+ const session = createMockSession({});
242
+
243
+ const result = await queryStateHandlerAsync(session, undefined, {
244
+ mcpSnapshotAsync: ok([
245
+ {
246
+ studioId: 'mcp-1',
247
+ placeName: 'TestPlace',
248
+ sessionId: 'test-place-3f9a-edit',
249
+ },
250
+ ]),
251
+ });
252
+
253
+ expect(result.mcp).toEqual({ status: 'ok', studioId: 'mcp-1' });
254
+ expect(formatStateText(result)).toContain(
255
+ 'MCP: reachable (studio_id mcp-1)'
256
+ );
257
+ });
258
+
259
+ it('says when MCP does not see this Studio', async () => {
260
+ const result = await queryStateHandlerAsync(
261
+ createMockSession({}),
262
+ undefined,
263
+ {
264
+ mcpSnapshotAsync: ok([]),
265
+ }
266
+ );
267
+
268
+ expect(formatStateText(result)).toContain(
269
+ 'MCP: not seen by its MCP server'
270
+ );
271
+ });
272
+
273
+ it('reports MCP being unavailable without failing the rest', async () => {
274
+ const result = await queryStateHandlerAsync(
275
+ createMockSession({}),
276
+ undefined,
277
+ {
278
+ mcpSnapshotAsync: async () => ({
279
+ status: 'timeout' as const,
280
+ studios: [],
281
+ message: 'tools/call timed out',
282
+ checkedAt: Date.now(),
283
+ latencyMs: 3000,
284
+ }),
285
+ }
286
+ );
287
+
288
+ expect(result.placeId).toBe(12345);
289
+ expect(formatStateText(result)).toContain(
290
+ 'MCP: timeout (tools/call timed out)'
291
+ );
292
+ });
293
+
294
+ it('leaves MCP out when the check gave up', async () => {
295
+ const result = await queryStateHandlerAsync(
296
+ createMockSession({}),
297
+ undefined,
298
+ {
299
+ mcpSnapshotAsync: async () => undefined,
300
+ }
301
+ );
302
+
303
+ expect(result.mcp).toBeUndefined();
304
+ expect(formatStateText(result)).not.toContain('MCP:');
305
+ });
306
+ });
@@ -21,6 +21,11 @@ import {
21
21
  } from '../../../process/studio-process-closer.js';
22
22
  import { tryGetStudioWindowStateAsync } from '../../../process/studio-window-state.js';
23
23
  import { getPluginBuildVersionAsync } from '../../../plugin/plugin-build-version.js';
24
+ import { arg } from '../../framework/arg-builder.js';
25
+ import {
26
+ getStudioMcpSnapshotAsync,
27
+ type StudioMcpSnapshot,
28
+ } from '../../../roblox-mcp/index.js';
24
29
 
25
30
  export interface PlaytestInfo {
26
31
  mode: 'play' | 'run';
@@ -65,13 +70,25 @@ export interface StateResult {
65
70
  focused: boolean;
66
71
  minimized: boolean;
67
72
  };
73
+ mcp?: {
74
+ status: StudioMcpSnapshot['status'];
75
+ studioId?: string;
76
+ message?: string;
77
+ };
68
78
  connectedAt: string;
69
79
  summary: string;
70
80
  }
71
81
 
82
+ export interface QueryStateOptions {
83
+ mcpSnapshotAsync?: (
84
+ sessions: SessionInfo[]
85
+ ) => Promise<StudioMcpSnapshot | undefined>;
86
+ }
87
+
72
88
  export async function queryStateHandlerAsync(
73
89
  session: BridgeSession,
74
- connection?: BridgeConnection
90
+ connection?: BridgeConnection,
91
+ options: QueryStateOptions = {}
75
92
  ): Promise<StateResult> {
76
93
  // Listed sessions carry the edit session's place name for a playtest
77
94
  const listed = connection
@@ -84,9 +101,10 @@ export async function queryStateHandlerAsync(
84
101
  // playtest shows in the edit window
85
102
  const ownWindow =
86
103
  session.info.context !== 'edit' && playtest?.playerCount !== undefined;
87
- const [result, expectedVersion] = await Promise.all([
104
+ const [result, expectedVersion, snapshot] = await Promise.all([
88
105
  session.queryStateAsync(),
89
106
  getPluginBuildVersionAsync(),
107
+ options.mcpSnapshotAsync?.(connection?.listSessions() ?? [session.info]),
90
108
  ]);
91
109
  const window = await tryGetWindowAsync(
92
110
  ownWindow ? [result.placeName] : [result.placeName, listed?.placeName],
@@ -111,6 +129,7 @@ export async function queryStateHandlerAsync(
111
129
  },
112
130
  playtest,
113
131
  window,
132
+ ...(snapshot ? { mcp: describeMcp(snapshot, session.info) } : {}),
114
133
  connectedAt: new Date(session.info.connectedAt).toISOString(),
115
134
  summary: `Mode: ${result.state}, Place: ${result.placeName} (${result.placeId})`,
116
135
  };
@@ -166,6 +185,36 @@ async function tryGetWindowAsync(
166
185
  return undefined;
167
186
  }
168
187
 
188
+ function describeMcp(
189
+ snapshot: StudioMcpSnapshot,
190
+ info: SessionInfo
191
+ ): NonNullable<StateResult['mcp']> {
192
+ if (snapshot.status !== 'ok') {
193
+ return {
194
+ status: snapshot.status,
195
+ ...(snapshot.message ? { message: snapshot.message } : {}),
196
+ };
197
+ }
198
+ const editSessionId =
199
+ info.context === 'edit' ? info.sessionId : `${info.instanceId}-edit`;
200
+ const studio = snapshot.studios.find((s) => s.sessionId === editSessionId);
201
+ return studio
202
+ ? { status: 'ok', studioId: studio.studioId }
203
+ : { status: 'ok', message: "Studio's MCP server does not see this Studio" };
204
+ }
205
+
206
+ function formatMcp(mcp: StateResult['mcp']): string | undefined {
207
+ if (!mcp || mcp.status === 'unsupported') return undefined;
208
+ if (mcp.status === 'ok') {
209
+ return mcp.studioId
210
+ ? `reachable (studio_id ${mcp.studioId})`
211
+ : 'not seen by its MCP server';
212
+ }
213
+ return OutputHelper.formatWarning(
214
+ `${mcp.status}${mcp.message ? ` (${mcp.message})` : ''}`
215
+ );
216
+ }
217
+
169
218
  function formatAge(isoTime: string): string {
170
219
  const seconds = Math.max(
171
220
  0,
@@ -223,10 +272,18 @@ export function formatStateText(result: StateResult): string {
223
272
  )
224
273
  }`
225
274
  );
275
+ const mcp = formatMcp(result.mcp);
276
+ if (mcp) {
277
+ lines.push(`MCP: ${mcp}`);
278
+ }
226
279
  return lines.join('\n');
227
280
  }
228
281
 
229
- export const infoCommand = defineCommand({
282
+ interface InfoArgs {
283
+ mcp?: boolean;
284
+ }
285
+
286
+ export const infoCommand = defineCommand<InfoArgs, StateResult>({
230
287
  group: 'process',
231
288
  name: 'info',
232
289
  description:
@@ -234,10 +291,21 @@ export const infoCommand = defineCommand({
234
291
  category: 'execution',
235
292
  safety: 'read',
236
293
  scope: 'session',
237
- args: {},
294
+ args: {
295
+ mcp: arg.flag({
296
+ description:
297
+ "Also ask Studio's MCP server whether it can reach this Studio (--no-mcp to skip)",
298
+ default: true,
299
+ }),
300
+ },
238
301
  cli: {
239
302
  format: formatStateText,
240
303
  },
241
- handler: async (session, _args, connection) =>
242
- queryStateHandlerAsync(session, connection),
304
+ handler: async (session, args, connection) =>
305
+ queryStateHandlerAsync(session, connection, {
306
+ mcpSnapshotAsync:
307
+ args.mcp && !connection?.isRemote
308
+ ? (sessions) => getStudioMcpSnapshotAsync({ sessions })
309
+ : undefined,
310
+ }),
243
311
  });
@@ -15,8 +15,13 @@ vi.mock('../../../process/studio-process-manager.js', () => ({
15
15
  }),
16
16
  }));
17
17
 
18
+ vi.mock('../../../process/studio-launch-registry.js', () => ({
19
+ recordStudioLaunchAsync: vi.fn().mockResolvedValue(undefined),
20
+ }));
21
+
18
22
  import { launchHandlerAsync } from './launch.js';
19
23
  import { launchStudioAsync } from '../../../process/studio-process-manager.js';
24
+ import { recordStudioLaunchAsync } from '../../../process/studio-launch-registry.js';
20
25
 
21
26
  let tempDir: string;
22
27
 
@@ -44,12 +49,32 @@ const place = (placeId: number, name?: string) => ({
44
49
  });
45
50
 
46
51
  describe('launchHandlerAsync', () => {
47
- it('launches a bare Studio outside any deploy.nevermore.json', async () => {
52
+ it('opens a new Baseplate outside any deploy.nevermore.json', async () => {
48
53
  const result = await launchHandlerAsync({ cwd: tempDir });
49
54
 
50
- expect(launchStudioAsync).toHaveBeenCalledWith({});
55
+ expect(launchStudioAsync).toHaveBeenCalledWith({
56
+ placeId: 95206881,
57
+ universeId: 28220420,
58
+ });
51
59
  expect(result.launched).toBe(true);
52
- expect(result.summary).toBe('Studio launched');
60
+ expect(result.summary).toBe('Studio launched with a new Baseplate');
61
+ });
62
+
63
+ it('records where a Baseplate was launched from', async () => {
64
+ vi.mocked(launchStudioAsync).mockResolvedValueOnce({
65
+ process: { pid: 4242 },
66
+ killAsync: vi.fn(),
67
+ } as any);
68
+
69
+ await launchHandlerAsync({ cwd: tempDir });
70
+
71
+ expect(recordStudioLaunchAsync).toHaveBeenCalledWith(
72
+ expect.objectContaining({
73
+ pid: 4242,
74
+ cwd: tempDir,
75
+ opened: 'a new Baseplate',
76
+ })
77
+ );
53
78
  });
54
79
 
55
80
  it('passes place path to launchStudioAsync', async () => {
@@ -163,8 +188,11 @@ describe('launchHandlerAsync with deploy.nevermore.json', () => {
163
188
 
164
189
  const result = await launchHandlerAsync({ cwd: tempDir });
165
190
 
166
- expect(launchStudioAsync).toHaveBeenCalledWith({});
167
- expect(result.summary).toBe('Studio launched');
191
+ expect(launchStudioAsync).toHaveBeenCalledWith({
192
+ placeId: 95206881,
193
+ universeId: 28220420,
194
+ });
195
+ expect(result.summary).toBe('Studio launched with a new Baseplate');
168
196
  });
169
197
 
170
198
  it('asks for a target when the config has no default', async () => {
@@ -7,11 +7,14 @@ import * as path from 'path';
7
7
  import { defineCommand } from '../../framework/define-command.js';
8
8
  import { arg } from '../../framework/arg-builder.js';
9
9
  import { launchStudioAsync } from '../../../process/studio-process-manager.js';
10
+ import { recordStudioLaunchAsync } from '../../../process/studio-launch-registry.js';
10
11
  import {
11
12
  DEPLOY_CONFIG_FILE_NAME,
12
13
  resolveDeployPlaceAsync,
13
14
  } from '../../../deploy/deploy-places.js';
14
15
 
16
+ const BASEPLATE_TEMPLATE = { placeId: 95206881, universeId: 28220420 };
17
+
15
18
  export interface LaunchOptions {
16
19
  placePath?: string;
17
20
  placeId?: number;
@@ -50,7 +53,11 @@ export async function launchHandlerAsync(
50
53
  );
51
54
  if (deployPlace) {
52
55
  const { placeId, universeId } = deployPlace.place;
53
- await launchStudioAsync({ placeId, universeId });
56
+ await _launchAndRecordAsync(
57
+ { placeId, universeId },
58
+ `'${deployPlace.selector}' (place ${placeId})`,
59
+ options.cwd
60
+ );
54
61
  const configDir = path.relative(
55
62
  options.cwd ?? process.cwd(),
56
63
  path.dirname(deployPlace.configPath)
@@ -69,8 +76,24 @@ export async function launchHandlerAsync(
69
76
  };
70
77
  }
71
78
 
79
+ if (!hasExplicitPlace) {
80
+ await _launchAndRecordAsync(
81
+ BASEPLATE_TEMPLATE,
82
+ 'a new Baseplate',
83
+ options.cwd
84
+ );
85
+ return {
86
+ launched: true,
87
+ summary: 'Studio launched with a new Baseplate',
88
+ };
89
+ }
90
+
72
91
  const { placePath, placeId, universeId } = options;
73
- await launchStudioAsync({ placePath, placeId, universeId });
92
+ await _launchAndRecordAsync(
93
+ { placePath, placeId, universeId },
94
+ placeId != null ? `place ${placeId}` : placePath ?? 'Studio',
95
+ options.cwd
96
+ );
74
97
  const opened =
75
98
  placeId != null
76
99
  ? `place ${placeId}${
@@ -85,6 +108,21 @@ export async function launchHandlerAsync(
85
108
  };
86
109
  }
87
110
 
111
+ async function _launchAndRecordAsync(
112
+ launch: Parameters<typeof launchStudioAsync>[0],
113
+ opened: string,
114
+ cwd = process.cwd()
115
+ ): Promise<void> {
116
+ const launchedAt = Date.now();
117
+ const studio = await launchStudioAsync(launch);
118
+ await recordStudioLaunchAsync({
119
+ pid: studio?.process?.pid,
120
+ cwd,
121
+ opened,
122
+ launchedAt,
123
+ }).catch(() => {});
124
+ }
125
+
88
126
  interface LaunchArgs {
89
127
  deployTarget?: string;
90
128
  place?: string;
@@ -96,7 +134,7 @@ export const launchCommand = defineCommand<LaunchArgs, LaunchResult>({
96
134
  group: 'process',
97
135
  name: 'launch',
98
136
  description:
99
- "Launch Roblox Studio, by default with the place of the nearest deploy.nevermore.json's default target",
137
+ "Launch Roblox Studio, by default with the place of the nearest deploy.nevermore.json's default target, or else a new Baseplate",
100
138
  category: 'infrastructure',
101
139
  safety: 'none',
102
140
  scope: 'standalone',