@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.
- package/README.md +59 -34
- package/addons/godot_agent_loop/README.md +26 -16
- package/addons/godot_agent_loop/plugin.cfg +3 -2
- package/addons/godot_agent_loop/plugin.gd +840 -25
- package/agent-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/adapter-manifest.json +3 -3
- package/agent-plugin/pi/extension.ts +1 -1
- package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
- package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
- package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
- package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
- package/build/authoring-session-manager.js +24 -1
- package/build/domain-tool-registries.js +5 -1
- package/build/editor-authoring-router.js +228 -0
- package/build/editor-bridge-protocol.js +2 -1
- package/build/editor-connection.js +29 -24
- package/build/editor-mutation-guard.js +3 -1
- package/build/editor-plugin-installer.js +2 -1
- package/build/editor-session-registry.js +392 -0
- package/build/editor-sync-queue.js +135 -0
- package/build/index.js +208 -29
- package/build/lifecycle-trace.js +80 -0
- package/build/scripts/mcp_editor_plugin.gd +840 -25
- package/build/scripts/mcp_interaction_server.gd +76 -2
- package/build/scripts/mcp_runtime/core_domain.gd +2 -1
- package/build/scripts/mcp_runtime/system_domain.gd +2 -0
- package/build/server-instructions.js +5 -5
- package/build/session-timing.js +17 -1
- package/build/tool-definitions.js +99 -6
- package/build/tool-handlers/game-tool-handlers.js +21 -4
- package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
- package/build/tool-handlers/project-handler-services.js +45 -6
- package/build/tool-handlers/project-tool-handlers.js +4 -4
- package/build/tool-manifest.js +29 -1
- package/build/tool-mutation-policy.js +1 -0
- package/build/tool-surface.js +4 -4
- package/build/utils.js +2 -2
- package/package.json +4 -3
- 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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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({
|
|
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,
|
|
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
|
|
1424
|
-
const
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|
package/build/tool-manifest.js
CHANGED
|
@@ -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: {
|