@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,98 @@
1
+ /**
2
+ * Builds the studio-bridge client the edit session plants into a playtest's
3
+ * server, for when Studio does not load user plugins into playtests.
4
+ *
5
+ * The planted client is an ordinary Script, so it can neither `loadstring`
6
+ * nor set a ModuleScript's source. Everything it may run is compiled ahead of
7
+ * time here: each source becomes a ModuleScript returning its function and
8
+ * the source itself, which the client's router looks up by exact text when
9
+ * the bridge pushes an action or sends a script (see PrecompiledSources.lua).
10
+ */
11
+
12
+ import * as fs from 'fs/promises';
13
+ import * as path from 'path';
14
+ import { resolveTemplatePath } from '@quenty/nevermore-template-helpers';
15
+ import { loadActionSourcesAsync } from '../commands/framework/action-loader.js';
16
+ import { getPluginBuildVersionAsync } from './plugin-build-version.js';
17
+
18
+ /** The `bridge` of a `startPlaytest` request. */
19
+ export interface PlaytestBridge {
20
+ /** Source of the planted entry Script. */
21
+ entrySource: string;
22
+ /** Source of the entry LocalScript planted for the playtest's clients, if any. */
23
+ clientEntrySource?: string;
24
+ /** The plugin's Shared modules, by name. */
25
+ modules: Record<string, string>;
26
+ /** ModuleScript sources compiled ahead of time, see `wrapPrecompiledSource`. */
27
+ precompiled: string[];
28
+ }
29
+
30
+ const templateDir = resolveTemplatePath(
31
+ import.meta.url,
32
+ 'studio-bridge-plugin'
33
+ );
34
+
35
+ /**
36
+ * Builds the bridge to plant, able to run every action plus `scripts`, the
37
+ * code a caller will send the playtest's server session.
38
+ */
39
+ export async function buildPlaytestBridgeAsync(
40
+ scripts: string[] = [],
41
+ options: { withClient?: boolean } = {}
42
+ ): Promise<PlaytestBridge> {
43
+ const [entrySource, clientEntrySource, modules, actions] = await Promise.all([
44
+ fs.readFile(
45
+ path.join(templateDir, 'playtest', 'StudioBridgePlaytest.server.lua'),
46
+ 'utf-8'
47
+ ),
48
+ options.withClient
49
+ ? fs.readFile(
50
+ path.join(templateDir, 'playtest', 'StudioBridgePlaytest.client.lua'),
51
+ 'utf-8'
52
+ )
53
+ : Promise.resolve(undefined),
54
+ readSharedModulesAsync(),
55
+ loadActionSourcesAsync(),
56
+ ]);
57
+
58
+ const sources = [...new Set([...actions.map((a) => a.source), ...scripts])];
59
+ return {
60
+ entrySource,
61
+ ...(clientEntrySource ? { clientEntrySource } : {}),
62
+ modules,
63
+ precompiled: sources.map(wrapPrecompiledSource),
64
+ };
65
+ }
66
+
67
+ async function readSharedModulesAsync(): Promise<Record<string, string>> {
68
+ const sharedDir = path.join(templateDir, 'src', 'Shared');
69
+ const version = await getPluginBuildVersionAsync();
70
+
71
+ const modules: Record<string, string> = {};
72
+ for (const fileName of await fs.readdir(sharedDir)) {
73
+ if (!fileName.endsWith('.lua') || fileName.endsWith('.spec.lua')) {
74
+ continue;
75
+ }
76
+ const source = await fs.readFile(path.join(sharedDir, fileName), 'utf-8');
77
+ modules[path.basename(fileName, '.lua')] = source.replaceAll(
78
+ '{{PLUGIN_VERSION}}',
79
+ version
80
+ );
81
+ }
82
+ return modules;
83
+ }
84
+
85
+ /**
86
+ * A ModuleScript source returning `{ fn, source }` for `source`. The function
87
+ * opens on the first line, so line numbers in errors match `source`. The text
88
+ * is a long string led by a character that is cut off again, since a long
89
+ * string drops a leading newline.
90
+ */
91
+ export function wrapPrecompiledSource(source: string): string {
92
+ // The string must close at its closer, not inside or straddling `source`
93
+ let level = '';
94
+ while (`${source}]${level}]`.indexOf(`]${level}]`) !== source.length) {
95
+ level += '=';
96
+ }
97
+ return `return { fn = function(...) ${source}\nend, source = string.sub([${level}[x${source}]${level}], 2) }`;
98
+ }
@@ -0,0 +1,56 @@
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
+ findStudioLaunch,
7
+ readStudioLaunchesAsync,
8
+ recordStudioLaunchAsync,
9
+ } from './studio-launch-registry.js';
10
+
11
+ describe('studio launch registry', () => {
12
+ let dir: string;
13
+ let file: string;
14
+
15
+ beforeEach(async () => {
16
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'launch-registry-'));
17
+ file = path.join(dir, 'nested', 'launches.json');
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await fs.rm(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ it('records launches and reads them back', async () => {
25
+ await recordStudioLaunchAsync(
26
+ { pid: 10, cwd: '/game', opened: 'a new Baseplate', launchedAt: 1 },
27
+ file
28
+ );
29
+
30
+ expect(await readStudioLaunchesAsync(file)).toEqual([
31
+ { pid: 10, cwd: '/game', opened: 'a new Baseplate', launchedAt: 1 },
32
+ ]);
33
+ });
34
+
35
+ it('reads a missing or broken registry as empty', async () => {
36
+ expect(await readStudioLaunchesAsync(file)).toEqual([]);
37
+ await fs.mkdir(path.dirname(file), { recursive: true });
38
+ await fs.writeFile(file, 'not json');
39
+ expect(await readStudioLaunchesAsync(file)).toEqual([]);
40
+ });
41
+
42
+ it('finds a launch by pid, else by when the process started', () => {
43
+ const records = [
44
+ { pid: 10, cwd: '/a', opened: 'x', launchedAt: 1_000 },
45
+ { cwd: '/b', opened: 'y', launchedAt: 60_000 },
46
+ ];
47
+
48
+ expect(findStudioLaunch(records, { pid: 10 })?.cwd).toBe('/a');
49
+ expect(findStudioLaunch(records, { pid: 99, startedAt: 62_000 })?.cwd).toBe(
50
+ '/b'
51
+ );
52
+ expect(
53
+ findStudioLaunch(records, { pid: 99, startedAt: 500_000 })
54
+ ).toBeUndefined();
55
+ });
56
+ });
@@ -0,0 +1,63 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+
5
+ const LAUNCH_MATCH_WINDOW_MS = 10_000;
6
+ const MAX_RECORDS = 50;
7
+
8
+ export interface StudioLaunchRecord {
9
+ pid?: number;
10
+ cwd: string;
11
+ opened: string;
12
+ launchedAt: number;
13
+ }
14
+
15
+ export function defaultLaunchRegistryPath(): string {
16
+ return path.join(os.tmpdir(), 'studio-bridge', 'launches.json');
17
+ }
18
+
19
+ export async function recordStudioLaunchAsync(
20
+ record: StudioLaunchRecord,
21
+ registryPath = defaultLaunchRegistryPath()
22
+ ): Promise<void> {
23
+ const records = await readStudioLaunchesAsync(registryPath);
24
+ records.push(record);
25
+ await fs.mkdir(path.dirname(registryPath), { recursive: true });
26
+ await fs.writeFile(
27
+ registryPath,
28
+ JSON.stringify(records.slice(-MAX_RECORDS), null, 2)
29
+ );
30
+ }
31
+
32
+ export async function readStudioLaunchesAsync(
33
+ registryPath = defaultLaunchRegistryPath()
34
+ ): Promise<StudioLaunchRecord[]> {
35
+ try {
36
+ const parsed = JSON.parse(await fs.readFile(registryPath, 'utf-8'));
37
+ return Array.isArray(parsed)
38
+ ? parsed.filter(
39
+ (r): r is StudioLaunchRecord =>
40
+ r && typeof r.cwd === 'string' && typeof r.launchedAt === 'number'
41
+ )
42
+ : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ export function findStudioLaunch(
49
+ records: StudioLaunchRecord[],
50
+ process: { pid: number; startedAt?: number }
51
+ ): StudioLaunchRecord | undefined {
52
+ const byPid = records.filter((r) => r.pid === process.pid);
53
+ if (byPid.length > 0) {
54
+ return byPid[byPid.length - 1];
55
+ }
56
+ if (process.startedAt === undefined) {
57
+ return undefined;
58
+ }
59
+ const near = records.filter(
60
+ (r) => Math.abs(r.launchedAt - process.startedAt!) <= LAUNCH_MATCH_WINDOW_MS
61
+ );
62
+ return near.length === 1 ? near[0] : undefined;
63
+ }
@@ -14,6 +14,7 @@ export interface RunningStudioProcess {
14
14
  pid: number;
15
15
  windowTitle: string;
16
16
  commandLine?: string;
17
+ startedAt?: number;
17
18
  }
18
19
 
19
20
  export interface CloseStudioOptions {
@@ -31,7 +32,7 @@ export async function listStudioProcessesAsync(): Promise<
31
32
  '-NoProfile',
32
33
  '-NonInteractive',
33
34
  '-Command',
34
- '@(Get-CimInstance Win32_Process -Filter "Name=\'RobloxStudioBeta.exe\'" | ForEach-Object { [pscustomobject]@{ Id = $_.ProcessId; MainWindowTitle = (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue).MainWindowTitle; CommandLine = $_.CommandLine } }) | ConvertTo-Json -Compress',
35
+ "@(Get-CimInstance Win32_Process -Filter \"Name='RobloxStudioBeta.exe'\" | ForEach-Object { [pscustomobject]@{ Id = $_.ProcessId; MainWindowTitle = (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue).MainWindowTitle; CommandLine = $_.CommandLine; StartTime = $_.CreationDate.ToUniversalTime().ToString('o') } }) | ConvertTo-Json -Compress",
35
36
  ],
36
37
  { reject: false }
37
38
  );
@@ -39,7 +40,7 @@ export async function listStudioProcessesAsync(): Promise<
39
40
  }
40
41
 
41
42
  if (process.platform === 'darwin') {
42
- const { stdout } = await execa('ps', ['-axo', 'pid=,command='], {
43
+ const { stdout } = await execa('ps', ['-axo', 'pid=,etime=,command='], {
43
44
  reject: false,
44
45
  });
45
46
  return parseMacProcessList(stdout);
@@ -58,35 +59,59 @@ export function parseWindowsProcessList(
58
59
  Id: number;
59
60
  MainWindowTitle: string | null;
60
61
  CommandLine?: string | null;
62
+ StartTime?: string | null;
61
63
  };
62
64
  const parsed = JSON.parse(trimmed) as Entry | Entry[];
63
65
  const entries = Array.isArray(parsed) ? parsed : [parsed];
64
66
 
65
- return entries.map((entry) => ({
66
- pid: entry.Id,
67
- windowTitle: entry.MainWindowTitle ?? '',
68
- commandLine: entry.CommandLine ?? undefined,
69
- }));
67
+ return entries.map((entry) => {
68
+ const startedAt = entry.StartTime ? Date.parse(entry.StartTime) : NaN;
69
+ return {
70
+ pid: entry.Id,
71
+ windowTitle: entry.MainWindowTitle ?? '',
72
+ commandLine: entry.CommandLine ?? undefined,
73
+ ...(Number.isFinite(startedAt) ? { startedAt } : {}),
74
+ };
75
+ });
70
76
  }
71
77
 
72
- export function parseMacProcessList(stdout: string): RunningStudioProcess[] {
78
+ export function parseMacProcessList(
79
+ stdout: string,
80
+ now = Date.now()
81
+ ): RunningStudioProcess[] {
73
82
  const processes: RunningStudioProcess[] = [];
74
83
  for (const line of stdout.split('\n')) {
75
84
  const match =
76
- /^\s*(\d+)\s+(.*RobloxStudio\.app\/Contents\/MacOS\/RobloxStudio.*)$/.exec(
85
+ /^\s*(\d+)\s+(?:([\d:-]+)\s+)?(.*RobloxStudio\.app\/Contents\/MacOS\/RobloxStudio.*)$/.exec(
77
86
  line
78
87
  );
79
88
  if (match) {
89
+ const elapsedSec = match[2] ? parseElapsedSeconds(match[2]) : undefined;
80
90
  processes.push({
81
91
  pid: Number(match[1]),
82
- windowTitle: match[2],
83
- commandLine: match[2],
92
+ windowTitle: match[3],
93
+ commandLine: match[3],
94
+ ...(elapsedSec !== undefined
95
+ ? { startedAt: now - elapsedSec * 1000 }
96
+ : {}),
84
97
  });
85
98
  }
86
99
  }
87
100
  return processes;
88
101
  }
89
102
 
103
+ export function parseElapsedSeconds(etime: string): number | undefined {
104
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
105
+ if (!match) return undefined;
106
+ const [, days, hours, minutes, seconds] = match;
107
+ return (
108
+ Number(days ?? 0) * 86400 +
109
+ Number(hours ?? 0) * 3600 +
110
+ Number(minutes) * 60 +
111
+ Number(seconds)
112
+ );
113
+ }
114
+
90
115
  /**
91
116
  * Processes whose window shows the given place. Studio titles a local file
92
117
  * by its full path and a cloud place by its name on the website, which the
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { matchProcessByUptime } from './studio-process-identity.js';
3
+
4
+ describe('matchProcessByUptime', () => {
5
+ const now = 1_000_000_000;
6
+ const processes = [
7
+ { pid: 1, startedAt: now - 300_000 },
8
+ { pid: 2, startedAt: now - 290_000 },
9
+ { pid: 3 },
10
+ ];
11
+
12
+ it('picks the process started when the uptime says', () => {
13
+ expect(
14
+ matchProcessByUptime(processes, { uptimeSec: 290.4, observedAt: now })
15
+ ?.pid
16
+ ).toBe(2);
17
+ });
18
+
19
+ it('allows for the clock starting after the process and drifting with age', () => {
20
+ expect(
21
+ matchProcessByUptime([{ pid: 9, startedAt: now - 3_600_000 - 10_000 }], {
22
+ uptimeSec: 3600,
23
+ observedAt: now,
24
+ })?.pid
25
+ ).toBe(9);
26
+ });
27
+
28
+ it('finds nothing outside the tolerance', () => {
29
+ expect(
30
+ matchProcessByUptime(processes, { uptimeSec: 250, observedAt: now })
31
+ ).toBeUndefined();
32
+ });
33
+
34
+ it('refuses to choose between two processes started together', () => {
35
+ expect(
36
+ matchProcessByUptime(
37
+ [
38
+ { pid: 1, startedAt: now - 100_000 },
39
+ { pid: 2, startedAt: now - 100_500 },
40
+ ],
41
+ { uptimeSec: 100.2, observedAt: now }
42
+ )
43
+ ).toBeUndefined();
44
+ });
45
+ });
@@ -0,0 +1,90 @@
1
+ import type { BridgeConnection, SessionInfo } from '../bridge/index.js';
2
+ import type { RunningStudioProcess } from './studio-process-closer.js';
3
+
4
+ export const UPTIME_TOLERANCE_MS = 3000;
5
+ const UPTIME_DRIFT_RATIO = 0.0025;
6
+ const UPTIME_READ_TIMEOUT_MS = 3000;
7
+
8
+ export interface UptimeReading {
9
+ uptimeSec: number;
10
+ observedAt: number;
11
+ }
12
+
13
+ export function matchProcessByUptime<
14
+ T extends Pick<RunningStudioProcess, 'pid' | 'startedAt'>
15
+ >(processes: T[], reading: UptimeReading): T | undefined {
16
+ const startedAt = reading.observedAt - reading.uptimeSec * 1000;
17
+ const tolerance =
18
+ UPTIME_TOLERANCE_MS + reading.uptimeSec * 1000 * UPTIME_DRIFT_RATIO;
19
+ const candidates = processes
20
+ .filter((p) => p.startedAt !== undefined)
21
+ .map((p) => ({ process: p, delta: Math.abs(p.startedAt! - startedAt) }))
22
+ .sort((a, b) => a.delta - b.delta);
23
+ const [best, next] = candidates;
24
+ if (!best || best.delta > tolerance) {
25
+ return undefined;
26
+ }
27
+ if (next && next.delta <= tolerance) {
28
+ return undefined;
29
+ }
30
+ return best.process;
31
+ }
32
+
33
+ export async function resolveSessionPidsAsync(
34
+ connection: BridgeConnection,
35
+ processes: RunningStudioProcess[],
36
+ instanceIds?: string[]
37
+ ): Promise<Map<string, number>> {
38
+ const pids = new Map<string, number>();
39
+ if (!processes.some((p) => p.startedAt !== undefined)) {
40
+ return pids;
41
+ }
42
+
43
+ const byInstance = new Map<string, SessionInfo[]>();
44
+ for (const session of connection.listSessions()) {
45
+ if (instanceIds && !instanceIds.includes(session.instanceId)) continue;
46
+ const list = byInstance.get(session.instanceId) ?? [];
47
+ list.push(session);
48
+ byInstance.set(session.instanceId, list);
49
+ }
50
+
51
+ await Promise.all(
52
+ [...byInstance].map(async ([instanceId, sessions]) => {
53
+ const ordered = [...sessions].sort(
54
+ (a, b) => Number(b.context === 'edit') - Number(a.context === 'edit')
55
+ );
56
+ for (const info of ordered) {
57
+ const reading = await _readUptimeAsync(connection, info.sessionId);
58
+ if (reading) {
59
+ const match = matchProcessByUptime(processes, reading);
60
+ if (match) {
61
+ pids.set(instanceId, match.pid);
62
+ }
63
+ return;
64
+ }
65
+ }
66
+ })
67
+ );
68
+ return pids;
69
+ }
70
+
71
+ async function _readUptimeAsync(
72
+ connection: BridgeConnection,
73
+ sessionId: string
74
+ ): Promise<UptimeReading | undefined> {
75
+ try {
76
+ const session = await connection.resolveSessionAsync(sessionId);
77
+ const sentAt = Date.now();
78
+ const result = await session.execAsync(
79
+ 'return os.clock()',
80
+ UPTIME_READ_TIMEOUT_MS
81
+ );
82
+ const uptimeSec = Number(result.returnValues?.[0]);
83
+ if (!result.success || !Number.isFinite(uptimeSec)) {
84
+ return undefined;
85
+ }
86
+ return { uptimeSec, observedAt: (sentAt + Date.now()) / 2 };
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ tryListStudiosAsync,
3
+ tryExecuteLuauAsync,
4
+ tryCallStudioToolAsync,
5
+ closeStudioMcpAsync,
6
+ type StudioMcpTarget,
7
+ type StudioMcpFailure,
8
+ type StudioMcpResult,
9
+ type StudioMcpOptions,
10
+ type StudioMcpLuauOptions,
11
+ } from './roblox-studio-mcp.js';
12
+ export {
13
+ getStudioMcpSnapshotAsync,
14
+ resetStudioMcpSnapshot,
15
+ type StudioMcpSnapshot,
16
+ type StudioMcpSnapshotOptions,
17
+ } from './studio-mcp-snapshot.js';
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { McpStdioClient } from './mcp-stdio-client.js';
3
+ import {
4
+ createFakeMcpServer,
5
+ toolText,
6
+ type FakeHandler,
7
+ } from '../test/fake-mcp-server.js';
8
+
9
+ const initialize: FakeHandler = (method) =>
10
+ method === 'initialize'
11
+ ? {
12
+ result: {
13
+ protocolVersion: '2025-06-18',
14
+ capabilities: { tools: {} },
15
+ serverInfo: { name: 'RobloxStudio', version: '1.0.0' },
16
+ },
17
+ }
18
+ : undefined;
19
+
20
+ async function startAsync(handler: FakeHandler, requestTimeoutMs?: number) {
21
+ const server = createFakeMcpServer(
22
+ (method, params) => initialize(method, params) ?? handler(method, params)
23
+ );
24
+ const client = await McpStdioClient.startAsync('StudioMCP.exe', {
25
+ spawnProcess: () => server.child,
26
+ requestTimeoutMs,
27
+ });
28
+ return { server, client };
29
+ }
30
+
31
+ describe('McpStdioClient', () => {
32
+ it('initializes, then says it is initialized', async () => {
33
+ const { server, client } = await startAsync(() => undefined);
34
+
35
+ expect(client.protocolVersion).toBe('2025-06-18');
36
+ expect(client.serverInfo?.name).toBe('RobloxStudio');
37
+ expect(server.requests.map((r) => r.method)).toEqual([
38
+ 'initialize',
39
+ 'notifications/initialized',
40
+ ]);
41
+ await client.closeAsync();
42
+ });
43
+
44
+ it('lists tools, repairing a list result without caching hints', async () => {
45
+ const { client } = await startAsync((method) =>
46
+ method === 'tools/list'
47
+ ? {
48
+ result: {
49
+ resultType: 'complete',
50
+ tools: [{ name: 'execute_luau' }],
51
+ },
52
+ }
53
+ : undefined
54
+ );
55
+
56
+ expect(await client.listToolsAsync()).toEqual([{ name: 'execute_luau' }]);
57
+ await expect(client.requestAsync('tools/list', {})).resolves.toMatchObject({
58
+ ttlMs: 0,
59
+ cacheScope: 'private',
60
+ });
61
+ await client.closeAsync();
62
+ });
63
+
64
+ it('calls a tool and joins its text content', async () => {
65
+ const { server, client } = await startAsync((method) =>
66
+ method === 'tools/call' ? toolText('42') : undefined
67
+ );
68
+
69
+ const result = await client.callToolAsync('execute_luau', { code: 'x' });
70
+
71
+ expect(result).toMatchObject({ isError: false, text: '42' });
72
+ expect(server.requests.at(-1)?.params).toEqual({
73
+ name: 'execute_luau',
74
+ arguments: { code: 'x' },
75
+ });
76
+ await client.closeAsync();
77
+ });
78
+
79
+ it('reports a tool error as isError rather than throwing', async () => {
80
+ const { client } = await startAsync(() => toolText('boom', true));
81
+
82
+ await expect(client.callToolAsync('execute_luau')).resolves.toMatchObject({
83
+ isError: true,
84
+ text: 'boom',
85
+ });
86
+ await client.closeAsync();
87
+ });
88
+
89
+ it('rejects a JSON-RPC error with its message', async () => {
90
+ const { client } = await startAsync(() => ({
91
+ error: { code: -32602, message: 'bad params' },
92
+ }));
93
+
94
+ await expect(client.requestAsync('tools/call', {})).rejects.toThrow(
95
+ 'tools/call failed: bad params'
96
+ );
97
+ await client.closeAsync();
98
+ });
99
+
100
+ it('rejects requests in flight when the server exits, with its stderr', async () => {
101
+ const { server, client } = await startAsync(() => undefined);
102
+
103
+ const pending = client.callToolAsync('execute_luau');
104
+ server.exit(3, 'Studio went away');
105
+
106
+ await expect(pending).rejects.toThrow(
107
+ "Studio's MCP server exited (code 3): Studio went away"
108
+ );
109
+ await expect(client.callToolAsync('execute_luau')).rejects.toThrow(
110
+ 'exited'
111
+ );
112
+ });
113
+
114
+ it('waits for the server to exit when closed', async () => {
115
+ const { server, client } = await startAsync(() => undefined);
116
+
117
+ await client.closeAsync();
118
+
119
+ expect(server.child.killed).toBe(true);
120
+ expect(client.isRunning).toBe(false);
121
+ await expect(client.callToolAsync('execute_luau')).rejects.toThrow(
122
+ 'closed'
123
+ );
124
+ });
125
+
126
+ it('closes a server that already exited without killing it again', async () => {
127
+ const { server, client } = await startAsync(() => undefined);
128
+ server.exit(0);
129
+ await new Promise((resolve) => setImmediate(resolve));
130
+
131
+ await client.closeAsync();
132
+
133
+ expect(server.child.killed).toBe(false);
134
+ });
135
+
136
+ it('times out a request the server never answers', async () => {
137
+ const { client } = await startAsync(() => undefined);
138
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
139
+
140
+ const pending = expect(
141
+ client.callToolAsync('execute_luau')
142
+ ).rejects.toThrow('tools/call timed out after 30000ms');
143
+ await vi.advanceTimersByTimeAsync(30_000);
144
+ await pending;
145
+ vi.useRealTimers();
146
+ await client.closeAsync();
147
+ });
148
+ });