@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
@@ -0,0 +1,113 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ getStudioMcpSnapshotAsync,
4
+ resetStudioMcpSnapshot,
5
+ } from './studio-mcp-snapshot.js';
6
+ import type { StudioMcpResult, StudioMcpTarget } from './roblox-studio-mcp.js';
7
+
8
+ type ListResult = StudioMcpResult<StudioMcpTarget[]>;
9
+
10
+ const OK: ListResult = {
11
+ success: true,
12
+ value: [{ studioId: 'mcp-1', placeName: 'Place1' }],
13
+ };
14
+
15
+ describe('getStudioMcpSnapshotAsync', () => {
16
+ beforeEach(() => resetStudioMcpSnapshot());
17
+ afterEach(() => vi.useRealTimers());
18
+
19
+ it('waits for the first check and reports the Studios it found', async () => {
20
+ const listAsync = vi.fn(async () => OK);
21
+
22
+ const snapshot = await getStudioMcpSnapshotAsync({ listAsync });
23
+
24
+ expect(snapshot).toMatchObject({
25
+ status: 'ok',
26
+ studios: [{ studioId: 'mcp-1' }],
27
+ });
28
+ expect(listAsync).toHaveBeenCalledWith(
29
+ expect.objectContaining({ readPlaceIds: false, timeoutMs: 3000 })
30
+ );
31
+ });
32
+
33
+ it('reuses a fresh snapshot without checking again', async () => {
34
+ const listAsync = vi.fn(async () => OK);
35
+
36
+ await getStudioMcpSnapshotAsync({ listAsync });
37
+ await getStudioMcpSnapshotAsync({ listAsync });
38
+
39
+ expect(listAsync).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it('answers with the stale snapshot at once and refreshes in the background', async () => {
43
+ let resolveSecond: (result: ListResult) => void = () => {};
44
+ const listAsync = vi
45
+ .fn<() => Promise<ListResult>>()
46
+ .mockResolvedValueOnce(OK)
47
+ .mockImplementationOnce(
48
+ () => new Promise((resolve) => (resolveSecond = resolve))
49
+ );
50
+
51
+ const first = await getStudioMcpSnapshotAsync({ listAsync, maxAgeMs: 0 });
52
+ const second = await getStudioMcpSnapshotAsync({ listAsync, maxAgeMs: 0 });
53
+
54
+ expect(second).toBe(first);
55
+ expect(listAsync).toHaveBeenCalledTimes(2);
56
+ resolveSecond({ success: false, reason: 'timeout', message: 'slow' });
57
+ await new Promise((resolve) => setImmediate(resolve));
58
+ const third = await getStudioMcpSnapshotAsync({ listAsync });
59
+ expect(third).toMatchObject({
60
+ status: 'timeout',
61
+ message: 'slow',
62
+ lastOkAt: first?.checkedAt,
63
+ });
64
+ });
65
+
66
+ it('runs one check at a time', async () => {
67
+ let finish: (result: ListResult) => void = () => {};
68
+ const listAsync = vi.fn(
69
+ () => new Promise<ListResult>((resolve) => (finish = resolve))
70
+ );
71
+
72
+ const both = Promise.all([
73
+ getStudioMcpSnapshotAsync({ listAsync }),
74
+ getStudioMcpSnapshotAsync({ listAsync }),
75
+ ]);
76
+ finish(OK);
77
+ await both;
78
+
79
+ expect(listAsync).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('gives up waiting rather than hang when the first check does not finish', async () => {
83
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
84
+ const listAsync = vi.fn(() => new Promise<ListResult>(() => {}));
85
+ let settled = false;
86
+
87
+ const pending = getStudioMcpSnapshotAsync({ listAsync }).then((s) => {
88
+ settled = true;
89
+ return s;
90
+ });
91
+ await vi.advanceTimersByTimeAsync(3000);
92
+ expect(settled).toBe(false);
93
+ await vi.advanceTimersByTimeAsync(250);
94
+
95
+ expect(await pending).toBeUndefined();
96
+ });
97
+
98
+ it('records a failure with its reason', async () => {
99
+ const listAsync = vi.fn(
100
+ async (): Promise<ListResult> => ({
101
+ success: false,
102
+ reason: 'unavailable',
103
+ message: 'not installed',
104
+ })
105
+ );
106
+
107
+ expect(await getStudioMcpSnapshotAsync({ listAsync })).toMatchObject({
108
+ status: 'unavailable',
109
+ message: 'not installed',
110
+ studios: [],
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,104 @@
1
+ import type { SessionInfo } from '../bridge/types.js';
2
+ import type { StudioWindow } from './studio/studio-session.js';
3
+ import {
4
+ tryListStudiosAsync,
5
+ type StudioMcpFailure,
6
+ type StudioMcpOptions,
7
+ type StudioMcpResult,
8
+ type StudioMcpTarget,
9
+ } from './roblox-studio-mcp.js';
10
+
11
+ export interface StudioMcpSnapshot {
12
+ status: 'ok' | StudioMcpFailure;
13
+ studios: StudioMcpTarget[];
14
+ message?: string;
15
+ checkedAt: number;
16
+ latencyMs: number;
17
+ lastOkAt?: number;
18
+ settled?: boolean;
19
+ }
20
+
21
+ export interface StudioMcpSnapshotOptions {
22
+ sessions?: SessionInfo[];
23
+ sessionPids?: Map<string, number>;
24
+ windows?: StudioWindow[];
25
+ maxWaitMs?: number;
26
+ maxAgeMs?: number;
27
+ listAsync?: (
28
+ options: StudioMcpOptions
29
+ ) => Promise<StudioMcpResult<StudioMcpTarget[]>>;
30
+ }
31
+
32
+ const DEFAULT_MAX_WAIT_MS = 3000;
33
+ const DEFAULT_MAX_AGE_MS = 5000;
34
+
35
+ let _cached: StudioMcpSnapshot | undefined;
36
+ let _inFlight: Promise<StudioMcpSnapshot> | undefined;
37
+
38
+ export async function getStudioMcpSnapshotAsync(
39
+ options: StudioMcpSnapshotOptions = {}
40
+ ): Promise<StudioMcpSnapshot | undefined> {
41
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
42
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
43
+ if (_cached && Date.now() - _cached.checkedAt < maxAgeMs) {
44
+ return _cached;
45
+ }
46
+
47
+ _inFlight ??= _refreshAsync(options, maxWaitMs).finally(() => {
48
+ _inFlight = undefined;
49
+ });
50
+ if (_cached) {
51
+ return _cached;
52
+ }
53
+
54
+ let timer: NodeJS.Timeout | undefined;
55
+ const gaveUp = new Promise<undefined>((resolve) => {
56
+ timer = setTimeout(() => resolve(undefined), maxWaitMs + 250);
57
+ });
58
+ try {
59
+ return await Promise.race([_inFlight, gaveUp]);
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ export function resetStudioMcpSnapshot(): void {
66
+ _cached = undefined;
67
+ _inFlight = undefined;
68
+ }
69
+
70
+ async function _refreshAsync(
71
+ options: StudioMcpSnapshotOptions,
72
+ timeoutMs: number
73
+ ): Promise<StudioMcpSnapshot> {
74
+ const startedAt = Date.now();
75
+ const result = await (options.listAsync ?? tryListStudiosAsync)({
76
+ sessions: options.sessions,
77
+ sessionPids: options.sessionPids,
78
+ ...(options.windows
79
+ ? { listWindowsAsync: async () => options.windows! }
80
+ : {}),
81
+ timeoutMs,
82
+ readPlaceIds: false,
83
+ });
84
+ const checkedAt = Date.now();
85
+ const snapshot: StudioMcpSnapshot = result.success
86
+ ? {
87
+ status: 'ok',
88
+ studios: result.value,
89
+ settled: result.settled !== false,
90
+ checkedAt,
91
+ latencyMs: checkedAt - startedAt,
92
+ lastOkAt: checkedAt,
93
+ }
94
+ : {
95
+ status: result.reason,
96
+ studios: [],
97
+ message: result.message,
98
+ checkedAt,
99
+ latencyMs: checkedAt - startedAt,
100
+ lastOkAt: _cached?.lastOkAt,
101
+ };
102
+ _cached = snapshot;
103
+ return snapshot;
104
+ }
@@ -0,0 +1,74 @@
1
+ import { EventEmitter } from 'events';
2
+ import { PassThrough } from 'stream';
3
+ import type { ChildProcessWithoutNullStreams } from 'child_process';
4
+
5
+ export type FakeHandler = (
6
+ method: string,
7
+ params: any
8
+ ) =>
9
+ | { result?: unknown; error?: { code: number; message: string } }
10
+ | undefined;
11
+
12
+ export interface FakeMcpServer {
13
+ child: ChildProcessWithoutNullStreams;
14
+ requests: Array<{ method: string; params: any }>;
15
+ exit(code: number, stderr?: string): void;
16
+ }
17
+
18
+ export function createFakeMcpServer(handler: FakeHandler): FakeMcpServer {
19
+ const stdin = new PassThrough();
20
+ const stdout = new PassThrough();
21
+ const stderr = new PassThrough();
22
+ const child = Object.assign(new EventEmitter(), {
23
+ stdin,
24
+ stdout,
25
+ stderr,
26
+ exitCode: null as number | null,
27
+ killed: false,
28
+ kill() {
29
+ child.killed = true;
30
+ child.emit('close', null);
31
+ return true;
32
+ },
33
+ });
34
+ const requests: FakeMcpServer['requests'] = [];
35
+
36
+ let buffer = '';
37
+ stdin.setEncoding('utf8');
38
+ stdin.on('data', (chunk: string) => {
39
+ buffer += chunk;
40
+ let newline: number;
41
+ while ((newline = buffer.indexOf('\n')) >= 0) {
42
+ const message = JSON.parse(buffer.slice(0, newline));
43
+ buffer = buffer.slice(newline + 1);
44
+ requests.push({ method: message.method, params: message.params });
45
+ if (message.id === undefined) {
46
+ continue;
47
+ }
48
+ const reply = handler(message.method, message.params);
49
+ if (reply) {
50
+ stdout.write(
51
+ JSON.stringify({ jsonrpc: '2.0', id: message.id, ...reply }) + '\n'
52
+ );
53
+ }
54
+ }
55
+ });
56
+
57
+ return {
58
+ child: child as unknown as ChildProcessWithoutNullStreams,
59
+ requests,
60
+ exit(code, text) {
61
+ if (text) {
62
+ stderr.write(text);
63
+ }
64
+ setImmediate(() => {
65
+ child.exitCode = code;
66
+ child.emit('close', code);
67
+ });
68
+ },
69
+ };
70
+ }
71
+
72
+ export function toolText(text: string, isError = false) {
73
+ return { result: { content: [{ type: 'text', text }], isError } };
74
+ }
@@ -23,6 +23,8 @@ export type PluginContext = 'edit' | 'client' | 'server';
23
23
  */
24
24
  export const MAX_MESSAGE_BYTES = 512 * 1024 * 1024;
25
25
 
26
+ export const MAX_EXECUTE_INPUT_BYTES = 192 * 1024 * 1024;
27
+
26
28
  export type Capability =
27
29
  | 'execute'
28
30
  | 'queryState'
@@ -35,6 +37,7 @@ export type Capability =
35
37
  | 'grepScripts'
36
38
  | 'startPlaytest'
37
39
  | 'endPlaytest'
40
+ | 'playtestPlayers'
38
41
  | 'heartbeat'
39
42
  | 'registerAction'
40
43
  | 'syncActions';
@@ -142,6 +145,7 @@ export interface ScriptCompleteMessage extends BaseMessage {
142
145
  * which means the script ran and returned nothing.
143
146
  */
144
147
  returnValues?: SerializedReturnValue[];
148
+ ranAs?: RunAs;
145
149
  };
146
150
  }
147
151
 
@@ -160,6 +164,8 @@ export interface RegisterMessage extends PushMessage {
160
164
  launchId?: string;
161
165
  /** The player of a multiplayer test client. */
162
166
  playerName?: string;
167
+ /** The playtest studio-bridge started this session's DataModel for. */
168
+ playtestId?: string;
163
169
  capabilities: Capability[];
164
170
  };
165
171
  }
@@ -320,6 +326,22 @@ export interface EndPlaytestResultMessage extends RequestMessage {
320
326
  };
321
327
  }
322
328
 
329
+ export interface PlaytestPlayerPayload {
330
+ name: string;
331
+ userId: number;
332
+ displayName: string;
333
+ }
334
+
335
+ export interface PlaytestPlayersResultMessage extends RequestMessage {
336
+ type: 'playtestPlayersResult';
337
+ payload: {
338
+ /** The players `list` found or `add` added. */
339
+ players?: PlaytestPlayerPayload[];
340
+ /** The players `remove` waited for that had not left by its timeout. */
341
+ remaining?: string[];
342
+ };
343
+ }
344
+
323
345
  export interface PluginErrorMessage extends BaseMessage {
324
346
  type: 'error';
325
347
  requestId?: string;
@@ -347,6 +369,7 @@ export type PluginMessage =
347
369
  | SyncActionsResultMessage
348
370
  | PlaytestEndedMessage
349
371
  | EndPlaytestResultMessage
372
+ | PlaytestPlayersResultMessage
350
373
  | PluginErrorMessage;
351
374
 
352
375
  export interface ExecuteMessage extends BaseMessage {
@@ -358,9 +381,14 @@ export interface ExecuteMessage extends BaseMessage {
358
381
  timeoutMs?: number;
359
382
  /** Passed to the script as `...`. */
360
383
  args?: string[];
384
+ /** Base64 files, passed to the script as buffers in `...` before `args`. */
385
+ inputs?: string[];
386
+ runAs?: RunAs;
361
387
  };
362
388
  }
363
389
 
390
+ export type RunAs = 'plugin' | 'game' | 'mcp';
391
+
364
392
  export interface ShutdownMessage extends PushMessage {
365
393
  type: 'shutdown';
366
394
  payload: Record<string, never>;
@@ -478,26 +506,32 @@ export interface StartPlaytestMessage extends RequestMessage {
478
506
  instanceId: string;
479
507
  /** Starts a multiplayer test, a server and a client process per player. */
480
508
  players?: number;
509
+ /** Plants a studio-bridge client as the playtest's server session. */
510
+ bridge?: PlaytestBridgePayload;
481
511
  };
482
512
  }
483
513
 
514
+ /** The studio-bridge client a `startPlaytest` plants, see playtest-bridge.ts. */
515
+ export interface PlaytestBridgePayload {
516
+ entrySource: string;
517
+ modules: Record<string, string>;
518
+ precompiled: string[];
519
+ }
520
+
484
521
  export interface EndPlaytestMessage extends RequestMessage {
485
522
  type: 'endPlaytest';
486
523
  payload: Record<string, never>;
487
524
  }
488
525
 
489
- /** Answered with `scriptComplete`, the same reply as `execute`. */
490
- export interface RunPlaytestScriptMessage extends RequestMessage {
491
- type: 'runPlaytestScript';
492
- payload: {
493
- mode: PlaytestMode;
494
- script: string;
495
- /** Planted in the playtest's server to run the script and end the playtest. */
496
- runnerSource: string;
497
- timeoutSec: number;
498
- /** Passed to the script as `...`. */
499
- args?: string[];
500
- };
526
+ export type PlaytestPlayersRequest =
527
+ | { op: 'list' }
528
+ | { op: 'add'; count: number; timeoutSec: number }
529
+ | { op: 'remove'; kick: string[]; names: string[]; timeoutSec: number }
530
+ | { op: 'leave' };
531
+
532
+ export interface PlaytestPlayersMessage extends RequestMessage {
533
+ type: 'playtestPlayers';
534
+ payload: PlaytestPlayersRequest;
501
535
  }
502
536
 
503
537
  export interface ServerErrorMessage extends BaseMessage {
@@ -525,7 +559,7 @@ export type ServerMessage =
525
559
  | SyncActionsMessage
526
560
  | StartPlaytestMessage
527
561
  | EndPlaytestMessage
528
- | RunPlaytestScriptMessage
562
+ | PlaytestPlayersMessage
529
563
  | ServerErrorMessage;
530
564
 
531
565
  export function encodeMessage(msg: ServerMessage): string {
@@ -616,6 +650,11 @@ export function decodePluginMessage(raw: string): PluginMessage | null {
616
650
  : undefined,
617
651
  output,
618
652
  returnValues,
653
+ ...(payload.ranAs === 'plugin' ||
654
+ payload.ranAs === 'game' ||
655
+ payload.ranAs === 'mcp'
656
+ ? { ranAs: payload.ranAs }
657
+ : {}),
619
658
  },
620
659
  };
621
660
  }
@@ -657,6 +696,10 @@ export function decodePluginMessage(raw: string): PluginMessage | null {
657
696
  typeof payload.playerName === 'string'
658
697
  ? payload.playerName
659
698
  : undefined,
699
+ playtestId:
700
+ typeof payload.playtestId === 'string'
701
+ ? payload.playtestId
702
+ : undefined,
660
703
  capabilities: payload.capabilities as Capability[],
661
704
  },
662
705
  };
@@ -906,6 +949,22 @@ export function decodePluginMessage(raw: string): PluginMessage | null {
906
949
  },
907
950
  };
908
951
 
952
+ case 'playtestPlayersResult':
953
+ if (requestId === undefined) return null;
954
+ return {
955
+ type: 'playtestPlayersResult',
956
+ sessionId,
957
+ requestId,
958
+ payload: {
959
+ players: Array.isArray(payload.players)
960
+ ? (payload.players as PlaytestPlayerPayload[])
961
+ : undefined,
962
+ remaining: isStringArray(payload.remaining)
963
+ ? payload.remaining
964
+ : undefined,
965
+ },
966
+ };
967
+
909
968
  case 'error':
910
969
  if (
911
970
  typeof payload.code !== 'string' ||
@@ -974,6 +1033,12 @@ export function decodeServerMessage(raw: string): ServerMessage | null {
974
1033
  ? { timeoutMs: payload.timeoutMs }
975
1034
  : {}),
976
1035
  ...(isStringArray(payload.args) ? { args: payload.args } : {}),
1036
+ ...(isStringArray(payload.inputs)
1037
+ ? { inputs: payload.inputs }
1038
+ : {}),
1039
+ ...(payload.runAs === 'plugin' || payload.runAs === 'game'
1040
+ ? { runAs: payload.runAs }
1041
+ : {}),
977
1042
  },
978
1043
  };
979
1044
  }
@@ -1185,6 +1250,9 @@ export function decodeServerMessage(raw: string): ServerMessage | null {
1185
1250
  ...(typeof payload.players === 'number'
1186
1251
  ? { players: payload.players }
1187
1252
  : {}),
1253
+ ...(isPlaytestBridge(payload.bridge)
1254
+ ? { bridge: payload.bridge }
1255
+ : {}),
1188
1256
  },
1189
1257
  };
1190
1258
 
@@ -1197,26 +1265,14 @@ export function decodeServerMessage(raw: string): ServerMessage | null {
1197
1265
  payload: {} as Record<string, never>,
1198
1266
  };
1199
1267
 
1200
- case 'runPlaytestScript':
1268
+ case 'playtestPlayers':
1201
1269
  if (requestId === undefined) return null;
1202
- if (
1203
- (payload.mode !== 'play' && payload.mode !== 'run') ||
1204
- typeof payload.script !== 'string' ||
1205
- typeof payload.runnerSource !== 'string' ||
1206
- typeof payload.timeoutSec !== 'number'
1207
- )
1208
- return null;
1270
+ if (!isPlaytestPlayersRequest(payload)) return null;
1209
1271
  return {
1210
- type: 'runPlaytestScript',
1272
+ type: 'playtestPlayers',
1211
1273
  sessionId,
1212
1274
  requestId,
1213
- payload: {
1214
- mode: payload.mode,
1215
- script: payload.script,
1216
- runnerSource: payload.runnerSource,
1217
- timeoutSec: payload.timeoutSec,
1218
- ...(isStringArray(payload.args) ? { args: payload.args } : {}),
1219
- },
1275
+ payload,
1220
1276
  };
1221
1277
 
1222
1278
  case 'error':
@@ -1240,3 +1296,37 @@ export function decodeServerMessage(raw: string): ServerMessage | null {
1240
1296
  return null;
1241
1297
  }
1242
1298
  }
1299
+
1300
+ function isPlaytestBridge(value: unknown): value is PlaytestBridgePayload {
1301
+ if (typeof value !== 'object' || value === null) return false;
1302
+ const bridge = value as Record<string, unknown>;
1303
+ return (
1304
+ typeof bridge.entrySource === 'string' &&
1305
+ typeof bridge.modules === 'object' &&
1306
+ bridge.modules !== null &&
1307
+ Object.values(bridge.modules).every((v) => typeof v === 'string') &&
1308
+ isStringArray(bridge.precompiled)
1309
+ );
1310
+ }
1311
+
1312
+ function isPlaytestPlayersRequest(
1313
+ value: Record<string, unknown>
1314
+ ): value is PlaytestPlayersRequest {
1315
+ switch (value.op) {
1316
+ case 'list':
1317
+ case 'leave':
1318
+ return true;
1319
+ case 'add':
1320
+ return (
1321
+ typeof value.count === 'number' && typeof value.timeoutSec === 'number'
1322
+ );
1323
+ case 'remove':
1324
+ return (
1325
+ isStringArray(value.kick) &&
1326
+ isStringArray(value.names) &&
1327
+ typeof value.timeoutSec === 'number'
1328
+ );
1329
+ default:
1330
+ return false;
1331
+ }
1332
+ }
@@ -0,0 +1,32 @@
1
+ --!strict
2
+ --[[
3
+ Entry point for the bridge planted into a playtest's client, the client twin
4
+ of StudioBridgePlaytest.server.lua: an ordinary LocalScript, so a play mode
5
+ playtest has a client session without Studio loading user plugins into it.
6
+ It only starts in the playtest it was planted for.
7
+ ]]
8
+
9
+ local RunService = game:GetService("RunService")
10
+
11
+ local PrecompiledSources = require(script.Shared.PrecompiledSources)
12
+ local StudioBridgeClient = require(script.Shared.StudioBridgeClient)
13
+ local StudioBridgeSessionUtils = require(script.Shared.StudioBridgeSessionUtils)
14
+
15
+ if not (RunService:IsRunning() and RunService:IsClient()) then
16
+ return
17
+ end
18
+
19
+ local ok, testArgs = pcall(function()
20
+ return (game:GetService("StudioTestService") :: any):GetTestArgs()
21
+ end)
22
+ local playtestArgs = if ok then StudioBridgeSessionUtils.getPlaytestArgs(testArgs) else nil
23
+ if not playtestArgs or script:GetAttribute("PlaytestId") ~= playtestArgs.playtestId then
24
+ return
25
+ end
26
+
27
+ local stop = StudioBridgeClient.start({
28
+ playtestArgs = playtestArgs,
29
+ compile = PrecompiledSources.createCompiler(script.Precompiled),
30
+ })
31
+
32
+ script.Destroying:Connect(stop)
@@ -0,0 +1,40 @@
1
+ --!strict
2
+ --[[
3
+ Entry point for the bridge planted into a playtest's server, so the playtest
4
+ has a server session without Studio loading user plugins into playtests.
5
+
6
+ The `startPlaytest` action plants this as a Script in the edit DataModel's
7
+ ServerScriptService, with the plugin's Shared modules and the sources
8
+ compiled for it ahead of time under it. The playtest copies the edit
9
+ DataModel, so it starts in the playtest's server as an ordinary Script. It
10
+ only starts in the playtest it was planted for: anywhere else, such as a
11
+ place saved while it was planted, it does nothing.
12
+
13
+ Built from the plugin template, so like the plugin it cannot use the
14
+ Nevermore loader.
15
+ ]]
16
+
17
+ local RunService = game:GetService("RunService")
18
+
19
+ local PrecompiledSources = require(script.Shared.PrecompiledSources)
20
+ local StudioBridgeClient = require(script.Shared.StudioBridgeClient)
21
+ local StudioBridgeSessionUtils = require(script.Shared.StudioBridgeSessionUtils)
22
+
23
+ if not (RunService:IsRunning() and RunService:IsServer()) then
24
+ return
25
+ end
26
+
27
+ local ok, testArgs = pcall(function()
28
+ return (game:GetService("StudioTestService") :: any):GetTestArgs()
29
+ end)
30
+ local playtestArgs = if ok then StudioBridgeSessionUtils.getPlaytestArgs(testArgs) else nil
31
+ if not playtestArgs or StudioBridgeSessionUtils.findPlantedBridge(script.Parent, playtestArgs.playtestId) ~= script then
32
+ return
33
+ end
34
+
35
+ local stop = StudioBridgeClient.start({
36
+ playtestArgs = playtestArgs,
37
+ compile = PrecompiledSources.createCompiler(script.Precompiled),
38
+ })
39
+
40
+ script.Destroying:Connect(stop)
@@ -30,6 +30,13 @@ export type Handler = (payload: { [string]: any }, requestId: string, sessionId:
30
30
 
31
31
  export type SendMessage = (message: { [string]: any }) -> ()
32
32
 
33
+ --[=[
34
+ Compiles Luau source into a function, or returns nil and why it could not.
35
+ @type Compiler (source: string, chunkName: string?) -> (any, string?)
36
+ @within ActionRouter
37
+ ]=]
38
+ export type Compiler = (source: string, chunkName: string?) -> (any, string?)
39
+
33
40
  export type ActionInfo = {
34
41
  module: any,
35
42
  hash: string,
@@ -42,6 +49,7 @@ export type ActionRouter = typeof(setmetatable(
42
49
  _responseTypes: { [string]: string },
43
50
  _actions: { [string]: ActionInfo },
44
51
  _vendorPng: any?,
52
+ _compiler: Compiler?,
45
53
  },
46
54
  {} :: typeof({ __index = ActionRouter })
47
55
  ))
@@ -58,6 +66,7 @@ function ActionRouter.new(): ActionRouter
58
66
 
59
67
  -- The screenshot action reads its PNG encoder off this field
60
68
  self._vendorPng = nil
69
+ self._compiler = nil
61
70
 
62
71
  return self
63
72
  end
@@ -83,6 +92,15 @@ function ActionRouter.SetVendorPng(self: ActionRouter, png: any)
83
92
  self._vendorPng = png
84
93
  end
85
94
 
95
+ --[=[
96
+ Sets how source compiles where `loadstring` is off, in place of compiling
97
+ through a ModuleScript. The bridge planted into a playtest cannot set a
98
+ ModuleScript's source, so it looks up sources compiled for it ahead of time.
99
+ ]=]
100
+ function ActionRouter.SetCompiler(self: ActionRouter, compiler: Compiler)
101
+ self._compiler = compiler
102
+ end
103
+
86
104
  --[=[
87
105
  Dispatches a message to its handler and returns the response message, if any.
88
106
  ]=]
@@ -130,15 +148,19 @@ end
130
148
 
131
149
  --[=[
132
150
  Compiles Luau source into a function, like `loadstring`. A playtest's client
133
- has no `loadstring`, so there the source compiles through a ModuleScript
134
- instead. Returns nil and the compile error when the source does not compile.
151
+ has no `loadstring`, so there the source compiles through the compiler set
152
+ with `SetCompiler`, or else a ModuleScript. Returns nil and the compile error
153
+ when the source does not compile.
135
154
  ]=]
136
- function ActionRouter.Compile(_self: ActionRouter, source: string, chunkName: string?): (any, string?)
155
+ function ActionRouter.Compile(self: ActionRouter, source: string, chunkName: string?): (any, string?)
137
156
  local loadstringOk, fn, compileErr = pcall(loadstring :: any, source, chunkName)
138
157
  if loadstringOk then
139
158
  return fn, compileErr
140
159
  end
141
160
 
161
+ if self._compiler then
162
+ return self._compiler(source, chunkName)
163
+ end
142
164
  return ActionRouter._compileWithModuleScript(source, chunkName)
143
165
  end
144
166