@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
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { describe, it, expect } from 'vitest';
6
6
  import type { SessionInfo } from '../../../bridge/index.js';
7
- import { listSessionsHandlerAsync } from './list.js';
7
+ import { formatSessionsTable, listSessionsHandlerAsync } from './list.js';
8
+ import type { StudioMcpSnapshot } from '../../../roblox-mcp/index.js';
8
9
 
9
10
  function createSessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
10
11
  return {
@@ -81,3 +82,131 @@ describe('listSessionsHandlerAsync', () => {
81
82
  expect(s.origin).toBe('managed');
82
83
  });
83
84
  });
85
+
86
+ describe('listSessionsHandlerAsync with Studio processes and MCP', () => {
87
+ const now = Date.parse('2026-11-05T00:30:00Z');
88
+ const snapshot = (
89
+ overrides: Partial<StudioMcpSnapshot> = {}
90
+ ): StudioMcpSnapshot => ({
91
+ status: 'ok',
92
+ studios: [],
93
+ checkedAt: now,
94
+ latencyMs: 640,
95
+ ...overrides,
96
+ });
97
+ const processes = [
98
+ {
99
+ pid: 70,
100
+ windowTitle: 'Place1 - Roblox Studio',
101
+ startedAt: now - 300_000,
102
+ },
103
+ {
104
+ pid: 80,
105
+ windowTitle: 'Place1 - Roblox Studio',
106
+ startedAt: now - 290_000,
107
+ },
108
+ ];
109
+
110
+ it('says no Studio is open, and how long it waited', async () => {
111
+ const result = await listSessionsHandlerAsync(
112
+ createMockConnection([]),
113
+ {},
114
+ { listProcessesAsync: async () => [], elapsedSec: () => 12.34 }
115
+ );
116
+
117
+ expect(result.summary).toBe('No Roblox Studio processes are open (12.3s).');
118
+ });
119
+
120
+ it('says Studio is open without the plugin, rather than that nothing is', async () => {
121
+ const result = await listSessionsHandlerAsync(
122
+ createMockConnection([]),
123
+ {},
124
+ { listProcessesAsync: async () => processes, elapsedSec: () => 5 }
125
+ );
126
+
127
+ expect(result.summary).toContain(
128
+ '2 Studio process(es) open, but none has the studio-bridge plugin connected (5.0s)'
129
+ );
130
+ });
131
+
132
+ it('lists each Studio with its plugin session, MCP id and where it was launched', async () => {
133
+ const sessions = [
134
+ createSessionInfo({ sessionId: 'a-edit', instanceId: 'a' }),
135
+ ];
136
+
137
+ const result = await listSessionsHandlerAsync(
138
+ createMockConnection(sessions),
139
+ {},
140
+ {
141
+ listProcessesAsync: async () => processes,
142
+ resolveSessionPidsAsync: async () => new Map([['a', 70]]),
143
+ readLaunchesAsync: async () => [
144
+ {
145
+ pid: 70,
146
+ cwd: 'C:gameshub',
147
+ opened: 'a new Baseplate',
148
+ launchedAt: now - 301_000,
149
+ },
150
+ ],
151
+ mcpSnapshotAsync: async () =>
152
+ snapshot({
153
+ studios: [
154
+ {
155
+ studioId: 'aaaaaaaa-1',
156
+ placeName: 'Place1',
157
+ pid: 70,
158
+ instanceId: 'a',
159
+ },
160
+ { studioId: 'bbbbbbbb-2', placeName: 'Place1', pid: 80 },
161
+ ],
162
+ }),
163
+ }
164
+ );
165
+
166
+ expect(result.sessions[0].pid).toBe(70);
167
+ expect(result.studios).toEqual([
168
+ expect.objectContaining({
169
+ pid: 70,
170
+ instanceId: 'a',
171
+ mcpStudioId: 'aaaaaaaa-1',
172
+ launch: expect.objectContaining({ cwd: 'C:gameshub' }),
173
+ }),
174
+ expect.objectContaining({ pid: 80, mcpStudioId: 'bbbbbbbb-2' }),
175
+ ]);
176
+ const text = formatSessionsTable(result, now);
177
+ expect(text).toContain('a new Baseplate from C:gameshub');
178
+ expect(text).toContain('not by studio-bridge');
179
+ expect(text).toContain('5m');
180
+ });
181
+
182
+ it('still lists sessions and Studios when MCP is unavailable, and says why', async () => {
183
+ const result = await listSessionsHandlerAsync(
184
+ createMockConnection([createSessionInfo({ sessionId: 's1' })]),
185
+ {},
186
+ {
187
+ listProcessesAsync: async () => processes,
188
+ mcpSnapshotAsync: async () =>
189
+ snapshot({ status: 'timeout', message: 'tools/call timed out' }),
190
+ }
191
+ );
192
+
193
+ const text = formatSessionsTable(result, now);
194
+ expect(text).toContain('s1');
195
+ expect(text).toContain('MCP: timeout (tools/call timed out)');
196
+ expect(result.studios).toHaveLength(2);
197
+ });
198
+
199
+ it('leaves MCP out when the check gave up or the platform has none', async () => {
200
+ for (const mcpSnapshotAsync of [
201
+ async () => undefined,
202
+ async () => snapshot({ status: 'unsupported' }),
203
+ ]) {
204
+ const result = await listSessionsHandlerAsync(
205
+ createMockConnection([createSessionInfo({ sessionId: 's1' })]),
206
+ {},
207
+ { mcpSnapshotAsync }
208
+ );
209
+ expect(formatSessionsTable(result, now)).not.toContain('MCP');
210
+ }
211
+ });
212
+ });
@@ -1,12 +1,15 @@
1
1
  /**
2
- * `process list` — list connected Studio sessions.
2
+ * `process list` — list connected Studio sessions and the Studio processes
3
+ * behind them.
3
4
  */
4
5
 
5
6
  import {
6
7
  defineCommand,
7
8
  type SessionTargeting,
8
9
  } from '../../framework/define-command.js';
10
+ import { arg } from '../../framework/arg-builder.js';
9
11
  import { OutputHelper } from '@quenty/cli-output-helpers';
12
+ import { formatDurationMs } from '@quenty/cli-output-helpers/cli-utils';
10
13
  import {
11
14
  formatTable,
12
15
  type TableColumn,
@@ -14,39 +17,195 @@ import {
14
17
  import type { BridgeConnection } from '../../../bridge/index.js';
15
18
  import type { SessionInfo } from '../../../bridge/index.js';
16
19
  import type { StudioState } from '../../../server/web-socket-protocol.js';
20
+ import {
21
+ getStudioMcpSnapshotAsync,
22
+ type StudioMcpSnapshot,
23
+ } from '../../../roblox-mcp/index.js';
24
+ import {
25
+ listStudioProcessesAsync,
26
+ type RunningStudioProcess,
27
+ } from '../../../process/studio-process-closer.js';
28
+ import { resolveSessionPidsAsync } from '../../../process/studio-process-identity.js';
29
+ import {
30
+ findStudioLaunch,
31
+ readStudioLaunchesAsync,
32
+ type StudioLaunchRecord,
33
+ } from '../../../process/studio-launch-registry.js';
17
34
  import { colorizeState } from '../format-state.js';
18
35
 
36
+ export interface McpStudioState {
37
+ status: StudioMcpSnapshot['status'];
38
+ message?: string;
39
+ latencyMs: number;
40
+ checkedAt: string;
41
+ lastOkAt?: string;
42
+ settled?: boolean;
43
+ }
44
+
45
+ export interface StudioProcessRow {
46
+ pid?: number;
47
+ startedAt?: string;
48
+ windowTitle: string;
49
+ instanceId?: string;
50
+ mcpStudioId?: string;
51
+ launch?: { cwd: string; opened: string; launchedAt: string };
52
+ }
53
+
54
+ export interface ListedSession extends SessionInfo {
55
+ pid?: number;
56
+ }
57
+
19
58
  export interface SessionsResult {
20
- sessions: SessionInfo[];
59
+ sessions: ListedSession[];
60
+ studios?: StudioProcessRow[];
61
+ mcp?: McpStudioState;
21
62
  summary: string;
22
63
  }
23
64
 
65
+ export interface ListSessionsOptions {
66
+ listProcessesAsync?: () => Promise<RunningStudioProcess[]>;
67
+ resolveSessionPidsAsync?: (
68
+ processes: RunningStudioProcess[]
69
+ ) => Promise<Map<string, number>>;
70
+ readLaunchesAsync?: () => Promise<StudioLaunchRecord[]>;
71
+ mcpSnapshotAsync?: (
72
+ sessions: SessionInfo[],
73
+ sessionPids: Map<string, number>,
74
+ processes: RunningStudioProcess[] | undefined
75
+ ) => Promise<StudioMcpSnapshot | undefined>;
76
+ elapsedSec?: () => number;
77
+ }
78
+
24
79
  /**
25
- * List all connected Studio sessions with a summary message.
80
+ * List the connected Studio sessions, and when the process list is available,
81
+ * every Studio process with its session, MCP id and where it was launched.
26
82
  */
27
83
  export async function listSessionsHandlerAsync(
28
84
  connection: BridgeConnection,
29
- targeting: SessionTargeting = {}
85
+ targeting: SessionTargeting = {},
86
+ options: ListSessionsOptions = {}
30
87
  ): Promise<SessionsResult> {
31
- const sessions = connection.findSessions(targeting.target, targeting.context);
88
+ const allSessions = connection.listSessions();
89
+ const processes = await options.listProcessesAsync?.().catch(() => undefined);
90
+ const sessionPids =
91
+ processes && options.resolveSessionPidsAsync
92
+ ? await options.resolveSessionPidsAsync(processes).catch(() => new Map())
93
+ : new Map<string, number>();
94
+ const [launches, snapshot] = await Promise.all([
95
+ options.readLaunchesAsync?.().catch(() => []) ?? Promise.resolve([]),
96
+ options.mcpSnapshotAsync?.(allSessions, sessionPids, processes),
97
+ ]);
32
98
 
33
- if (sessions.length === 0) {
99
+ const sessions: ListedSession[] = connection
100
+ .findSessions(targeting.target, targeting.context)
101
+ .map((s) => {
102
+ const pid = sessionPids.get(s.instanceId);
103
+ return pid !== undefined ? { ...s, pid } : s;
104
+ });
105
+ const studios = processes
106
+ ? buildStudioRows(processes, sessionPids, launches, snapshot)
107
+ : undefined;
108
+ const mcp = snapshot ? toMcpStudioState(snapshot) : undefined;
109
+
110
+ return {
111
+ sessions,
112
+ ...(studios ? { studios } : {}),
113
+ ...(mcp ? { mcp } : {}),
114
+ summary: describeSummary(
115
+ sessions.length,
116
+ allSessions.length,
117
+ processes,
118
+ options.elapsedSec?.()
119
+ ),
120
+ };
121
+ }
122
+
123
+ function buildStudioRows(
124
+ processes: RunningStudioProcess[],
125
+ sessionPids: Map<string, number>,
126
+ launches: StudioLaunchRecord[],
127
+ snapshot: StudioMcpSnapshot | undefined
128
+ ): StudioProcessRow[] {
129
+ const instanceByPid = new Map([...sessionPids].map(([id, pid]) => [pid, id]));
130
+ const mcpStudios = snapshot?.status === 'ok' ? snapshot.studios : [];
131
+ const shown = new Set<string>();
132
+
133
+ const rows: StudioProcessRow[] = processes.map((proc) => {
134
+ const instanceId = instanceByPid.get(proc.pid);
135
+ const mcpStudio = mcpStudios.find(
136
+ (s) =>
137
+ s.pid === proc.pid ||
138
+ (instanceId !== undefined && s.instanceId === instanceId)
139
+ );
140
+ if (mcpStudio) shown.add(mcpStudio.studioId);
141
+ const launch = findStudioLaunch(launches, proc);
34
142
  return {
35
- sessions: [],
36
- summary:
37
- connection.listSessions().length > 0
38
- ? 'No sessions match --target/--context.'
39
- : 'No active sessions. Is Studio running with the studio-bridge plugin?',
143
+ pid: proc.pid,
144
+ ...(proc.startedAt !== undefined
145
+ ? { startedAt: new Date(proc.startedAt).toISOString() }
146
+ : {}),
147
+ windowTitle: proc.windowTitle.replace(/ - Roblox Studio$/, ''),
148
+ ...(instanceId ? { instanceId } : {}),
149
+ ...(mcpStudio ? { mcpStudioId: mcpStudio.studioId } : {}),
150
+ ...(launch
151
+ ? {
152
+ launch: {
153
+ cwd: launch.cwd,
154
+ opened: launch.opened,
155
+ launchedAt: new Date(launch.launchedAt).toISOString(),
156
+ },
157
+ }
158
+ : {}),
40
159
  };
160
+ });
161
+
162
+ for (const studio of mcpStudios) {
163
+ if (shown.has(studio.studioId)) continue;
164
+ rows.push({
165
+ windowTitle: studio.placeName || '(loading)',
166
+ ...(studio.instanceId ? { instanceId: studio.instanceId } : {}),
167
+ mcpStudioId: studio.studioId,
168
+ });
41
169
  }
170
+ return rows;
171
+ }
42
172
 
173
+ function describeSummary(
174
+ matching: number,
175
+ connected: number,
176
+ processes: RunningStudioProcess[] | undefined,
177
+ elapsedSec: number | undefined
178
+ ): string {
179
+ if (matching > 0) {
180
+ return `${matching} session(s) connected.`;
181
+ }
182
+ if (connected > 0) {
183
+ return 'No sessions match --target/--context.';
184
+ }
185
+ const waited = elapsedSec !== undefined ? ` (${elapsedSec.toFixed(1)}s)` : '';
186
+ if (!processes) {
187
+ return `No active sessions${waited}. Is Studio running with the studio-bridge plugin?`;
188
+ }
189
+ if (processes.length === 0) {
190
+ return `No Roblox Studio processes are open${waited}.`;
191
+ }
192
+ return `${processes.length} Studio process(es) open, but none has the studio-bridge plugin connected${waited}. Run 'studio-bridge plugin install', and check the plugin is enabled in Studio.`;
193
+ }
194
+
195
+ export function toMcpStudioState(snapshot: StudioMcpSnapshot): McpStudioState {
43
196
  return {
44
- sessions,
45
- summary: `${sessions.length} session(s) connected.`,
197
+ status: snapshot.status,
198
+ ...(snapshot.message ? { message: snapshot.message } : {}),
199
+ latencyMs: snapshot.latencyMs,
200
+ checkedAt: new Date(snapshot.checkedAt).toISOString(),
201
+ ...(snapshot.lastOkAt
202
+ ? { lastOkAt: new Date(snapshot.lastOkAt).toISOString() }
203
+ : {}),
204
+ ...(snapshot.settled === false ? { settled: false } : {}),
46
205
  };
47
206
  }
48
207
 
49
- const sessionColumns: TableColumn<SessionInfo>[] = [
208
+ const sessionColumns: TableColumn<ListedSession>[] = [
50
209
  {
51
210
  header: 'Session',
52
211
  value: (s) => s.sessionId,
@@ -59,25 +218,134 @@ const sessionColumns: TableColumn<SessionInfo>[] = [
59
218
  value: (s) => s.state,
60
219
  format: (v) => colorizeState(v as StudioState),
61
220
  },
221
+ { header: 'PID', value: (s) => (s.pid !== undefined ? String(s.pid) : '') },
62
222
  ];
63
223
 
64
- export function formatSessionsTable(result: SessionsResult): string {
65
- if (result.sessions.length === 0) return result.summary;
66
- return formatTable(result.sessions, sessionColumns);
224
+ function studioColumns(
225
+ withMcp: boolean,
226
+ now: number
227
+ ): TableColumn<StudioProcessRow>[] {
228
+ const columns: TableColumn<StudioProcessRow>[] = [
229
+ {
230
+ header: 'PID',
231
+ value: (s) => (s.pid !== undefined ? String(s.pid) : '?'),
232
+ },
233
+ {
234
+ header: 'Up',
235
+ value: (s) =>
236
+ s.startedAt
237
+ ? formatDurationMs(Math.max(0, now - Date.parse(s.startedAt)))
238
+ : '',
239
+ },
240
+ { header: 'Window', value: (s) => s.windowTitle },
241
+ { header: 'Plugin', value: (s) => s.instanceId ?? 'none' },
242
+ ];
243
+ if (withMcp) {
244
+ columns.push({
245
+ header: 'MCP',
246
+ value: (s) => (s.mcpStudioId ? s.mcpStudioId.slice(0, 8) : 'none'),
247
+ });
248
+ }
249
+ columns.push({
250
+ header: 'Launched',
251
+ value: (s) =>
252
+ s.launch
253
+ ? `${s.launch.opened} from ${s.launch.cwd}`
254
+ : 'not by studio-bridge',
255
+ });
256
+ return columns;
257
+ }
258
+
259
+ export function formatMcpLine(mcp: McpStudioState, now = Date.now()): string {
260
+ const age = formatAgo(Date.parse(mcp.checkedAt), now);
261
+ if (mcp.status === 'ok') {
262
+ const pending =
263
+ mcp.settled === false
264
+ ? ', may be incomplete (Studios take a few seconds to reach a new MCP server)'
265
+ : '';
266
+ return `MCP: ok, answered in ${mcp.latencyMs}ms, checked ${age}${pending}`;
267
+ }
268
+ const lastOk = mcp.lastOkAt
269
+ ? `, last ok ${formatAgo(Date.parse(mcp.lastOkAt), now)}`
270
+ : '';
271
+ return OutputHelper.formatWarning(
272
+ `MCP: ${mcp.status}${
273
+ mcp.message ? ` (${mcp.message})` : ''
274
+ }, checked ${age}${lastOk}`
275
+ );
276
+ }
277
+
278
+ export function formatSessionsTable(
279
+ result: SessionsResult,
280
+ now = Date.now()
281
+ ): string {
282
+ const mcp =
283
+ result.mcp && result.mcp.status !== 'unsupported' ? result.mcp : undefined;
284
+ const lines: string[] = [];
285
+ if (result.sessions.length > 0) {
286
+ lines.push(formatTable(result.sessions, sessionColumns));
287
+ } else {
288
+ lines.push(result.summary);
289
+ }
290
+ if (result.studios && result.studios.length > 0) {
291
+ lines.push('');
292
+ lines.push(
293
+ formatTable(result.studios, studioColumns(mcp?.status === 'ok', now))
294
+ );
295
+ }
296
+ if (mcp) {
297
+ lines.push(formatMcpLine(mcp, now));
298
+ }
299
+ return lines.join('\n');
300
+ }
301
+
302
+ function formatAgo(at: number, now: number): string {
303
+ return `${formatDurationMs(Math.max(0, now - at))} ago`;
67
304
  }
68
305
 
69
- export const listCommand = defineCommand({
306
+ interface ListArgs {
307
+ mcp?: boolean;
308
+ }
309
+
310
+ export const listCommand = defineCommand<ListArgs, SessionsResult>({
70
311
  group: 'process',
71
312
  name: 'list',
72
- description: 'List connected Studio sessions',
313
+ description:
314
+ 'List connected Studio sessions and every Studio process, with where each was launched',
73
315
  category: 'infrastructure',
74
316
  safety: 'read',
75
317
  scope: 'connection',
76
318
  coversAllSessions: true,
77
- args: {},
319
+ args: {
320
+ mcp: arg.flag({
321
+ description:
322
+ "Ask Studio's MCP server too, which also reaches Studios without the plugin (--no-mcp to skip)",
323
+ default: true,
324
+ }),
325
+ },
78
326
  cli: {
79
327
  format: formatSessionsTable,
80
328
  },
81
- handler: async (connection, _args, targeting) =>
82
- listSessionsHandlerAsync(connection, targeting),
329
+ handler: async (connection, args, targeting) =>
330
+ listSessionsHandlerAsync(
331
+ connection,
332
+ targeting,
333
+ connection.isRemote
334
+ ? { elapsedSec: () => process.uptime() }
335
+ : {
336
+ listProcessesAsync: listStudioProcessesAsync,
337
+ resolveSessionPidsAsync: (processes) =>
338
+ resolveSessionPidsAsync(connection, processes),
339
+ readLaunchesAsync: () => readStudioLaunchesAsync(),
340
+ mcpSnapshotAsync: args.mcp
341
+ ? (sessions, sessionPids, processes) =>
342
+ getStudioMcpSnapshotAsync({
343
+ sessions,
344
+ sessionPids,
345
+ windows: processes,
346
+ })
347
+ : undefined,
348
+ elapsedSec: () => process.uptime(),
349
+ }
350
+ ),
83
351
  });
@@ -41,7 +41,7 @@ interface ProcessRunArgs {
41
41
  code?: string;
42
42
  file?: string;
43
43
  place?: string;
44
- timeout?: number;
44
+ scriptTimeout?: number;
45
45
  scriptArgs?: string[];
46
46
  }
47
47
 
@@ -85,8 +85,8 @@ export const processRunCommand = defineCommand<
85
85
  description: 'Path to a .rbxl place file',
86
86
  alias: 'p',
87
87
  }),
88
- timeout: arg.option({
89
- description: 'Execution timeout in milliseconds',
88
+ scriptTimeout: arg.option({
89
+ description: 'Cancel the script after this many milliseconds',
90
90
  type: 'number',
91
91
  }),
92
92
  scriptArgs: arg.passthrough({
@@ -107,7 +107,7 @@ export const processRunCommand = defineCommand<
107
107
  scriptContent,
108
108
  packageName: 'studio-bridge',
109
109
  placePath: args.place,
110
- timeoutMs: args.timeout ?? 120_000,
110
+ timeoutMs: args.scriptTimeout ?? 120_000,
111
111
  verbose: OutputHelper.isVerbose(),
112
112
  showLogs: true,
113
113
  filePath,
@@ -94,7 +94,7 @@ describe('buildDockerRunArgsAsync', () => {
94
94
  const bashCmd = args[args.length - 1];
95
95
  expect(bashCmd).toContain('studio-bridge linux inject-credentials');
96
96
  expect(bashCmd).toContain('studio-bridge process run');
97
- expect(bashCmd).toContain('--timeout 120000');
97
+ expect(bashCmd).toContain('--script-timeout 120000');
98
98
  expect(bashCmd).not.toContain('--verbose');
99
99
  });
100
100
 
@@ -198,7 +198,7 @@ export async function buildDockerRunArgsAsync(
198
198
  'run',
199
199
  '--file',
200
200
  scriptFilePath,
201
- '--timeout',
201
+ '--script-timeout',
202
202
  String(timeoutMs),
203
203
  ];
204
204
 
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ buildPlaytestBridgeAsync,
4
+ wrapPrecompiledSource,
5
+ } from './playtest-bridge.js';
6
+
7
+ describe('wrapPrecompiledSource', () => {
8
+ it('opens the function on the first line', () => {
9
+ const wrapped = wrapPrecompiledSource('return 1');
10
+ expect(wrapped.split('\n')[0]).toBe('return { fn = function(...) return 1');
11
+ });
12
+
13
+ it('uses a long string level the source does not close', () => {
14
+ expect(wrapPrecompiledSource('return "]]"')).toContain('[=[x');
15
+ expect(wrapPrecompiledSource('return "]]" .. "]=]"')).toContain('[==[x');
16
+ });
17
+
18
+ it('does not let a trailing ] close the string early', () => {
19
+ const wrapped = wrapPrecompiledSource('local t = {}\nreturn t[1]');
20
+ expect(wrapped).toContain('[=[x');
21
+ });
22
+
23
+ it('keeps a leading newline in the source text', () => {
24
+ expect(wrapPrecompiledSource('\nreturn 1')).toContain('[[x\nreturn 1]]');
25
+ });
26
+ });
27
+
28
+ describe('buildPlaytestBridgeAsync', () => {
29
+ it('plants the Shared modules with the plugin version filled in', async () => {
30
+ const bridge = await buildPlaytestBridgeAsync();
31
+
32
+ expect(Object.keys(bridge.modules)).toContain('StudioBridgeClient');
33
+ expect(Object.keys(bridge.modules)).toContain('PrecompiledSources');
34
+ expect(Object.keys(bridge.modules).some((n) => n.endsWith('.spec'))).toBe(
35
+ false
36
+ );
37
+ expect(bridge.modules.StudioBridgeSessionUtils).not.toContain(
38
+ '{{PLUGIN_VERSION}}'
39
+ );
40
+ expect(bridge.entrySource).toContain('StudioBridgeClient.start');
41
+ });
42
+
43
+ it('precompiles every action plus the given scripts', async () => {
44
+ const bridge = await buildPlaytestBridgeAsync(['return "mine"']);
45
+
46
+ expect(bridge.precompiled.some((s) => s.includes('return "mine"'))).toBe(
47
+ true
48
+ );
49
+ expect(bridge.precompiled.some((s) => s.includes('ExecuteAction'))).toBe(
50
+ true
51
+ );
52
+ });
53
+ });