@quenty/studio-bridge 0.22.1 → 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 +25 -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 +5 -5
  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
@@ -0,0 +1,279 @@
1
+ import type { SessionContext, SessionInfo } from '../../bridge/types.js';
2
+ import type { McpStdioClient } from '../mcp/mcp-stdio-client.js';
3
+ import { matchProcessByUptime } from '../../process/studio-process-identity.js';
4
+
5
+ export const DATAMODEL_TYPES: Record<SessionContext, string> = {
6
+ edit: 'Edit',
7
+ client: 'Client',
8
+ server: 'Server',
9
+ };
10
+
11
+ export class StudioNotFoundError extends Error {}
12
+
13
+ const ASSISTANT_FRAME = /^(?:sabuiltin_[^\s:]+|AssistantCommand):\d+: /;
14
+
15
+ export function cleanToolError(text: string): string {
16
+ let cleaned = text;
17
+ while (ASSISTANT_FRAME.test(cleaned)) {
18
+ cleaned = cleaned.replace(ASSISTANT_FRAME, '');
19
+ }
20
+ return cleaned;
21
+ }
22
+
23
+ export interface McpStudio {
24
+ studioId: string;
25
+ placeName: string;
26
+ }
27
+
28
+ export interface StudioWindow {
29
+ pid: number;
30
+ windowTitle: string;
31
+ startedAt?: number;
32
+ }
33
+
34
+ export interface MatchedStudio {
35
+ studioId: string;
36
+ placeName: string;
37
+ placeId?: number;
38
+ gameId?: number;
39
+ sessionId?: string;
40
+ instanceId?: string;
41
+ pid?: number;
42
+ }
43
+
44
+ export interface LuauRunResult {
45
+ success: boolean;
46
+ result?: string;
47
+ error?: string;
48
+ }
49
+
50
+ export async function listMcpStudiosAsync(
51
+ client: McpStdioClient
52
+ ): Promise<McpStudio[]> {
53
+ const reply = await client.callToolAsync('list_roblox_studios');
54
+ if (reply.isError) {
55
+ throw new Error(`Could not list Studios over MCP: ${reply.text}`);
56
+ }
57
+ let parsed: { studios?: Array<{ id?: unknown; name?: unknown }> };
58
+ try {
59
+ parsed = JSON.parse(reply.text);
60
+ } catch {
61
+ throw new Error(`Unexpected Studio list from MCP: ${reply.text}`);
62
+ }
63
+ return (parsed.studios ?? [])
64
+ .filter((s) => typeof s.id === 'string')
65
+ .map((s) => ({
66
+ studioId: s.id as string,
67
+ placeName: typeof s.name === 'string' ? s.name : '',
68
+ }));
69
+ }
70
+
71
+ export async function runLuauAsync(
72
+ client: McpStdioClient,
73
+ studioId: string,
74
+ code: string,
75
+ context: SessionContext = 'edit',
76
+ timeoutMs?: number
77
+ ): Promise<LuauRunResult> {
78
+ const reply = await client.callToolAsync(
79
+ 'execute_luau',
80
+ { studio_id: studioId, datamodel_type: DATAMODEL_TYPES[context], code },
81
+ timeoutMs
82
+ );
83
+ return reply.isError
84
+ ? { success: false, error: cleanToolError(reply.text) }
85
+ : { success: true, result: reply.text };
86
+ }
87
+
88
+ export interface MatchStudiosOptions {
89
+ readPlaceIds?: boolean;
90
+ studios?: McpStudio[];
91
+ sessionPids?: Map<string, number>;
92
+ }
93
+
94
+ export async function matchStudiosAsync(
95
+ client: McpStdioClient,
96
+ sessions: SessionInfo[] = [],
97
+ windows: StudioWindow[] = [],
98
+ options: MatchStudiosOptions = {}
99
+ ): Promise<MatchedStudio[]> {
100
+ const studios = options.studios ?? (await listMcpStudiosAsync(client));
101
+ const editSessions = sessions.filter((s) => s.context === 'edit');
102
+ const nameCount = (name: string) =>
103
+ studios.filter((s) => s.placeName === name).length;
104
+ const canTime = windows.some((w) => w.startedAt !== undefined);
105
+
106
+ const matched = await Promise.all(
107
+ studios.map(async (studio): Promise<MatchedStudio> => {
108
+ const named = editSessions.filter(
109
+ (s) => s.placeName === studio.placeName
110
+ );
111
+ const uniqueByName =
112
+ named.length === 1 && nameCount(studio.placeName) === 1;
113
+ const wantProbe =
114
+ canTime ||
115
+ (!uniqueByName && (named.length > 0 || options.readPlaceIds !== false));
116
+ return wantProbe
117
+ ? {
118
+ ...studio,
119
+ ...(await _probeAsync(client, studio.studioId, windows)),
120
+ }
121
+ : { ...studio };
122
+ })
123
+ );
124
+
125
+ const claimed = new Set<string>();
126
+ const assign = (studio: MatchedStudio, session: SessionInfo) => {
127
+ claimed.add(session.sessionId);
128
+ studio.sessionId = session.sessionId;
129
+ studio.instanceId = session.instanceId;
130
+ studio.placeId ??= session.placeId;
131
+ studio.gameId ??= session.gameId;
132
+ };
133
+ const unclaimed = () => editSessions.filter((s) => !claimed.has(s.sessionId));
134
+
135
+ if (options.sessionPids) {
136
+ for (const studio of matched) {
137
+ if (studio.pid === undefined) continue;
138
+ const byPid = unclaimed().filter(
139
+ (s) => options.sessionPids!.get(s.instanceId) === studio.pid
140
+ );
141
+ if (byPid.length === 1) {
142
+ assign(studio, byPid[0]);
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const studio of matched) {
148
+ if (studio.sessionId) continue;
149
+ const named = unclaimed().filter((s) => s.placeName === studio.placeName);
150
+ const unmatchedSameName = matched.filter(
151
+ (m) => !m.sessionId && m.placeName === studio.placeName
152
+ ).length;
153
+ if (named.length === 1 && unmatchedSameName === 1) {
154
+ assign(studio, named[0]);
155
+ }
156
+ }
157
+
158
+ for (const studio of matched) {
159
+ if (studio.sessionId || !studio.placeId) continue;
160
+ const byId = unclaimed().filter((s) => s.placeId === studio.placeId);
161
+ if (byId.length === 1) {
162
+ assign(studio, byId[0]);
163
+ }
164
+ }
165
+
166
+ for (const studio of matched) {
167
+ if (studio.pid !== undefined || nameCount(studio.placeName) !== 1) continue;
168
+ const window = windows.filter((w) =>
169
+ _windowShowsPlace(w.windowTitle, studio.placeName)
170
+ );
171
+ if (window.length === 1) {
172
+ studio.pid = window[0].pid;
173
+ }
174
+ }
175
+
176
+ return matched;
177
+ }
178
+
179
+ export function resolveStudio(
180
+ studios: MatchedStudio[],
181
+ target?: string
182
+ ): MatchedStudio {
183
+ if (target === undefined) {
184
+ if (studios.length === 1) {
185
+ return studios[0];
186
+ }
187
+ throw new StudioNotFoundError(
188
+ studios.length === 0
189
+ ? 'No Studio is connected to its MCP server'
190
+ : `${studios.length} Studios are open (${_describe(
191
+ studios
192
+ )}). Pick one with --target.`
193
+ );
194
+ }
195
+
196
+ const exact = studios.filter(
197
+ (s) =>
198
+ s.studioId === target ||
199
+ s.sessionId === target ||
200
+ s.instanceId === target ||
201
+ String(s.pid) === target
202
+ );
203
+ if (exact.length === 1) {
204
+ return exact[0];
205
+ }
206
+
207
+ const lowered = target.toLowerCase();
208
+ const loose = studios.filter(
209
+ (s) =>
210
+ s.studioId.startsWith(target) ||
211
+ s.sessionId?.startsWith(target) ||
212
+ s.instanceId?.startsWith(target) ||
213
+ s.placeName.toLowerCase() === lowered
214
+ );
215
+ if (loose.length === 1) {
216
+ return loose[0];
217
+ }
218
+
219
+ throw new StudioNotFoundError(
220
+ loose.length > 1
221
+ ? `'${target}' matches ${loose.length} Studios (${_describe(
222
+ loose
223
+ )}). Use a longer id.`
224
+ : `No Studio matches '${target}'. Open: ${_describe(studios) || 'none'}`
225
+ );
226
+ }
227
+
228
+ const PROBE_LUAU =
229
+ 'return tostring(os.clock()) .. "," .. tostring(game.PlaceId) .. "," .. tostring(game.GameId)';
230
+
231
+ async function _probeAsync(
232
+ client: McpStdioClient,
233
+ studioId: string,
234
+ windows: StudioWindow[]
235
+ ): Promise<Partial<MatchedStudio>> {
236
+ for (const context of ['edit', 'server', 'client'] as const) {
237
+ const sentAt = Date.now();
238
+ const run = await runLuauAsync(client, studioId, PROBE_LUAU, context);
239
+ const match = run.success
240
+ ? /^([\d.]+),(\d+),(\d+)$/.exec(run.result ?? '')
241
+ : null;
242
+ if (!match) {
243
+ if (run.success || !/not available/i.test(run.error ?? '')) {
244
+ return {};
245
+ }
246
+ continue;
247
+ }
248
+ const reading = {
249
+ uptimeSec: Number(match[1]),
250
+ observedAt: (sentAt + Date.now()) / 2,
251
+ };
252
+ const window = matchProcessByUptime(windows, reading);
253
+ return {
254
+ placeId: Number(match[2]),
255
+ gameId: Number(match[3]),
256
+ ...(window ? { pid: window.pid } : {}),
257
+ };
258
+ }
259
+ return {};
260
+ }
261
+
262
+ function _windowShowsPlace(title: string, placeName: string): boolean {
263
+ if (!placeName) {
264
+ return false;
265
+ }
266
+ const shown = title.replace(/ - Roblox Studio$/, '');
267
+ return (
268
+ shown === placeName ||
269
+ shown.endsWith(`\\${placeName}`) ||
270
+ shown.endsWith(`/${placeName}`) ||
271
+ shown.replace(/\.rbxlx?$/, '').endsWith(placeName)
272
+ );
273
+ }
274
+
275
+ function _describe(studios: MatchedStudio[]): string {
276
+ return studios
277
+ .map((s) => `${s.sessionId ?? s.studioId} '${s.placeName}'`)
278
+ .join(', ');
279
+ }
@@ -0,0 +1,186 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import {
6
+ findStudioMcpLaunchAsync,
7
+ parseContentFolder,
8
+ } from './studio-mcp-locator.js';
9
+
10
+ describe('findStudioMcpLaunchAsync', () => {
11
+ let localAppData: string;
12
+ let versionsDir: string;
13
+
14
+ beforeEach(async () => {
15
+ localAppData = await fs.mkdtemp(path.join(os.tmpdir(), 'studio-mcp-'));
16
+ versionsDir = path.join(localAppData, 'Roblox', 'Versions');
17
+ await fs.mkdir(versionsDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await fs.rm(localAppData, { recursive: true, force: true });
22
+ });
23
+
24
+ async function makeVersionAsync(
25
+ name: string,
26
+ files: string[],
27
+ mtime: Date
28
+ ): Promise<string> {
29
+ const dir = path.join(versionsDir, name);
30
+ await fs.mkdir(path.join(dir, 'content'), { recursive: true });
31
+ for (const file of files) {
32
+ await fs.writeFile(path.join(dir, file), '');
33
+ }
34
+ await fs.utimes(dir, mtime, mtime);
35
+ return dir;
36
+ }
37
+
38
+ it('uses the version folder the registry content folder is in', async () => {
39
+ const dir = await makeVersionAsync(
40
+ 'version-current',
41
+ ['RobloxStudioBeta.exe', 'StudioMCP.exe'],
42
+ new Date(2026, 0, 1)
43
+ );
44
+ await makeVersionAsync(
45
+ 'version-newer',
46
+ ['RobloxStudioBeta.exe', 'StudioMCP.exe'],
47
+ new Date(2026, 5, 1)
48
+ );
49
+
50
+ const { command: found } = await findStudioMcpLaunchAsync({
51
+ platform: 'win32',
52
+ localAppData,
53
+ readContentFolderAsync: async () => path.join(dir, 'content') + path.sep,
54
+ });
55
+
56
+ expect(found).toBe(path.join(dir, 'StudioMCP.exe'));
57
+ });
58
+
59
+ it('skips a registry folder that only holds a lingering StudioMCP.exe', async () => {
60
+ const stale = await makeVersionAsync(
61
+ 'version-stale',
62
+ ['StudioMCP.exe'],
63
+ new Date(2026, 5, 1)
64
+ );
65
+ const current = await makeVersionAsync(
66
+ 'version-current',
67
+ ['RobloxStudioBeta.exe', 'StudioMCP.exe'],
68
+ new Date(2026, 0, 1)
69
+ );
70
+
71
+ const { command: found } = await findStudioMcpLaunchAsync({
72
+ platform: 'win32',
73
+ localAppData,
74
+ readContentFolderAsync: async () => path.join(stale, 'content'),
75
+ });
76
+
77
+ expect(found).toBe(path.join(current, 'StudioMCP.exe'));
78
+ });
79
+
80
+ it('falls back to the newest folder holding both executables', async () => {
81
+ await makeVersionAsync(
82
+ 'version-old',
83
+ ['RobloxStudioBeta.exe', 'StudioMCP.exe'],
84
+ new Date(2026, 0, 1)
85
+ );
86
+ const newest = await makeVersionAsync(
87
+ 'version-new',
88
+ ['RobloxStudioBeta.exe', 'StudioMCP.exe'],
89
+ new Date(2026, 3, 1)
90
+ );
91
+ await makeVersionAsync(
92
+ 'version-newest-stale',
93
+ ['StudioMCP.exe'],
94
+ new Date(2026, 6, 1)
95
+ );
96
+
97
+ const { command: found } = await findStudioMcpLaunchAsync({
98
+ platform: 'win32',
99
+ localAppData,
100
+ readContentFolderAsync: async () => undefined,
101
+ });
102
+
103
+ expect(found).toBe(path.join(newest, 'StudioMCP.exe'));
104
+ });
105
+
106
+ it('says so when no folder holds both executables', async () => {
107
+ await makeVersionAsync(
108
+ 'version-stale',
109
+ ['StudioMCP.exe'],
110
+ new Date(2026, 0, 1)
111
+ );
112
+
113
+ await expect(
114
+ findStudioMcpLaunchAsync({
115
+ platform: 'win32',
116
+ localAppData,
117
+ readContentFolderAsync: async () => undefined,
118
+ })
119
+ ).rejects.toThrow('No StudioMCP.exe found next to RobloxStudioBeta.exe');
120
+ });
121
+
122
+ it('finds StudioMCP in the macOS app bundle', async () => {
123
+ const exe = path.join(localAppData, 'StudioMCP');
124
+ await fs.writeFile(exe, '');
125
+
126
+ const launch = await findStudioMcpLaunchAsync({
127
+ platform: 'darwin',
128
+ macCandidates: [path.join(localAppData, 'missing'), exe],
129
+ });
130
+
131
+ expect(launch).toEqual({ command: exe, args: [] });
132
+ });
133
+
134
+ it('runs the Linux StudioMCP.exe under wine with its environment', async () => {
135
+ await fs.writeFile(path.join(localAppData, 'StudioMCP.exe'), '');
136
+ const env = { WINEPREFIX: '/wine' };
137
+
138
+ const launch = await findStudioMcpLaunchAsync({
139
+ platform: 'linux',
140
+ linuxStudioDir: localAppData,
141
+ linuxEnv: env,
142
+ });
143
+
144
+ expect(launch).toEqual({
145
+ command: 'wine',
146
+ args: [path.join(localAppData, 'StudioMCP.exe')],
147
+ env,
148
+ });
149
+ });
150
+
151
+ it('says so when the Linux Studio has no StudioMCP.exe', async () => {
152
+ await expect(
153
+ findStudioMcpLaunchAsync({
154
+ platform: 'linux',
155
+ linuxStudioDir: localAppData,
156
+ linuxEnv: {},
157
+ })
158
+ ).rejects.toThrow('No StudioMCP.exe in');
159
+ });
160
+
161
+ it('refuses a platform Studio does not run on', async () => {
162
+ await expect(
163
+ findStudioMcpLaunchAsync({ platform: 'freebsd' })
164
+ ).rejects.toThrow('not available on freebsd');
165
+ });
166
+ });
167
+
168
+ describe('parseContentFolder', () => {
169
+ it('reads the value out of reg query output', () => {
170
+ expect(
171
+ parseContentFolder(
172
+ '\r\nHKEY_CURRENT_USER\\Software\\Roblox\\RobloxStudio\r\n ContentFolder REG_SZ C:\\Users\\a b\\AppData\\Local\\Roblox\\Versions\\version-1\\content\\\r\n\r\n'
173
+ )
174
+ ).toBe(
175
+ 'C:\\Users\\a b\\AppData\\Local\\Roblox\\Versions\\version-1\\content\\'
176
+ );
177
+ });
178
+
179
+ it('returns undefined when the value is missing', () => {
180
+ expect(
181
+ parseContentFolder(
182
+ 'ERROR: The system was unable to find the specified registry key or value.'
183
+ )
184
+ ).toBeUndefined();
185
+ });
186
+ });
@@ -0,0 +1,180 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { execa } from 'execa';
5
+
6
+ const STUDIO_EXE = 'RobloxStudioBeta.exe';
7
+ const MCP_EXE = 'StudioMCP.exe';
8
+
9
+ export class StudioMcpUnsupportedError extends Error {}
10
+
11
+ export interface StudioMcpLaunch {
12
+ command: string;
13
+ args: string[];
14
+ env?: Record<string, string>;
15
+ }
16
+
17
+ export interface StudioMcpLocatorOptions {
18
+ platform?: NodeJS.Platform;
19
+ localAppData?: string;
20
+ readContentFolderAsync?: () => Promise<string | undefined>;
21
+ macCandidates?: string[];
22
+ linuxStudioDir?: string;
23
+ linuxEnv?: Record<string, string>;
24
+ }
25
+
26
+ export async function findStudioMcpLaunchAsync(
27
+ options: StudioMcpLocatorOptions = {}
28
+ ): Promise<StudioMcpLaunch> {
29
+ const platform = options.platform ?? process.platform;
30
+ if (platform === 'win32') {
31
+ return { command: await _findWindowsAsync(options), args: [] };
32
+ }
33
+ if (platform === 'darwin') {
34
+ return { command: await _findMacAsync(options), args: [] };
35
+ }
36
+ if (platform === 'linux') {
37
+ return _findLinuxAsync(options);
38
+ }
39
+ throw new StudioMcpUnsupportedError(
40
+ `Studio's MCP server is not available on ${platform}`
41
+ );
42
+ }
43
+
44
+ export async function readStudioContentFolderAsync(): Promise<
45
+ string | undefined
46
+ > {
47
+ const { stdout } = await execa(
48
+ 'reg',
49
+ ['query', 'HKCU\\Software\\Roblox\\RobloxStudio', '/v', 'ContentFolder'],
50
+ { reject: false }
51
+ );
52
+ return parseContentFolder(stdout);
53
+ }
54
+
55
+ export function parseContentFolder(regOutput: string): string | undefined {
56
+ const match = /ContentFolder\s+REG_(?:EXPAND_)?SZ\s+(.+)$/m.exec(regOutput);
57
+ return match?.[1].trim() || undefined;
58
+ }
59
+
60
+ async function _findWindowsAsync(
61
+ options: StudioMcpLocatorOptions
62
+ ): Promise<string> {
63
+ const readContentFolderAsync =
64
+ options.readContentFolderAsync ?? readStudioContentFolderAsync;
65
+ const contentFolder = await readContentFolderAsync();
66
+ if (contentFolder) {
67
+ const versionDir = path.resolve(contentFolder, '..');
68
+ if (await _isStudioVersionDirAsync(versionDir)) {
69
+ return path.join(versionDir, MCP_EXE);
70
+ }
71
+ }
72
+
73
+ const localAppData = options.localAppData ?? process.env.LOCALAPPDATA;
74
+ if (!localAppData) {
75
+ throw new Error('LOCALAPPDATA is not set, so Studio cannot be found');
76
+ }
77
+ const versionsDir = path.join(localAppData, 'Roblox', 'Versions');
78
+ for (const versionDir of await _listVersionDirsNewestFirstAsync(
79
+ versionsDir
80
+ )) {
81
+ if (await _isStudioVersionDirAsync(versionDir)) {
82
+ return path.join(versionDir, MCP_EXE);
83
+ }
84
+ }
85
+
86
+ throw new Error(
87
+ `No ${MCP_EXE} found next to ${STUDIO_EXE} under ${versionsDir}. Is Roblox Studio installed and up to date?`
88
+ );
89
+ }
90
+
91
+ async function _findMacAsync(
92
+ options: StudioMcpLocatorOptions
93
+ ): Promise<string> {
94
+ const candidates = options.macCandidates ?? [
95
+ '/Applications/RobloxStudio.app/Contents/MacOS/StudioMCP',
96
+ path.join(
97
+ os.homedir(),
98
+ 'Applications/RobloxStudio.app/Contents/MacOS/StudioMCP'
99
+ ),
100
+ ];
101
+ for (const candidate of candidates) {
102
+ if (await _isFileAsync(candidate)) {
103
+ return candidate;
104
+ }
105
+ }
106
+ throw new Error(
107
+ `No StudioMCP found in Roblox Studio's app bundle (${candidates.join(
108
+ ', '
109
+ )})`
110
+ );
111
+ }
112
+
113
+ async function _findLinuxAsync(
114
+ options: StudioMcpLocatorOptions
115
+ ): Promise<StudioMcpLaunch> {
116
+ let studioDir = options.linuxStudioDir;
117
+ let env = options.linuxEnv;
118
+ if (studioDir === undefined) {
119
+ const { resolveLinuxConfig } = await import('../linux/linux-config.js');
120
+ const { buildWineEnv } = await import('../linux/linux-wine-env.js');
121
+ const config = resolveLinuxConfig();
122
+ studioDir = config.studioDir;
123
+ env ??= buildWineEnv(config);
124
+ }
125
+
126
+ const exe = path.join(studioDir, MCP_EXE);
127
+ if (!(await _isFileAsync(exe))) {
128
+ throw new Error(
129
+ `No ${MCP_EXE} in ${studioDir}. Run "studio-bridge linux setup" with a Studio that ships it.`
130
+ );
131
+ }
132
+ return { command: 'wine', args: [exe], env };
133
+ }
134
+
135
+ async function _isStudioVersionDirAsync(dir: string): Promise<boolean> {
136
+ const [hasStudio, hasMcp] = await Promise.all([
137
+ _isFileAsync(path.join(dir, STUDIO_EXE)),
138
+ _isFileAsync(path.join(dir, MCP_EXE)),
139
+ ]);
140
+ return hasStudio && hasMcp;
141
+ }
142
+
143
+ async function _listVersionDirsNewestFirstAsync(
144
+ versionsDir: string
145
+ ): Promise<string[]> {
146
+ let entries: string[];
147
+ try {
148
+ entries = await fs.readdir(versionsDir);
149
+ } catch {
150
+ return [];
151
+ }
152
+
153
+ const dirs = await Promise.all(
154
+ entries
155
+ .filter((entry) => entry.startsWith('version-'))
156
+ .map(async (entry) => {
157
+ const dir = path.join(versionsDir, entry);
158
+ try {
159
+ const stat = await fs.stat(dir);
160
+ return stat.isDirectory()
161
+ ? { dir, mtimeMs: stat.mtimeMs }
162
+ : undefined;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ })
167
+ );
168
+ return dirs
169
+ .filter((d): d is { dir: string; mtimeMs: number } => d !== undefined)
170
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
171
+ .map((d) => d.dir);
172
+ }
173
+
174
+ async function _isFileAsync(file: string): Promise<boolean> {
175
+ try {
176
+ return (await fs.stat(file)).isFile();
177
+ } catch {
178
+ return false;
179
+ }
180
+ }