@beremaran/godot-agent-loop 1.0.1 → 1.1.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 (41) hide show
  1. package/README.md +59 -34
  2. package/addons/godot_agent_loop/README.md +26 -16
  3. package/addons/godot_agent_loop/plugin.cfg +3 -2
  4. package/addons/godot_agent_loop/plugin.gd +840 -25
  5. package/agent-plugin/.claude-plugin/plugin.json +1 -1
  6. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  7. package/agent-plugin/.mcp.json +1 -1
  8. package/agent-plugin/adapter-manifest.json +3 -3
  9. package/agent-plugin/pi/extension.ts +1 -1
  10. package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
  11. package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
  12. package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
  13. package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
  14. package/build/authoring-session-manager.js +24 -1
  15. package/build/domain-tool-registries.js +5 -1
  16. package/build/editor-authoring-router.js +228 -0
  17. package/build/editor-bridge-protocol.js +2 -1
  18. package/build/editor-connection.js +29 -24
  19. package/build/editor-mutation-guard.js +3 -1
  20. package/build/editor-plugin-installer.js +2 -1
  21. package/build/editor-session-registry.js +392 -0
  22. package/build/editor-sync-queue.js +135 -0
  23. package/build/index.js +208 -29
  24. package/build/lifecycle-trace.js +80 -0
  25. package/build/scripts/mcp_editor_plugin.gd +840 -25
  26. package/build/scripts/mcp_interaction_server.gd +76 -2
  27. package/build/scripts/mcp_runtime/core_domain.gd +2 -1
  28. package/build/scripts/mcp_runtime/system_domain.gd +2 -0
  29. package/build/server-instructions.js +5 -5
  30. package/build/session-timing.js +17 -1
  31. package/build/tool-definitions.js +99 -6
  32. package/build/tool-handlers/game-tool-handlers.js +21 -4
  33. package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
  34. package/build/tool-handlers/project-handler-services.js +45 -6
  35. package/build/tool-handlers/project-tool-handlers.js +4 -4
  36. package/build/tool-manifest.js +29 -1
  37. package/build/tool-mutation-policy.js +1 -0
  38. package/build/tool-surface.js +4 -4
  39. package/build/utils.js +2 -2
  40. package/package.json +4 -3
  41. package/product.json +7 -5
@@ -4,15 +4,30 @@ import { existsSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { execFile } from 'child_process';
6
6
  import { promisify } from 'util';
7
- import { createErrorResponse, errorMessage, normalizeParameters, validatePath } from '../utils.js';
7
+ import { convertCamelToSnakeCase, createErrorResponse, errorMessage, normalizeParameters, validatePath } from '../utils.js';
8
8
  import { GODOT_VERSION_OPTIONS } from '../godot-subprocess.js';
9
- import { deterministicSessionArguments, deterministicSessionEnvironment } from '../session-timing.js';
9
+ import { deterministicSessionArguments, deterministicSessionEnvironment, realtimeSessionArguments, realtimeSessionEnvironment, timingPolicy, } from '../session-timing.js';
10
10
  import { describeCatalogTool, searchToolCatalog } from '../tool-surface.js';
11
11
  const execFileAsync = promisify(execFile);
12
+ const SCENARIO_INPUT_TOOLS = new Set([
13
+ 'game_key_press', 'game_key_hold', 'game_key_release', 'game_click', 'game_mouse_move',
14
+ 'game_scroll', 'game_mouse_drag', 'game_gamepad', 'game_input_action',
15
+ ]);
16
+ const SCENARIO_OBSERVE_TOOLS = new Set([
17
+ 'game_get_scene_tree', 'game_get_ui', 'game_get_node_info', 'game_get_property',
18
+ 'game_get_errors', 'game_get_logs', 'game_get_camera', 'game_get_audio', 'game_performance',
19
+ ]);
20
+ function waitSuccess(condition, startedAt, attempts, observed) {
21
+ return { satisfied: true, condition, elapsed_ms: Date.now() - startedAt, attempts, last_observed: observed };
22
+ }
23
+ function sameJson(left, right) {
24
+ return JSON.stringify(left) === JSON.stringify(right);
25
+ }
12
26
  /** Implements editor launch, project runtime, and Godot version tools. */
13
27
  export class LifecycleToolHandlers {
14
28
  context;
15
29
  processGeneration = 0;
30
+ editorEnsureTails = new Map();
16
31
  constructor(context) {
17
32
  this.context = context;
18
33
  }
@@ -47,6 +62,47 @@ export class LifecycleToolHandlers {
47
62
  return createErrorResponse('action must be search, describe, or call.');
48
63
  }
49
64
  async handleLaunchEditor(args) {
65
+ return this.ensureEditor(normalizeParameters(args), true);
66
+ }
67
+ async handleEditorSession(args) {
68
+ args = normalizeParameters(args || {});
69
+ if (!args.projectPath || !['ensure', 'status', 'disconnect'].includes(args.action)) {
70
+ return createErrorResponse('projectPath and action ensure, status, or disconnect are required.');
71
+ }
72
+ if (!validatePath(args.projectPath) || !this.context.isPathAllowed(args.projectPath)) {
73
+ return createErrorResponse('Project path is outside the allowed roots');
74
+ }
75
+ if (!existsSync(join(args.projectPath, 'project.godot'))) {
76
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
77
+ }
78
+ if (args.action === 'disconnect') {
79
+ const session = this.context.disconnectEditorSession?.(args.projectPath);
80
+ if (!session)
81
+ return createErrorResponse('Editor session registry is unavailable.');
82
+ return this.editorSessionResponse(session);
83
+ }
84
+ if (args.action === 'status') {
85
+ const session = await this.context.getEditorSessionStatus?.(args.projectPath);
86
+ if (!session)
87
+ return createErrorResponse('Editor session registry is unavailable.');
88
+ return this.editorSessionResponse(session);
89
+ }
90
+ return this.ensureEditor(args, args.launchIfNeeded === true);
91
+ }
92
+ ensureEditor(args, launchIfNeeded) {
93
+ const projectPath = typeof args.projectPath === 'string' ? args.projectPath : '';
94
+ const existing = this.editorEnsureTails.get(projectPath);
95
+ if (existing)
96
+ return existing;
97
+ const pending = this.ensureEditorUnserialized(args, launchIfNeeded);
98
+ this.editorEnsureTails.set(projectPath, pending);
99
+ void pending.finally(() => {
100
+ if (this.editorEnsureTails.get(projectPath) === pending)
101
+ this.editorEnsureTails.delete(projectPath);
102
+ });
103
+ return pending;
104
+ }
105
+ async ensureEditorUnserialized(args, launchIfNeeded) {
50
106
  args = normalizeParameters(args);
51
107
  if (!args.projectPath)
52
108
  return createErrorResponse('Project path is required');
@@ -54,34 +110,59 @@ export class LifecycleToolHandlers {
54
110
  return createErrorResponse('Invalid project path');
55
111
  if (!this.context.isPathAllowed(args.projectPath))
56
112
  return createErrorResponse('Project path is outside the allowed roots');
113
+ if (!existsSync(join(args.projectPath, 'project.godot'))) {
114
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
115
+ }
57
116
  try {
117
+ const timeoutMs = Math.round((typeof args.timeoutSeconds === 'number' ? args.timeoutSeconds : 2) * 1_000);
118
+ const discovered = await this.context.ensureEditorSession?.(args.projectPath, timeoutMs);
119
+ if (discovered?.connected)
120
+ return this.editorSessionResponse({ ...discovered, reused: true, spawned: false });
121
+ if (!launchIfNeeded) {
122
+ return this.editorSessionResponse(discovered ?? {
123
+ state: 'no_editor', project_path: args.projectPath, connected: false, reused: false, spawned: false,
124
+ editor_pid: null, editor_start_identity: null, port: null, protocol_version: null,
125
+ addon_version: null, godot_version: null, created_at: null,
126
+ reason: 'No discoverable compatible editor. Install and enable the persistent addon, or retry with launchIfNeeded.',
127
+ });
128
+ }
58
129
  const godotPath = await this.requireGodotPath();
59
130
  if (!godotPath)
60
131
  return createErrorResponse('Could not find a valid Godot executable path');
61
- if (!existsSync(join(args.projectPath, 'project.godot')))
62
- return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
63
- this.context.logDebug(`Launching Godot editor for project: ${args.projectPath}`);
132
+ this.context.logDebug(`No reusable editor found; launching Godot editor for project: ${args.projectPath}`);
64
133
  const editorPlugin = this.context.installEditorPlugin?.(args.projectPath);
65
134
  const editorArgs = ['-e', '--path', args.projectPath];
66
135
  const editorProcess = spawn(godotPath, editorArgs, {
67
136
  stdio: 'pipe', env: { ...process.env, ...this.context.getRuntimeEnvironment(), ...(this.context.getEditorEnvironment?.() ?? {}) },
68
137
  });
69
138
  editorProcess.on('error', (err) => { console.error('Failed to start Godot editor:', err); });
70
- const editorEnvironment = this.context.getEditorEnvironment?.() ?? {};
71
- return { content: [{ type: 'text', text: JSON.stringify({
72
- launched: true,
73
- project_path: args.projectPath,
74
- editor_plugin: true,
75
- plugin_owned: editorPlugin?.owned ?? false,
76
- plugin_distribution: editorPlugin?.distribution ?? 'unavailable',
77
- editor_protocol_version: editorPlugin?.protocolVersion ?? null,
78
- editor_bridge_port: editorEnvironment.GODOT_MCP_EDITOR_PORT ?? null,
79
- }, null, 2) }] };
139
+ const attached = await this.context.ensureEditorSession?.(args.projectPath, 20_000);
140
+ if (!attached?.connected) {
141
+ return createErrorResponse(`Editor launched but bridge did not become ready: ${attached?.state ?? 'registry_unavailable'}${attached?.reason ? ` (${attached.reason})` : ''}`);
142
+ }
143
+ return this.editorSessionResponse({
144
+ ...attached,
145
+ reused: false,
146
+ spawned: true,
147
+ plugin_owned: editorPlugin?.owned ?? false,
148
+ plugin_distribution: editorPlugin?.distribution ?? 'unavailable',
149
+ });
80
150
  }
81
151
  catch (error) {
82
- return createErrorResponse(`Failed to launch Godot editor: ${this.errorMessage(error)}`);
152
+ const message = this.errorMessage(error);
153
+ if (message.includes('protocol is incompatible')) {
154
+ return this.editorSessionResponse({
155
+ state: 'addon_upgrade_restart_required', project_path: args.projectPath, connected: false,
156
+ reused: false, spawned: false, editor_pid: null, editor_start_identity: null, port: null,
157
+ protocol_version: null, addon_version: null, godot_version: null, created_at: null, reason: message,
158
+ });
159
+ }
160
+ return createErrorResponse(`Failed to ensure Godot editor: ${message}`);
83
161
  }
84
162
  }
163
+ editorSessionResponse(session) {
164
+ return { content: [{ type: 'text', text: JSON.stringify({ editor_session: session }, null, 2) }] };
165
+ }
85
166
  async handleEditorControl(args) {
86
167
  args = normalizeParameters(args || {});
87
168
  const allowed = ['inspect', 'select', 'save', 'reload', 'open_scene', 'set_property', 'rename_node', 'undo', 'redo'];
@@ -91,6 +172,9 @@ export class LifecycleToolHandlers {
91
172
  if (!validatePath(args.projectPath) || !this.context.isPathAllowed(args.projectPath)) {
92
173
  return createErrorResponse('Project path is outside the allowed roots');
93
174
  }
175
+ if (args.scenePath !== undefined && !this.isEditorPathAllowed(args.projectPath, args.scenePath)) {
176
+ return createErrorResponse('scenePath is outside the project root');
177
+ }
94
178
  const params = {};
95
179
  for (const key of ['nodePaths', 'scenePath', 'nodePath', 'property', 'value', 'name']) {
96
180
  if (args[key] !== undefined)
@@ -99,7 +183,7 @@ export class LifecycleToolHandlers {
99
183
  try {
100
184
  if (!this.context.sendEditorCommand)
101
185
  return createErrorResponse('Editor bridge is not configured. Launch the editor through launch_editor first.');
102
- const result = await this.context.sendEditorCommand(args.action, params, 15_000);
186
+ const result = await this.context.sendEditorCommand(args.projectPath, args.action, params, 15_000);
103
187
  if (result.error)
104
188
  return createErrorResponse(`editor_control failed: ${typeof result.error === 'string' ? result.error : JSON.stringify(result.error)}`);
105
189
  return { content: [{ type: 'text', text: JSON.stringify({ project_path: args.projectPath, action: args.action, ...result }, null, 2) }] };
@@ -108,6 +192,56 @@ export class LifecycleToolHandlers {
108
192
  return createErrorResponse(`editor_control failed: ${this.errorMessage(error)}`);
109
193
  }
110
194
  }
195
+ async handleEditorTransaction(args) {
196
+ args = normalizeParameters(args || {});
197
+ if (!args.projectPath || !args.scenePath || !args.name || !Array.isArray(args.operations) || args.operations.length === 0) {
198
+ return createErrorResponse('projectPath, scenePath, name, and at least one operation are required.');
199
+ }
200
+ if (!validatePath(args.projectPath) || !this.context.isPathAllowed(args.projectPath)) {
201
+ return createErrorResponse('Project path is outside the allowed roots');
202
+ }
203
+ if (!this.isEditorPathAllowed(args.projectPath, args.scenePath)) {
204
+ return createErrorResponse('scenePath is outside the project root');
205
+ }
206
+ for (const [index, operation] of args.operations.entries()) {
207
+ if (!operation || typeof operation !== 'object')
208
+ continue;
209
+ for (const key of ['scenePath', 'scriptPath', 'resourcePath']) {
210
+ if (operation[key] !== undefined && !this.isEditorPathAllowed(args.projectPath, operation[key])) {
211
+ return createErrorResponse(`operations[${index}].${key} is outside the project root`);
212
+ }
213
+ }
214
+ }
215
+ if (!this.context.sendEditorCommand)
216
+ return createErrorResponse('Editor session registry is unavailable.');
217
+ try {
218
+ const params = convertCamelToSnakeCase({
219
+ scenePath: args.scenePath,
220
+ name: args.name,
221
+ rootType: args.rootType,
222
+ operations: args.operations,
223
+ focusPath: args.focusPath,
224
+ save: args.save !== false,
225
+ });
226
+ const result = await this.context.sendEditorCommand(args.projectPath, 'transaction', params, 30_000);
227
+ if (result.error) {
228
+ return createErrorResponse(`editor_transaction failed: ${typeof result.error === 'string' ? result.error : JSON.stringify(result.error)}`);
229
+ }
230
+ const editorSession = await this.context.getEditorSessionStatus?.(args.projectPath);
231
+ return { content: [{ type: 'text', text: JSON.stringify({
232
+ project_path: args.projectPath,
233
+ backend: 'editor',
234
+ editor_session: editorSession ?? null,
235
+ sync_status: 'acknowledged',
236
+ fallback_reason: null,
237
+ observed_target_state: result.observed_target_state ?? null,
238
+ ...result,
239
+ }, null, 2) }] };
240
+ }
241
+ catch (error) {
242
+ return createErrorResponse(`editor_transaction failed: ${this.errorMessage(error)}`);
243
+ }
244
+ }
111
245
  async handleRunProject(args) {
112
246
  args = normalizeParameters(args);
113
247
  if (!args.projectPath)
@@ -148,33 +282,47 @@ export class LifecycleToolHandlers {
148
282
  // bad script hung the whole session. Errors still print with a full
149
283
  // backtrace without it, and the editor binary already runs projects as a
150
284
  // debug build.
151
- const commandArgs = [...deterministicSessionArguments(), '--path', args.projectPath];
152
- if (args.scene && (!this.context.isRelativePathAllowed || this.context.isRelativePathAllowed(args.projectPath, args.scene)))
285
+ const mode = args.timingMode === 'deterministic' ? 'deterministic' : 'realtime';
286
+ const commandArgs = [
287
+ ...(mode === 'deterministic' ? deterministicSessionArguments() : realtimeSessionArguments()),
288
+ '--path', args.projectPath,
289
+ ];
290
+ if (args.scene && this.context.isRelativePathAllowed(args.projectPath, args.scene))
153
291
  commandArgs.push(args.scene);
154
292
  this.context.logDebug(`Running Godot project: ${args.projectPath}`);
155
293
  const processGeneration = ++this.processGeneration;
156
294
  this.context.startProjectProcess(godotPath, commandArgs, () => { this.handleProjectExit(processGeneration); }, {
157
295
  ...this.context.getRuntimeEnvironment(),
158
- ...deterministicSessionEnvironment(),
296
+ ...(mode === 'deterministic' ? deterministicSessionEnvironment() : realtimeSessionEnvironment()),
159
297
  });
160
298
  this.context.connectToGame(args.projectPath).catch(error => {
161
299
  this.context.logDebug(`Failed to connect to game interaction server: ${error}`);
162
300
  });
163
- return {
164
- content: [{
165
- type: 'text',
166
- text: `Godot project started in debug mode. Use get_debug_output to see output. Game interaction server connecting on port ${this.context.getInteractionPort()}...`,
167
- }],
168
- };
301
+ return { content: [{ type: 'text', text: JSON.stringify({
302
+ started: true,
303
+ project_path: args.projectPath,
304
+ scene: args.scene ?? null,
305
+ interaction_port: this.context.getInteractionPort(),
306
+ timing_policy: timingPolicy(mode),
307
+ message: 'Godot project started in debug mode; use get_debug_output for process output.',
308
+ }, null, 2) }] };
169
309
  }
170
310
  catch (error) {
171
311
  return createErrorResponse(`Failed to run Godot project: ${this.errorMessage(error)}`);
172
312
  }
173
313
  }
314
+ isEditorPathAllowed(projectPath, relativePath) {
315
+ return typeof relativePath === 'string'
316
+ && this.context.isRelativePathAllowed(projectPath, relativePath);
317
+ }
174
318
  async handleVerifyProject(args) {
175
319
  args = normalizeParameters(args || {});
176
320
  const teardown = args.teardown !== false;
177
- const started = await this.handleRunProject({ projectPath: args.projectPath, ...(args.scene ? { scene: args.scene } : {}) });
321
+ const started = await this.handleRunProject({
322
+ projectPath: args.projectPath,
323
+ ...(args.scene ? { scene: args.scene } : {}),
324
+ timingMode: 'deterministic',
325
+ });
178
326
  if (started.isError === true)
179
327
  return started;
180
328
  const evidence = {
@@ -183,6 +331,7 @@ export class LifecycleToolHandlers {
183
331
  assertions: [],
184
332
  screenshot: null,
185
333
  teardown,
334
+ timing_policy: timingPolicy('deterministic'),
186
335
  };
187
336
  let passed = true;
188
337
  try {
@@ -245,6 +394,191 @@ export class LifecycleToolHandlers {
245
394
  ...(passed ? {} : { isError: true }),
246
395
  };
247
396
  }
397
+ async handleGameWaitUntil(args) {
398
+ args = normalizeParameters(args || {});
399
+ const result = await this.waitUntilEvidence(args);
400
+ return {
401
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
402
+ ...(result.satisfied === true ? {} : { isError: true }),
403
+ };
404
+ }
405
+ async handleGameScenario(args) {
406
+ args = normalizeParameters(args || {});
407
+ if (!args.name || !Array.isArray(args.steps) || args.steps.length === 0) {
408
+ return createErrorResponse('name and at least one scenario step are required.');
409
+ }
410
+ if (!this.context.isGameConnected())
411
+ return createErrorResponse('Not connected to a running Godot project.');
412
+ const startedAt = Date.now();
413
+ const deadline = startedAt + Math.round((typeof args.timeoutSeconds === 'number' ? args.timeoutSeconds : 60) * 1_000);
414
+ const evidence = [];
415
+ let passed = true;
416
+ let failure = null;
417
+ for (let index = 0; index < args.steps.length; index++) {
418
+ if (Date.now() >= deadline) {
419
+ passed = false;
420
+ failure = 'Scenario timeout expired';
421
+ break;
422
+ }
423
+ const step = args.steps[index];
424
+ const stepStartedAt = Date.now();
425
+ const item = { index, type: step.type, label: step.label ?? null };
426
+ try {
427
+ if (step.type === 'wait' || step.type === 'assert') {
428
+ const condition = normalizeParameters((step.condition ?? {}));
429
+ const remaining = Math.max(0.05, (deadline - Date.now()) / 1_000);
430
+ const requestedTimeout = typeof condition.timeoutSeconds === 'number' ? condition.timeoutSeconds : remaining;
431
+ const result = await this.waitUntilEvidence({ ...condition, timeoutSeconds: Math.min(remaining, requestedTimeout) });
432
+ item.result = result;
433
+ if (result.satisfied !== true) {
434
+ passed = false;
435
+ failure = `Step ${index} condition was not satisfied`;
436
+ }
437
+ }
438
+ else if (step.type === 'screenshot') {
439
+ const response = await this.context.sendGameCommand('screenshot', {}, Math.max(1, deadline - Date.now()));
440
+ if ('error' in response)
441
+ throw new Error(response.error.message);
442
+ const result = response.result;
443
+ const bytes = Buffer.from(result.data ?? '', 'base64');
444
+ item.result = {
445
+ captured: bytes.length > 0, width: result.width, height: result.height, bytes: bytes.length,
446
+ sha256: createHash('sha256').update(bytes).digest('hex'), preview_omitted: true,
447
+ };
448
+ }
449
+ else if (step.type === 'performance') {
450
+ const response = await this.context.sendGameCommand('get_performance', {
451
+ action: 'sample', sample_count: 1,
452
+ }, Math.max(1, deadline - Date.now()));
453
+ if ('error' in response)
454
+ throw new Error(response.error.message);
455
+ item.result = response.result;
456
+ }
457
+ else if (step.type === 'input' || step.type === 'observe') {
458
+ const allowed = step.type === 'input' ? SCENARIO_INPUT_TOOLS : SCENARIO_OBSERVE_TOOLS;
459
+ const tool = typeof step.tool === 'string' ? step.tool : step.type === 'input' ? 'game_key_press' : 'game_get_scene_tree';
460
+ if (!allowed.has(tool))
461
+ throw new Error(`Scenario ${step.type} tool is not allowed: ${tool}`);
462
+ if (!this.context.dispatchTool)
463
+ throw new Error('Scenario tool dispatch is unavailable');
464
+ const response = await this.context.dispatchTool(tool, (step.arguments ?? {}));
465
+ if (response.isError === true)
466
+ throw new Error(response.content.map(content => 'text' in content ? content.text : content.type).join('; '));
467
+ item.result = response.content.map(content => content.type === 'image'
468
+ ? { type: 'image', mime_type: 'mimeType' in content ? content.mimeType : 'image/png', preview_omitted: true }
469
+ : { type: content.type, text: 'text' in content ? String(content.text).slice(0, 2_000) : '' });
470
+ }
471
+ else {
472
+ throw new Error(`Unsupported scenario step type: ${String(step.type)}`);
473
+ }
474
+ }
475
+ catch (error) {
476
+ passed = false;
477
+ failure = `Step ${index} failed: ${this.errorMessage(error)}`;
478
+ item.error = this.errorMessage(error);
479
+ }
480
+ item.duration_ms = Date.now() - stepStartedAt;
481
+ evidence.push(item);
482
+ if (!passed)
483
+ break;
484
+ }
485
+ const teardown = { attempted: true };
486
+ try {
487
+ const restored = await this.context.sendGameCommand('time_scale', { action: 'set', time_scale: 1 }, 2_000);
488
+ teardown.time_scale_restored = !('error' in restored);
489
+ }
490
+ catch (error) {
491
+ teardown.time_scale_restored = false;
492
+ teardown.error = this.errorMessage(error);
493
+ passed = false;
494
+ }
495
+ return {
496
+ content: [{ type: 'text', text: JSON.stringify({
497
+ name: args.name, passed, failure, step_count: evidence.length,
498
+ duration_ms: Date.now() - startedAt, steps: evidence, teardown,
499
+ }, null, 2) }],
500
+ ...(passed ? {} : { isError: true }),
501
+ };
502
+ }
503
+ async waitUntilEvidence(args) {
504
+ const condition = args.condition;
505
+ if (!['connection', 'node', 'property', 'signal', 'log', 'scene'].includes(condition)) {
506
+ return { satisfied: false, error: 'condition must be connection, node, property, signal, log, or scene' };
507
+ }
508
+ const timeoutMs = Math.round((typeof args.timeoutSeconds === 'number' ? args.timeoutSeconds : 10) * 1_000);
509
+ const pollMs = typeof args.pollIntervalMs === 'number' ? args.pollIntervalMs : 100;
510
+ const startedAt = Date.now();
511
+ const deadline = startedAt + timeoutMs;
512
+ let attempts = 0;
513
+ let lastObserved = null;
514
+ if (condition === 'signal') {
515
+ if (!args.nodePath || !args.signal)
516
+ return { satisfied: false, error: 'nodePath and signal are required' };
517
+ const response = await this.context.sendGameCommand('await_signal', {
518
+ node_path: args.nodePath, signal_name: args.signal, timeout: timeoutMs / 1_000,
519
+ }, timeoutMs + 1_000);
520
+ return 'error' in response
521
+ ? { satisfied: false, condition, elapsed_ms: Date.now() - startedAt, attempts: 1, last_observed: response.error }
522
+ : { satisfied: true, condition, elapsed_ms: Date.now() - startedAt, attempts: 1, last_observed: response.result };
523
+ }
524
+ while (Date.now() <= deadline) {
525
+ attempts += 1;
526
+ if (condition === 'connection') {
527
+ lastObserved = { connected: this.context.isGameConnected() };
528
+ if (this.context.isGameConnected())
529
+ return waitSuccess(condition, startedAt, attempts, lastObserved);
530
+ }
531
+ else if (condition === 'log') {
532
+ const output = this.context.getActiveProcess()?.output.join('\n') ?? '';
533
+ lastObserved = { tail: output.slice(-2_000) };
534
+ if (typeof args.text === 'string' && output.includes(args.text))
535
+ return waitSuccess(condition, startedAt, attempts, lastObserved);
536
+ }
537
+ else {
538
+ if (!this.context.isGameConnected())
539
+ lastObserved = { connected: false };
540
+ else {
541
+ const command = condition === 'node' ? 'get_node_info' : condition === 'property' ? 'get_property' : 'get_scene_tree';
542
+ const params = condition === 'node' ? { node_path: args.nodePath }
543
+ : condition === 'property' ? { node_path: args.nodePath, property: args.property }
544
+ : {};
545
+ let response = null;
546
+ try {
547
+ response = await this.context.sendGameCommand(command, params, Math.min(5_000, Math.max(1, deadline - Date.now())));
548
+ }
549
+ catch (error) {
550
+ // A poll issued at the edge of the deadline can consume its own
551
+ // remaining transport budget. That is timeout evidence, not an
552
+ // MCP handler crash, and must not erase the last successful read.
553
+ if (lastObserved === null)
554
+ lastObserved = { error: this.errorMessage(error) };
555
+ }
556
+ if (response) {
557
+ lastObserved = 'error' in response ? { error: response.error } : response.result;
558
+ if (condition === 'node' && !('error' in response))
559
+ return waitSuccess(condition, startedAt, attempts, lastObserved);
560
+ if (condition === 'property' && !('error' in response)) {
561
+ const result = response.result;
562
+ if (sameJson(result.value, args.value))
563
+ return waitSuccess(condition, startedAt, attempts, lastObserved);
564
+ }
565
+ if (condition === 'scene' && !('error' in response)) {
566
+ const result = response.result;
567
+ if (result.current_scene === args.scenePath)
568
+ return waitSuccess(condition, startedAt, attempts, lastObserved);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ if (Date.now() >= deadline)
574
+ break;
575
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, deadline - Date.now())));
576
+ }
577
+ return {
578
+ satisfied: false, condition, elapsed_ms: Date.now() - startedAt, attempts,
579
+ timeout_ms: timeoutMs, last_observed: lastObserved,
580
+ };
581
+ }
248
582
  async evaluateVerificationAssertion(assertion) {
249
583
  if (assertion.kind === 'node_exists') {
250
584
  if (!assertion.nodePath)
@@ -629,7 +629,7 @@ export class ProjectIntegrityService {
629
629
  if ('error' in inventory)
630
630
  return createErrorResponse(inventory.error);
631
631
  if (args.action === 'analyze')
632
- return { content: [{ type: 'text', text: JSON.stringify(this.analyze(args.projectPath, inventory.files), null, 2) }] };
632
+ return { content: [{ type: 'text', text: JSON.stringify(this.analyze(args.projectPath, inventory.files, args.allowProceduralMainScene === true), null, 2) }] };
633
633
  if (args.action === 'leaks') {
634
634
  const report = this.analyze(args.projectPath, inventory.files);
635
635
  return { content: [{ type: 'text', text: JSON.stringify({
@@ -685,7 +685,7 @@ export class ProjectIntegrityService {
685
685
  };
686
686
  return walk(projectPath) ? { files: files.sort() } : { error: `Project exceeds maxFiles limit (${maxFiles}).` };
687
687
  }
688
- analyze(projectPath, files) {
688
+ analyze(projectPath, files, allowProceduralMainScene = false) {
689
689
  const fileSet = new Set(files);
690
690
  const graph = {};
691
691
  const uidOwners = new Map();
@@ -714,7 +714,47 @@ export class ProjectIntegrityService {
714
714
  duplicate_uids: [...uidOwners.entries()].filter(([, owners]) => new Set(owners).size > 1)
715
715
  .map(([uid, owners]) => ({ uid, files: [...new Set(owners)] })),
716
716
  cycles: this.cycles(graph), orphan_resources: files.filter(file => !incoming.has(file) && !roots.has(file)),
717
- orphan_nodes: orphanNodes, limits: { max_files: files.length },
717
+ orphan_nodes: orphanNodes,
718
+ main_scene_structure: this.mainSceneStructure(projectPath, projectConfig, allowProceduralMainScene),
719
+ limits: { max_files: files.length },
720
+ };
721
+ }
722
+ mainSceneStructure(projectPath, projectConfig, allowProceduralMainScene) {
723
+ const configured = /run\/main_scene\s*=\s*"res:\/\/([^"]+)"/.exec(projectConfig)?.[1];
724
+ if (!configured)
725
+ return { configured: false, warning: 'main_scene_not_configured' };
726
+ const scenePath = join(projectPath, configured);
727
+ if (!existsSync(scenePath) || extname(configured).toLowerCase() !== '.tscn') {
728
+ return { configured: true, scene_path: configured, inspectable: false, warning: 'main_scene_not_text_inspectable' };
729
+ }
730
+ const scene = readFileSync(scenePath, 'utf8');
731
+ const nodeCount = [...scene.matchAll(/^\[node\s+/gm)].length;
732
+ const scriptRefs = new Map([...scene.matchAll(/^\[ext_resource\s+type="Script"\s+path="res:\/\/([^"]+)"\s+id="([^"]+)"/gm)]
733
+ .map(match => [match[2], match[1]]));
734
+ const rootBlock = /^\[node\s+[^\]]+\]\s*\n([\s\S]*?)(?=^\[|$)/m.exec(scene)?.[1] ?? '';
735
+ const rootScriptId = /script\s*=\s*ExtResource\("([^"]+)"\)/.exec(rootBlock)?.[1];
736
+ const rootScriptPath = rootScriptId ? scriptRefs.get(rootScriptId) : undefined;
737
+ const rootScript = rootScriptPath && existsSync(join(projectPath, rootScriptPath))
738
+ ? readFileSync(join(projectPath, rootScriptPath), 'utf8')
739
+ : '';
740
+ const constructsAtStartup = /func\s+_ready\s*\([^)]*\)[\s\S]{0,12000}\b(?:add_child|instantiate|new)\s*\(/.test(rootScript);
741
+ const trivial = nodeCount <= 1;
742
+ const warning = trivial && constructsAtStartup && !allowProceduralMainScene
743
+ ? 'trivial_main_scene_with_procedural_startup_hierarchy'
744
+ : null;
745
+ return {
746
+ configured: true,
747
+ scene_path: configured,
748
+ inspectable: true,
749
+ persisted_node_count: nodeCount,
750
+ root_script: rootScriptPath ?? null,
751
+ constructs_persistent_hierarchy_at_startup: constructsAtStartup,
752
+ explicit_procedural_requirement: allowProceduralMainScene,
753
+ meaningful_persisted_structure: !trivial || allowProceduralMainScene,
754
+ warning,
755
+ recommendation: warning
756
+ ? 'Persist meaningful game hierarchy/resources in the scene, or rerun with allowProceduralMainScene only when procedural construction is intentional.'
757
+ : null,
718
758
  };
719
759
  }
720
760
  findOrphanNodes(scene, content, output) {
@@ -1420,9 +1460,8 @@ export class AddonManagementService {
1420
1460
  const matched = expected === null || result.stdout.includes(expected);
1421
1461
  const rawDiagnostics = `${result.stdout}\n${result.stderr}`.split(/\r?\n/)
1422
1462
  .filter(line => /SCRIPT ERROR|Parse Error|\bERROR:/i.test(line)).slice(0, 256);
1423
- const godot44 = result.stdout.includes('Godot Engine v4.4.');
1424
- const knownDiagnostics = godot44 ? rawDiagnostics.filter(line => /progress dialog \(task\)|tasks\.has\(p_task\)/i.test(line)) : [];
1425
- const diagnostics = rawDiagnostics.filter(line => !knownDiagnostics.includes(line));
1463
+ const knownDiagnostics = [];
1464
+ const diagnostics = rawDiagnostics;
1426
1465
  return { ...result, ok: result.ok && matched && diagnostics.length === 0,
1427
1466
  expected_output: expected, output_matched: matched, diagnostics, known_diagnostics: knownDiagnostics };
1428
1467
  }
@@ -8,7 +8,7 @@ import { ProjectConfigurationService, ProjectExportService, ProjectFileIOService
8
8
  const execFileAsync = promisify(execFile);
9
9
  const CI_EXPORT_PLATFORMS = new Set(['windows', 'linux', 'macos', 'web']);
10
10
  const DOCKER_BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04']);
11
- const GODOT_EXPORT_VERSION = /^4\.\d+(?:\.\d+)?(?:-stable)?$/;
11
+ const GODOT_EXPORT_VERSION = /^4\.(?:[7-9]|\d{2,})(?:\.\d+)?(?:-stable)?$/;
12
12
  const EXPORT_PRESET_NAME = /^[A-Za-z0-9][A-Za-z0-9 _./-]{0,127}$/;
13
13
  const INPUT_EVENT_KEY_PREFIX = 'Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0';
14
14
  const INPUT_EVENT_KEY_SUFFIX = ',"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)';
@@ -56,7 +56,7 @@ export function mergeInputMapAction(content, actionName, deadzone, physicalKeyco
56
56
  }
57
57
  function validateGodotExportVersion(version) {
58
58
  if (typeof version !== 'string' || !GODOT_EXPORT_VERSION.test(version)) {
59
- return 'godotVersion must be a supported Godot 4 version such as "4.3-stable".';
59
+ return 'godotVersion must be a supported Godot 4.7+ version such as "4.7-stable".';
60
60
  }
61
61
  return null;
62
62
  }
@@ -1387,7 +1387,7 @@ export class ProjectToolHandlers {
1387
1387
  return { content: [{ type: 'text', text: content }] };
1388
1388
  }
1389
1389
  else if (args.action === 'create') {
1390
- const godotVersion = args.godotVersion || '4.3-stable';
1390
+ const godotVersion = args.godotVersion || '4.7-stable';
1391
1391
  const versionError = validateGodotExportVersion(godotVersion);
1392
1392
  if (versionError)
1393
1393
  return createErrorResponse(versionError);
@@ -1429,7 +1429,7 @@ export class ProjectToolHandlers {
1429
1429
  return { content: [{ type: 'text', text: content }] };
1430
1430
  }
1431
1431
  else if (args.action === 'create') {
1432
- const godotVersion = args.godotVersion || '4.3-stable';
1432
+ const godotVersion = args.godotVersion || '4.7-stable';
1433
1433
  const baseImage = args.baseImage || 'ubuntu:22.04';
1434
1434
  let exportPreset = args.exportPreset || 'Linux/X11';
1435
1435
  const versionError = validateGodotExportVersion(godotVersion);
@@ -18,6 +18,13 @@ export const toolManifest = {
18
18
  actions: null,
19
19
  privileged: false,
20
20
  },
21
+ editor_session: {
22
+ domain: 'lifecycle',
23
+ handler: 'handleEditorSession',
24
+ backend: { kind: 'process' },
25
+ actions: ['ensure', 'status', 'disconnect'],
26
+ privileged: false,
27
+ },
21
28
  editor_control: {
22
29
  domain: 'lifecycle',
23
30
  handler: 'handleEditorControl',
@@ -25,6 +32,13 @@ export const toolManifest = {
25
32
  actions: ['inspect', 'select', 'save', 'reload', 'open_scene', 'set_property', 'rename_node', 'undo', 'redo'],
26
33
  privileged: false,
27
34
  },
35
+ editor_transaction: {
36
+ domain: 'lifecycle',
37
+ handler: 'handleEditorTransaction',
38
+ backend: { kind: 'process' },
39
+ actions: null,
40
+ privileged: false,
41
+ },
28
42
  run_project: {
29
43
  domain: 'lifecycle',
30
44
  handler: 'handleRunProject',
@@ -39,6 +53,20 @@ export const toolManifest = {
39
53
  actions: null,
40
54
  privileged: false,
41
55
  },
56
+ game_wait_until: {
57
+ domain: 'lifecycle',
58
+ handler: 'handleGameWaitUntil',
59
+ backend: { kind: 'process' },
60
+ actions: null,
61
+ privileged: false,
62
+ },
63
+ game_scenario: {
64
+ domain: 'lifecycle',
65
+ handler: 'handleGameScenario',
66
+ backend: { kind: 'process' },
67
+ actions: null,
68
+ privileged: false,
69
+ },
42
70
  run_project_tests: {
43
71
  domain: 'project',
44
72
  handler: 'handleRunProjectTests',
@@ -282,7 +310,7 @@ export const toolManifest = {
282
310
  domain: 'game',
283
311
  handler: 'handleGamePerformance',
284
312
  backend: { kind: 'runtime', command: 'get_performance' },
285
- actions: ['sample', 'start', 'stop', 'report', 'leaks'],
313
+ actions: ['sample', 'start', 'stop', 'report', 'leaks', 'stress'],
286
314
  privileged: false,
287
315
  },
288
316
  game_wait: {