@vgai/sdk 0.5.0 → 0.5.2

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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/account.ts +5 -4
  3. package/src/cinematic/capabilities-operations.ts +3 -3
  4. package/src/cinematic/cue-operations.ts +11 -11
  5. package/src/cinematic/gsap-operations.ts +5 -5
  6. package/src/cinematic/index.ts +3 -3
  7. package/src/cinematic/preview-operations.ts +10 -10
  8. package/src/cinematic/preview-transport.ts +2 -2
  9. package/src/cinematic/render-operations.ts +15 -15
  10. package/src/cinematic/render-transport.ts +2 -2
  11. package/src/cinematic/theatre-operations.ts +17 -17
  12. package/src/editor/camera-operations.ts +7 -7
  13. package/src/editor/console-operations.ts +5 -5
  14. package/src/editor/hierarchy-operations.ts +5 -5
  15. package/src/editor/index.ts +3 -3
  16. package/src/editor/open-operations.ts +10 -10
  17. package/src/editor/screenshot-operations.ts +5 -5
  18. package/src/editor/selection-operations.ts +7 -7
  19. package/src/editor/session-operations.ts +3 -3
  20. package/src/editor/source-location-operations.ts +6 -6
  21. package/src/editor/transport.ts +24 -11
  22. package/src/errors.ts +8 -8
  23. package/src/generations.ts +12 -1
  24. package/src/http/http-projection.ts +26 -26
  25. package/src/index.ts +22 -30
  26. package/src/mcp/index.ts +2 -2
  27. package/src/mcp/mcp-projection.ts +25 -25
  28. package/src/mcp/mcp-server.ts +2 -2
  29. package/src/operations.ts +5 -5
  30. package/src/play/control-operations.ts +10 -10
  31. package/src/play/debug-command-operations.ts +12 -12
  32. package/src/play/index.ts +3 -3
  33. package/src/play/input-operations.ts +10 -10
  34. package/src/play/lifecycle-operations.ts +11 -11
  35. package/src/play/log-operations.ts +7 -7
  36. package/src/play/run-ticks-operations.ts +8 -8
  37. package/src/play/state-operations.ts +9 -9
  38. package/src/play/status-operations.ts +7 -7
  39. package/src/play/transport.ts +93 -22
  40. package/src/project/asset-operations.ts +8 -8
  41. package/src/project/component-operations.ts +10 -10
  42. package/src/project/discovery-operations.ts +9 -9
  43. package/src/project/entity-operations.ts +8 -8
  44. package/src/project/index.ts +4 -4
  45. package/src/project/input-map-operations.ts +9 -9
  46. package/src/project/inspection-operation.ts +3 -3
  47. package/src/project/manifest-operations.ts +9 -9
  48. package/src/project/scene-operations.ts +14 -14
  49. package/src/project/shared.ts +17 -17
  50. package/src/project-tool-catalog.ts +21 -5
  51. package/src/registry.ts +39 -39
  52. package/src/render/render-cinematic.ts +24 -1
  53. package/src/tools.ts +19 -12
  54. package/src/types.ts +4 -4
package/src/play/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * B4 — `play.*` operations
3
3
  * (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B4). Registered
4
- * separately from B1's `registerBuiltinOperations` (`../operations.ts`),
4
+ * separately from B1's `registerBuiltinTools` (`../operations.ts`),
5
5
  * B2's `registerProjectOperations` (`../project/index.ts`), and B3's
6
6
  * `registerEditorOperations` (`../editor/index.ts`) — the default
7
7
  * `operations` singleton (`../index.ts`) calls all four.
@@ -43,7 +43,7 @@ export {
43
43
  withPlayTimeout,
44
44
  } from './transport.js';
45
45
 
46
- import type { OperationRegistry } from '../registry.js';
46
+ import type { ToolRegistry } from '../registry.js';
47
47
  import { registerControlOperations } from './control-operations.js';
48
48
  import { registerDebugCommandOperations } from './debug-command-operations.js';
49
49
  import { registerInputOperations } from './input-operations.js';
@@ -54,7 +54,7 @@ import { registerStateOperations } from './state-operations.js';
54
54
  import { registerStatusOperations } from './status-operations.js';
55
55
 
56
56
  /** Register every B4 `play.*` operation onto `registry`. */
57
- export function registerPlayOperations(registry: OperationRegistry): void {
57
+ export function registerPlayOperations(registry: ToolRegistry): void {
58
58
  registerLifecycleOperations(registry);
59
59
  registerStatusOperations(registry);
60
60
  registerLogOperations(registry);
@@ -58,8 +58,8 @@
58
58
  */
59
59
 
60
60
  import { z } from 'zod';
61
- import { OperationError } from '../errors.js';
62
- import { defineOperation, type OperationRegistry } from '../registry.js';
61
+ import { ToolError } from '../errors.js';
62
+ import { defineTool, type ToolRegistry } from '../registry.js';
63
63
  import type { InputInjectionRequest, PlayCommandResult } from './transport.js';
64
64
  import {
65
65
  getPlayTransport,
@@ -205,22 +205,22 @@ function throwForInjectFailure(result: PlayCommandResult): never {
205
205
  const marker = relayErrorMarker(result);
206
206
  const message = result.error ?? 'inject-input command failed';
207
207
  if (marker?.code === 'INPUT_ACTION_NOT_FOUND') {
208
- throw new OperationError('INPUT_ACTION_NOT_FOUND', message, {
208
+ throw new ToolError('INPUT_ACTION_NOT_FOUND', message, {
209
209
  registered: relayStringArray(marker.data['registered']),
210
210
  });
211
211
  }
212
212
  if (marker?.code === 'TICK_ALREADY_PASSED') {
213
- throw new OperationError('TICK_ALREADY_PASSED', message, {
213
+ throw new ToolError('TICK_ALREADY_PASSED', message, {
214
214
  currentTick: typeof marker.data['currentTick'] === 'number' ? marker.data['currentTick'] : 0,
215
215
  });
216
216
  }
217
217
  if (marker?.code === 'DEBUG_INPUT_WORLD_NOT_FOUND') {
218
- throw new OperationError('INPUT_WORLD_NOT_FOUND', message, {
218
+ throw new ToolError('INPUT_WORLD_NOT_FOUND', message, {
219
219
  worldId: typeof marker.data['worldId'] === 'string' ? marker.data['worldId'] : '',
220
220
  registered: relayStringArray(marker.data['registered']),
221
221
  });
222
222
  }
223
- throw new OperationError('COMMAND_FAILED', message, { message });
223
+ throw new ToolError('COMMAND_FAILED', message, { message });
224
224
  }
225
225
 
226
226
  const PlayInputInjectResult = z.object({
@@ -234,7 +234,7 @@ const PlayInputInjectResult = z.object({
234
234
  ),
235
235
  });
236
236
 
237
- export const playInputInject = defineOperation({
237
+ export const playInputInject = defineTool({
238
238
  name: 'play.input.inject',
239
239
  summary: 'Inject synthetic input into a running play session through the normal action path.',
240
240
  description:
@@ -289,7 +289,7 @@ export const playInputInject = defineOperation({
289
289
  'play.input.inject',
290
290
  ).catch(() => undefined);
291
291
  if (!result) {
292
- throw new OperationError(
292
+ throw new ToolError(
293
293
  'INPUT_INJECTION_UNSUPPORTED',
294
294
  'The connected editor page predates the inject-input relay command — reload it.',
295
295
  {},
@@ -303,7 +303,7 @@ export const playInputInject = defineOperation({
303
303
  | undefined;
304
304
  if (outcome?.delivered === false) {
305
305
  const reason = outcome.reason ?? 'input-gated';
306
- throw new OperationError('INPUT_GATED', reason, { reason });
306
+ throw new ToolError('INPUT_GATED', reason, { reason });
307
307
  }
308
308
  return outcome?.scheduled === true
309
309
  ? { ok: true as const, scheduled: true }
@@ -311,6 +311,6 @@ export const playInputInject = defineOperation({
311
311
  },
312
312
  });
313
313
 
314
- export function registerInputOperations(registry: OperationRegistry): void {
314
+ export function registerInputOperations(registry: ToolRegistry): void {
315
315
  registry.register(playInputInject);
316
316
  }
@@ -35,8 +35,8 @@
35
35
  */
36
36
 
37
37
  import { z } from 'zod';
38
- import { OperationError } from '../errors.js';
39
- import { defineOperation, type OperationRegistry } from '../registry.js';
38
+ import { ToolError } from '../errors.js';
39
+ import { defineTool, type ToolRegistry } from '../registry.js';
40
40
  import {
41
41
  getPlayTransport,
42
42
  PLAY_COMMAND_TIMEOUT_MS,
@@ -74,7 +74,7 @@ async function relayAndCheck(
74
74
  error: err instanceof Error ? err.message : String(err),
75
75
  }));
76
76
  if (!result.ok) {
77
- throw new OperationError('COMMAND_FAILED', result.error ?? `${op} command failed`, {
77
+ throw new ToolError('COMMAND_FAILED', result.error ?? `${op} command failed`, {
78
78
  message: result.error ?? `${op} command failed`,
79
79
  });
80
80
  }
@@ -106,7 +106,7 @@ const PlayStartInput = z.object({
106
106
  ),
107
107
  });
108
108
 
109
- export const playStart = defineOperation({
109
+ export const playStart = defineTool({
110
110
  name: 'play.start',
111
111
  summary: 'Start (or restart) play mode in a connected editor session and wait for readiness.',
112
112
  description:
@@ -137,7 +137,7 @@ export const playStart = defineOperation({
137
137
  );
138
138
  } catch (err) {
139
139
  if (err instanceof PlayTimeoutError) {
140
- throw new OperationError(
140
+ throw new ToolError(
141
141
  'PLAY_START_TIMEOUT',
142
142
  `The runtime did not report readiness within ${timeoutMs}ms.`,
143
143
  { timeoutMs },
@@ -146,7 +146,7 @@ export const playStart = defineOperation({
146
146
  throw err;
147
147
  }
148
148
  if (!result.ok) {
149
- throw new OperationError('COMMAND_FAILED', result.error ?? 'play command failed', {
149
+ throw new ToolError('COMMAND_FAILED', result.error ?? 'play command failed', {
150
150
  message: result.error ?? 'play command failed',
151
151
  });
152
152
  }
@@ -160,7 +160,7 @@ export const playStart = defineOperation({
160
160
 
161
161
  const NoInput = z.object({});
162
162
 
163
- export const playPause = defineOperation({
163
+ export const playPause = defineTool({
164
164
  name: 'play.pause',
165
165
  summary: 'Pause the running play session in a connected editor.',
166
166
  description: 'Relays {type:"pause"} through POST /__editor/command (already-handled case).',
@@ -184,7 +184,7 @@ export const playPause = defineOperation({
184
184
  },
185
185
  });
186
186
 
187
- export const playResume = defineOperation({
187
+ export const playResume = defineTool({
188
188
  name: 'play.resume',
189
189
  summary: 'Resume a paused play session in a connected editor.',
190
190
  description: 'Relays {type:"resume"} through POST /__editor/command (already-handled case).',
@@ -208,7 +208,7 @@ export const playResume = defineOperation({
208
208
  },
209
209
  });
210
210
 
211
- export const playStop = defineOperation({
211
+ export const playStop = defineTool({
212
212
  name: 'play.stop',
213
213
  summary: 'Stop play mode in a connected editor and clean up the launched runtime.',
214
214
  description:
@@ -238,7 +238,7 @@ export const playStop = defineOperation({
238
238
  },
239
239
  });
240
240
 
241
- export const playFrameStep = defineOperation({
241
+ export const playFrameStep = defineTool({
242
242
  name: 'play.frameStep',
243
243
  summary: 'Advance a paused play session by exactly one frame.',
244
244
  description: 'Relays {type:"step"} through POST /__editor/command (already-handled case).',
@@ -262,7 +262,7 @@ export const playFrameStep = defineOperation({
262
262
  },
263
263
  });
264
264
 
265
- export function registerLifecycleOperations(registry: OperationRegistry): void {
265
+ export function registerLifecycleOperations(registry: ToolRegistry): void {
266
266
  registry.register(playStart);
267
267
  registry.register(playPause);
268
268
  registry.register(playResume);
@@ -43,7 +43,7 @@
43
43
  import { readdirSync } from 'node:fs';
44
44
  import { join } from 'node:path';
45
45
  import { z } from 'zod';
46
- import { OperationError } from '../errors.js';
46
+ import { ToolError } from '../errors.js';
47
47
  import {
48
48
  FILE_NOT_FOUND_ERROR,
49
49
  NO_PROJECT_ROOT_ERROR,
@@ -52,7 +52,7 @@ import {
52
52
  requireProjectRoot,
53
53
  resolveProjectPath,
54
54
  } from '../project/shared.js';
55
- import { defineOperation, type OperationRegistry } from '../registry.js';
55
+ import { defineTool, type ToolRegistry } from '../registry.js';
56
56
  import {
57
57
  getPlayTransport,
58
58
  PLAY_READ_TIMEOUT_MS,
@@ -81,7 +81,7 @@ const PlayLogDiscoverResult = z.object({
81
81
  .describe('Filenames (e.g. "play-2026-07-11T10-00-00.jsonl"), oldest first (name-sorted).'),
82
82
  });
83
83
 
84
- export const playLogDiscover = defineOperation({
84
+ export const playLogDiscover = defineTool({
85
85
  name: 'play.log.discover',
86
86
  summary: "List every persisted play-mode log file under the project's logs/ directory.",
87
87
  description:
@@ -189,7 +189,7 @@ function parseLogLine(
189
189
  };
190
190
  }
191
191
 
192
- export const playLogRead = defineOperation({
192
+ export const playLogRead = defineTool({
193
193
  name: 'play.log.read',
194
194
  summary: 'Read entries from a persisted play-mode log file.',
195
195
  description:
@@ -238,7 +238,7 @@ const PlayLogFollowResult = z.object({
238
238
  .describe('Live count of entries buffered for the active play session.'),
239
239
  });
240
240
 
241
- export const playLogFollow = defineOperation({
241
+ export const playLogFollow = defineTool({
242
242
  name: 'play.log.follow',
243
243
  summary: "Describe how to poll-follow the currently active play session's log.",
244
244
  description:
@@ -262,7 +262,7 @@ export const playLogFollow = defineOperation({
262
262
  'play.log.follow',
263
263
  ).catch(() => undefined);
264
264
  if (!metadata) {
265
- throw new OperationError(
265
+ throw new ToolError(
266
266
  'PLAY_LOG_FOLLOW_UNAVAILABLE',
267
267
  'The connected editor session did not answer the log-entries read in time.',
268
268
  {},
@@ -272,7 +272,7 @@ export const playLogFollow = defineOperation({
272
272
  },
273
273
  });
274
274
 
275
- export function registerLogOperations(registry: OperationRegistry): void {
275
+ export function registerLogOperations(registry: ToolRegistry): void {
276
276
  registry.register(playLogDiscover);
277
277
  registry.register(playLogRead);
278
278
  registry.register(playLogFollow);
@@ -20,8 +20,8 @@
20
20
  */
21
21
 
22
22
  import { z } from 'zod';
23
- import { OperationError } from '../errors.js';
24
- import { defineOperation, type OperationRegistry } from '../registry.js';
23
+ import { ToolError } from '../errors.js';
24
+ import { defineTool, type ToolRegistry } from '../registry.js';
25
25
  import {
26
26
  getPlayTransport,
27
27
  PLAY_COMMAND_TIMEOUT_MS,
@@ -81,7 +81,7 @@ const PlayRunTicksInput = z.object({
81
81
 
82
82
  const PlayRunTicksResult = z.object({ ok: z.literal(true) });
83
83
 
84
- export const playRunTicks = defineOperation({
84
+ export const playRunTicks = defineTool({
85
85
  name: 'play.runTicks',
86
86
  summary: 'Synchronously fast-forward a running play session by n fixed gameplay ticks.',
87
87
  description:
@@ -115,7 +115,7 @@ export const playRunTicks = defineOperation({
115
115
  'play.runTicks',
116
116
  ).catch(() => undefined);
117
117
  if (!result) {
118
- throw new OperationError(
118
+ throw new ToolError(
119
119
  'RUN_TICKS_UNSUPPORTED',
120
120
  'The connected editor page predates the run-ticks relay command — reload it.',
121
121
  {},
@@ -125,17 +125,17 @@ export const playRunTicks = defineOperation({
125
125
  const marker = relayErrorMarker(result);
126
126
  const message = result.error ?? 'run-ticks command failed';
127
127
  if (marker?.code === 'RUN_TICKS_UNAVAILABLE') {
128
- throw new OperationError('RUN_TICKS_UNAVAILABLE', message, {});
128
+ throw new ToolError('RUN_TICKS_UNAVAILABLE', message, {});
129
129
  }
130
130
  if (marker?.code === 'RUN_TICKS_PAUSED') {
131
- throw new OperationError('RUN_TICKS_PAUSED', message, {});
131
+ throw new ToolError('RUN_TICKS_PAUSED', message, {});
132
132
  }
133
- throw new OperationError('COMMAND_FAILED', message, { message });
133
+ throw new ToolError('COMMAND_FAILED', message, { message });
134
134
  }
135
135
  return { ok: true as const };
136
136
  },
137
137
  });
138
138
 
139
- export function registerRunTicksOperations(registry: OperationRegistry): void {
139
+ export function registerRunTicksOperations(registry: ToolRegistry): void {
140
140
  registry.register(playRunTicks);
141
141
  }
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { z } from 'zod';
24
- import { OperationError } from '../errors.js';
25
- import { defineOperation, type OperationRegistry } from '../registry.js';
24
+ import { ToolError } from '../errors.js';
25
+ import { defineTool, type ToolRegistry } from '../registry.js';
26
26
  import {
27
27
  getPlayTransport,
28
28
  PLAY_READ_TIMEOUT_MS,
@@ -61,14 +61,14 @@ const COMMAND_FAILED_ERROR = {
61
61
  function throwStateRelayFailure(result: PlayCommandResult, label: string): never {
62
62
  const marker = relayErrorMarker(result);
63
63
  if (marker?.code === 'STATE_PROVIDER_NOT_FOUND') {
64
- throw new OperationError(
64
+ throw new ToolError(
65
65
  'STATE_PROVIDER_NOT_FOUND',
66
66
  result.error ?? 'state provider not found',
67
67
  { registered: relayStringArray(marker.data['registered']) },
68
68
  );
69
69
  }
70
70
  const message = result.error ?? `${label} command failed`;
71
- throw new OperationError('COMMAND_FAILED', message, { message });
71
+ throw new ToolError('COMMAND_FAILED', message, { message });
72
72
  }
73
73
 
74
74
  // ---------------------------------------------------------------------------
@@ -92,7 +92,7 @@ const PlayGameplayStateInspectResult = z.object({
92
92
  .describe('The game-provided state snapshot, or null when the game exposes none.'),
93
93
  });
94
94
 
95
- export const playGameplayStateInspect = defineOperation({
95
+ export const playGameplayStateInspect = defineTool({
96
96
  name: 'play.gameplayState.inspect',
97
97
  summary: 'Inspect game-registered state providers from a running play session.',
98
98
  description:
@@ -126,7 +126,7 @@ export const playGameplayStateInspect = defineOperation({
126
126
  'play.gameplayState.inspect',
127
127
  ).catch(() => undefined);
128
128
  if (result === undefined) {
129
- throw new OperationError(
129
+ throw new ToolError(
130
130
  'GAMEPLAY_STATE_UNSUPPORTED',
131
131
  'The connected editor page predates the gameplay-state relay commands — reload it.',
132
132
  {},
@@ -158,7 +158,7 @@ const PlayGameplayStateListResult = z.object({
158
158
  .describe('Every registered state provider of the running game, with its honesty tier.'),
159
159
  });
160
160
 
161
- export const playGameplayStateList = defineOperation({
161
+ export const playGameplayStateList = defineTool({
162
162
  name: 'play.gameplayState.list',
163
163
  summary: 'List the state providers a running play session registers, with honesty tiers.',
164
164
  description:
@@ -190,7 +190,7 @@ export const playGameplayStateList = defineOperation({
190
190
  'play.gameplayState.list',
191
191
  ).catch(() => undefined);
192
192
  if (result === undefined) {
193
- throw new OperationError(
193
+ throw new ToolError(
194
194
  'GAMEPLAY_STATE_UNSUPPORTED',
195
195
  'The connected editor page predates the gameplay-state relay commands — reload it.',
196
196
  {},
@@ -204,7 +204,7 @@ export const playGameplayStateList = defineOperation({
204
204
  },
205
205
  });
206
206
 
207
- export function registerStateOperations(registry: OperationRegistry): void {
207
+ export function registerStateOperations(registry: ToolRegistry): void {
208
208
  registry.register(playGameplayStateInspect);
209
209
  registry.register(playGameplayStateList);
210
210
  }
@@ -20,8 +20,8 @@
20
20
  */
21
21
 
22
22
  import { z } from 'zod';
23
- import { OperationError } from '../errors.js';
24
- import { defineOperation, type OperationRegistry } from '../registry.js';
23
+ import { ToolError } from '../errors.js';
24
+ import { defineTool, type ToolRegistry } from '../registry.js';
25
25
  import {
26
26
  getPlayTransport,
27
27
  PLAY_READ_TIMEOUT_MS,
@@ -73,7 +73,7 @@ const PlayStatusResult = z.object({
73
73
  .describe('Currently applied simulation time-scale, or null when never successfully set.'),
74
74
  });
75
75
 
76
- export const playStatus = defineOperation({
76
+ export const playStatus = defineTool({
77
77
  name: 'play.status',
78
78
  summary: 'Read the current play-mode status (running/paused/stopped, seed, time-scale).',
79
79
  description:
@@ -97,7 +97,7 @@ export const playStatus = defineOperation({
97
97
  'play.status',
98
98
  ).catch(() => undefined);
99
99
  if (!status) {
100
- throw new OperationError(
100
+ throw new ToolError(
101
101
  'PLAY_STATUS_UNAVAILABLE',
102
102
  'The connected editor session did not answer the status read in time.',
103
103
  {},
@@ -120,7 +120,7 @@ const PlayActiveWorldInspectResult = z.object({
120
120
  worldKind: z.string(),
121
121
  });
122
122
 
123
- export const playActiveWorldInspect = defineOperation({
123
+ export const playActiveWorldInspect = defineTool({
124
124
  name: 'play.activeWorld.inspect',
125
125
  summary: 'Inspect the active world of a running play session (id/kind).',
126
126
  description:
@@ -144,7 +144,7 @@ export const playActiveWorldInspect = defineOperation({
144
144
  'play.activeWorld.inspect',
145
145
  ).catch(() => undefined);
146
146
  if (!world) {
147
- throw new OperationError(
147
+ throw new ToolError(
148
148
  'ACTIVE_WORLD_UNAVAILABLE',
149
149
  "The connected editor's wire protocol does not carry active-world data today.",
150
150
  {},
@@ -154,7 +154,7 @@ export const playActiveWorldInspect = defineOperation({
154
154
  },
155
155
  });
156
156
 
157
- export function registerStatusOperations(registry: OperationRegistry): void {
157
+ export function registerStatusOperations(registry: ToolRegistry): void {
158
158
  registry.register(playStatus);
159
159
  registry.register(playActiveWorldInspect);
160
160
  }
@@ -78,9 +78,9 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
78
78
  import { homedir } from 'node:os';
79
79
  import { join, resolve } from 'node:path';
80
80
  import { z } from 'zod';
81
- import { OperationError } from '../errors.js';
82
- import type { ErrorDefinition } from '../registry.js';
83
- import type { OperationContext } from '../types.js';
81
+ import { ToolError } from '../errors.js';
82
+ import type { ToolErrorDefinition } from '../registry.js';
83
+ import type { ToolContext } from '../types.js';
84
84
 
85
85
  // ---------------------------------------------------------------------------
86
86
  // Named timeouts
@@ -92,8 +92,35 @@ export const PLAY_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
92
92
  export const PLAY_PROBE_TIMEOUT_MS = 1500;
93
93
  /** Bound on a plain read (status, log-entries count, ...). */
94
94
  export const PLAY_READ_TIMEOUT_MS = 3000;
95
- /** Bound on a relayed pause/resume/stop/frame-step command — mirrors the relay's generic `COMMAND_TIMEOUT_MS` (editor-server.ts). */
96
- export const PLAY_COMMAND_TIMEOUT_MS = 5000;
95
+ /**
96
+ * Bound on a relayed pause/resume/stop/frame-step command.
97
+ *
98
+ * The relay's own generic window is `COMMAND_TIMEOUT_MS = 5000`
99
+ * (`packages/editor/server/editor-server.ts`), and this bound must sit ABOVE
100
+ * it for exactly the reason `PLAY_START_TIMEOUT_MS` documents below: the
101
+ * client's clock starts before the request has even reached the server, so a
102
+ * client bound EQUAL to the server's can only ever fire first. It did. A
103
+ * command the editor merely answered slowly came back as
104
+ * `AbortSignal.timeout` → `postJson` returning undefined → "Command relay
105
+ * unreachable." — which reads as "no editor is running" and sends the caller
106
+ * to restart a perfectly healthy session. The server's own honest verdict
107
+ * ("editor connected but did not respond", `timedOut: true`) could never be
108
+ * delivered, because nobody was still listening when it was written.
109
+ *
110
+ * Caught by 39-play-sdk-live's AC-A2.3/AC-A2.5, where every
111
+ * `play.gameplayState.inspect` poll reported an unreachable relay while
112
+ * `play.input.inject` on the same session had just succeeded.
113
+ *
114
+ * 8000 was not enough margin. The server's generic window is 5000
115
+ * (`COMMAND_TIMEOUT_MS`), so 8000 left 3s to cover the request reaching the
116
+ * server, the SSE hop to the browser, the result hop back, and the response —
117
+ * and on a loaded machine that is not enough: the 2026-07-27 nightly saw the
118
+ * same misleading "Command relay unreachable." on a session that was fine.
119
+ * Waiting longer costs a HEALTHY call nothing (it returns as soon as the
120
+ * browser answers); the bound exists only to cap the pathological path, and
121
+ * on that path the server's own verdict is the message worth having.
122
+ */
123
+ export const PLAY_COMMAND_TIMEOUT_MS = 20_000;
97
124
  /**
98
125
  * Bound on `play.start`. Mirrors the real relay's own
99
126
  * `PLAY_COMMAND_TIMEOUT_MS = 120_000` (`packages/editor/server/editor-server.ts:385`)
@@ -145,6 +172,8 @@ export interface PlaySessionInfo {
145
172
  port: number;
146
173
  project: string | null;
147
174
  pid: number | null;
175
+ /** Registry discriminator. `kind: 'e2e'` is standalone and cannot host editor-relayed play control. */
176
+ kind?: string;
148
177
  /** Exact explicitly targeted editor origin/base URL, including protocol and host. */
149
178
  url?: string;
150
179
  }
@@ -364,6 +393,7 @@ interface RegistrySessionEntry {
364
393
  port: number;
365
394
  pid: number;
366
395
  startedAt: string;
396
+ kind?: string;
367
397
  }
368
398
 
369
399
  function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
@@ -373,7 +403,8 @@ function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
373
403
  (typeof s['project'] === 'string' || s['project'] === null) &&
374
404
  typeof s['port'] === 'number' &&
375
405
  typeof s['pid'] === 'number' &&
376
- typeof s['startedAt'] === 'string'
406
+ typeof s['startedAt'] === 'string' &&
407
+ (s['kind'] === undefined || typeof s['kind'] === 'string')
377
408
  );
378
409
  }
379
410
 
@@ -393,7 +424,10 @@ function readRegisteredSessions(): RegistrySessionEntry[] {
393
424
  try {
394
425
  const raw: unknown = JSON.parse(readFileSync(registryFile, 'utf8'));
395
426
  return Array.isArray(raw)
396
- ? raw.filter(isRegistrySessionEntry).filter((s) => pidAlive(s.pid))
427
+ ? raw
428
+ .filter(isRegistrySessionEntry)
429
+ .filter((s) => s.kind !== 'e2e')
430
+ .filter((s) => pidAlive(s.pid))
397
431
  : [];
398
432
  } catch {
399
433
  return [];
@@ -410,11 +444,39 @@ async function fetchJson(url: string, timeoutMs: number): Promise<unknown | unde
410
444
  }
411
445
  }
412
446
 
413
- async function postJson(
414
- url: string,
415
- body: unknown,
416
- timeoutMs: number,
417
- ): Promise<{ status: number; json: unknown } | undefined> {
447
+ /** Either a response came back, or it didn't and `reason` says why.
448
+ *
449
+ * This used to be `… | undefined`, and the single caller turned every
450
+ * `undefined` into the same sentence: "Command relay unreachable." That
451
+ * sentence names a CONNECTION fault, but the `catch` it came from covers a
452
+ * refused socket, a DNS failure, an aborted request, and a body that never
453
+ * arrived — four different diagnoses wearing one message. The cost is on the
454
+ * record: the 2026-07-27 nightly reported an unreachable relay, the message
455
+ * read as "no editor is running", the fix went into timeout constants
456
+ * (`PLAY_COMMAND_TIMEOUT_MS`, twice), and the run stayed red because the
457
+ * actual cause was never in the log. The relay's SERVER always answers within
458
+ * its per-type window with an explicit verdict (`relayCommand`,
459
+ * editor-server.ts) — so when nothing comes back at all, WHICH transport
460
+ * fault it was is the entire diagnosis, and it is the one thing we were
461
+ * throwing away. */
462
+ type PostOutcome =
463
+ | { delivered: true; status: number; json: unknown }
464
+ | { delivered: false; reason: string };
465
+
466
+ /** Turn a `fetch` rejection into something a caller can act on. Undici hangs
467
+ * the useful part off `cause.code` (`ECONNREFUSED`, `ENOTFOUND`, …) while the
468
+ * abort path shows up as the error's own `name`. */
469
+ function describeFetchFailure(error: unknown, timeoutMs: number): string {
470
+ const err = error as { name?: string; message?: string; cause?: { code?: string } };
471
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
472
+ return `no response within ${timeoutMs}ms`;
473
+ }
474
+ const code = err?.cause?.code;
475
+ if (typeof code === 'string') return code;
476
+ return err?.message ?? String(error);
477
+ }
478
+
479
+ async function postJson(url: string, body: unknown, timeoutMs: number): Promise<PostOutcome> {
418
480
  try {
419
481
  const res = await fetch(url, {
420
482
  method: 'POST',
@@ -423,9 +485,9 @@ async function postJson(
423
485
  signal: AbortSignal.timeout(timeoutMs),
424
486
  });
425
487
  const json = await res.json().catch(() => undefined);
426
- return { status: res.status, json };
427
- } catch {
428
- return undefined;
488
+ return { delivered: true, status: res.status, json };
489
+ } catch (error) {
490
+ return { delivered: false, reason: describeFetchFailure(error, timeoutMs) };
429
491
  }
430
492
  }
431
493
 
@@ -438,8 +500,11 @@ async function relayCommand(
438
500
  command: Record<string, unknown>,
439
501
  timeoutMs: number,
440
502
  ): Promise<PlayCommandResult> {
441
- const result = await postJson(`${baseUrl(session)}/__editor/command`, command, timeoutMs);
442
- if (!result) return { ok: false, error: 'Command relay unreachable.' };
503
+ const target = `${baseUrl(session)}/__editor/command`;
504
+ const result = await postJson(target, command, timeoutMs);
505
+ if (!result.delivered) {
506
+ return { ok: false, error: `Command relay unreachable (${target}): ${result.reason}.` };
507
+ }
443
508
  const json = result.json as Record<string, unknown> | undefined;
444
509
  // `commandResponseFor` (editor server-utils.ts) SPREADS the browser
445
510
  // handler's `data` into the response body at the top level, beside
@@ -498,7 +563,12 @@ export class HttpPlayTransport implements PlayTransport {
498
563
  );
499
564
  if (body === undefined) return undefined;
500
565
  const project = (body as { project?: { path?: string } | null }).project?.path ?? null;
501
- const info: PlaySessionInfo = { port: s.port, project, pid: s.pid };
566
+ const info: PlaySessionInfo = {
567
+ port: s.port,
568
+ project,
569
+ pid: s.pid,
570
+ ...(s.kind !== undefined ? { kind: s.kind } : {}),
571
+ };
502
572
  return info;
503
573
  }),
504
574
  );
@@ -673,7 +743,7 @@ export class HttpPlayTransport implements PlayTransport {
673
743
  const defaultTransport = new HttpPlayTransport();
674
744
 
675
745
  /** Resolve the transport to use: `ctx['playTransport']` when injected (tests), else the shared real transport. */
676
- export function getPlayTransport(ctx: OperationContext): PlayTransport {
746
+ export function getPlayTransport(ctx: ToolContext): PlayTransport {
677
747
  const injected = ctx['playTransport'];
678
748
  return (injected as PlayTransport | undefined) ?? defaultTransport;
679
749
  }
@@ -683,7 +753,7 @@ export function getPlayTransport(ctx: OperationContext): PlayTransport {
683
753
  // + deterministic session resolution
684
754
  // ---------------------------------------------------------------------------
685
755
 
686
- export const PLAY_RUNTIME_NOT_AVAILABLE_ERROR: ErrorDefinition = {
756
+ export const PLAY_RUNTIME_NOT_AVAILABLE_ERROR: ToolErrorDefinition = {
687
757
  code: 'EDITOR_NOT_RUNNING',
688
758
  summary:
689
759
  'No live, responsive editor session is available to host a play runtime (none running, ' +
@@ -717,12 +787,12 @@ function portOf(url: string): number | undefined {
717
787
  * > `ctx.projectRoot` match > lowest live port > `EDITOR_NOT_RUNNING`.
718
788
  */
719
789
  export async function resolvePlaySession(
720
- ctx: OperationContext,
790
+ ctx: ToolContext,
721
791
  transport: Pick<PlayTransport, 'listSessions' | 'probeSessionUrl'>,
722
792
  ): Promise<PlaySessionInfo> {
723
793
  const editorUrl = ctx.editorUrl;
724
794
  const notRunning = (): never => {
725
- throw new OperationError('EDITOR_NOT_RUNNING', 'No editor connected to host a play runtime.', {
795
+ throw new ToolError('EDITOR_NOT_RUNNING', 'No editor connected to host a play runtime.', {
726
796
  ...(editorUrl !== undefined ? { editorUrl } : {}),
727
797
  });
728
798
  };
@@ -749,6 +819,7 @@ export async function resolvePlaySession(
749
819
  } catch {
750
820
  return notRunning();
751
821
  }
822
+ sessions = sessions.filter((session) => session.kind !== 'e2e');
752
823
  if (sessions.length === 0) return notRunning();
753
824
 
754
825
  if (editorUrl !== undefined) {