@beremaran/godot-agent-loop 1.0.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 (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,49 @@
1
+ import { createErrorResponse, convertCamelToSnakeCase, errorMessage, normalizeParameters } from './utils.js';
2
+ /** Shared runtime command boundary for tools that control a running game. */
3
+ export class GameCommandService {
4
+ processManager;
5
+ connection;
6
+ constructor(processManager, connection) {
7
+ this.processManager = processManager;
8
+ this.connection = connection;
9
+ }
10
+ hasActiveProcess() {
11
+ return this.processManager.activeProcess !== null;
12
+ }
13
+ isConnected() {
14
+ return this.connection.isConnected;
15
+ }
16
+ connectedProjectPath() {
17
+ return this.connection.connectedProjectPath;
18
+ }
19
+ readNewErrors(limit) {
20
+ return this.processManager.readNewErrors(limit);
21
+ }
22
+ readNewLogs(limit) {
23
+ return this.processManager.readNewLogs(limit);
24
+ }
25
+ send(command, params = {}, timeoutMs = 10000) {
26
+ return this.connection.send(command, params, timeoutMs);
27
+ }
28
+ /**
29
+ * Performs the common runtime checks and formats regular command responses.
30
+ * Parameter mapping remains in the domain handler, where each tool's
31
+ * request shape is explicit.
32
+ */
33
+ async execute(name, args, buildParams, timeoutMs) {
34
+ if (!this.hasActiveProcess())
35
+ return createErrorResponse('No active Godot process. Use run_project first.');
36
+ if (!this.isConnected())
37
+ return createErrorResponse('Not connected to game interaction server.');
38
+ const normalizedArgs = normalizeParameters((args || {}));
39
+ try {
40
+ const response = await this.send(name, convertCamelToSnakeCase(buildParams(normalizedArgs)), timeoutMs);
41
+ if ('error' in response)
42
+ return createErrorResponse(`${name} failed: ${response.error.message}`);
43
+ return { content: [{ type: 'text', text: JSON.stringify(response.result, null, 2) }] };
44
+ }
45
+ catch (error) {
46
+ return createErrorResponse(`${name} failed: ${errorMessage(error)}`);
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,337 @@
1
+ import { createConnection } from 'net';
2
+ import { performance } from 'perf_hooks';
3
+ import { AUTHORING_COMMANDS_CAPABILITY, CANCEL_METHOD, HANDSHAKE_METHOD, PRIVILEGED_RUNTIME_CAPABILITY, PRIVILEGED_RUNTIME_COMMANDS, PRIVILEGED_RUNTIME_COMMAND_GROUPS, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, SESSION_AUTHENTICATION_CAPABILITY, commandMethod, isAuthoringCommand, isHandshakeResult, isJsonRpcResponse, isSessionCommand, privilegedGroupCapability } from './runtime-protocol.js';
4
+ /** Owns the newline-delimited JSON-RPC 2.0 request lifecycle for a running Godot game. */
5
+ export class GameConnection {
6
+ socket = null;
7
+ connected = false;
8
+ responseBuffer = '';
9
+ pendingRequests = new Map();
10
+ projectPath = null;
11
+ interactionServerInjectedByUs = false;
12
+ nextRequestId = 1;
13
+ port;
14
+ host;
15
+ initialDelayMs;
16
+ retryDelayMs;
17
+ maxAttempts;
18
+ log;
19
+ allowPrivilegedCommands;
20
+ allowedPrivilegedGroups;
21
+ authSecret;
22
+ onLifecycleEvent;
23
+ runtimeCapabilities = [];
24
+ connectionGeneration = 0;
25
+ connectingSocket = null;
26
+ pendingDelay = null;
27
+ constructor(options = {}) {
28
+ this.port = options.port ?? 9090;
29
+ this.host = options.host ?? '127.0.0.1';
30
+ this.initialDelayMs = options.initialDelayMs ?? 2000;
31
+ this.retryDelayMs = options.retryDelayMs ?? 500;
32
+ // Cold editor imports and shader compilation can exceed the old seven-second
33
+ // connection window on fresh CI machines. Keep retrying for roughly 90s,
34
+ // while connect() still aborts immediately when the process exits.
35
+ this.maxAttempts = options.maxAttempts ?? 180;
36
+ this.log = options.log ?? (() => undefined);
37
+ this.allowPrivilegedCommands = options.allowPrivilegedCommands ?? false;
38
+ this.allowedPrivilegedGroups = new Set(options.allowedPrivilegedGroups ?? []);
39
+ this.authSecret = options.authSecret;
40
+ this.onLifecycleEvent = options.onLifecycleEvent ?? (() => undefined);
41
+ }
42
+ get interactionPort() { return this.port; }
43
+ get isConnected() { return this.connected; }
44
+ get connectedProjectPath() { return this.projectPath; }
45
+ supportsCapability(capability) { return this.runtimeCapabilities.includes(capability); }
46
+ /** Records whether this server added the interaction autoload for the active project. */
47
+ recordInteractionServerInstallation(installed) {
48
+ this.interactionServerInjectedByUs = installed;
49
+ }
50
+ /** Returns and clears ownership so removal is performed at most once. */
51
+ consumeInteractionServerOwnership() {
52
+ const ownedByMcp = this.interactionServerInjectedByUs;
53
+ this.interactionServerInjectedByUs = false;
54
+ return ownedByMcp;
55
+ }
56
+ /** Clears the project association after its interaction server has been removed. */
57
+ clearConnectedProject() {
58
+ this.projectPath = null;
59
+ }
60
+ async connect(projectPath, isProcessActive) {
61
+ const generation = ++this.connectionGeneration;
62
+ this.cancelPendingDelay();
63
+ this.destroySocket();
64
+ this.connected = false;
65
+ this.responseBuffer = '';
66
+ this.rejectAllPending(this.connectionError(null, 'Connection superseded'));
67
+ this.projectPath = projectPath;
68
+ if (!await this.delay(this.initialDelayMs, generation))
69
+ return;
70
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
71
+ if (!this.isCurrentGeneration(generation))
72
+ return;
73
+ if (!isProcessActive()) {
74
+ this.logEvent('connection_aborted', { reason: 'process_not_running' });
75
+ return;
76
+ }
77
+ try {
78
+ const connected = await this.connectOnce(attempt, generation);
79
+ if (!connected || !this.isCurrentGeneration(generation))
80
+ return;
81
+ return;
82
+ }
83
+ catch {
84
+ if (!this.isCurrentGeneration(generation))
85
+ return;
86
+ this.logEvent('connection_retry', {
87
+ attempt, max_attempts: this.maxAttempts, retry_delay_ms: this.retryDelayMs,
88
+ });
89
+ if (!await this.delay(this.retryDelayMs, generation))
90
+ return;
91
+ }
92
+ }
93
+ if (this.isCurrentGeneration(generation)) {
94
+ console.error(JSON.stringify({
95
+ component: 'godot-agent-loop-server', event: 'connection_failed', attempts: this.maxAttempts,
96
+ }));
97
+ }
98
+ }
99
+ disconnect() {
100
+ this.connectionGeneration++;
101
+ this.cancelPendingDelay();
102
+ this.destroySocket();
103
+ this.connected = false;
104
+ this.responseBuffer = '';
105
+ this.runtimeCapabilities = [];
106
+ this.rejectAllPending(this.connectionError(null, 'Disconnected'));
107
+ }
108
+ rejectAllPending(response) {
109
+ for (const resolver of this.pendingRequests.values())
110
+ resolver(response);
111
+ this.pendingRequests.clear();
112
+ }
113
+ resolveResponse(parsed) {
114
+ if (this.pendingRequests.size === 0 || !isJsonRpcResponse(parsed))
115
+ return;
116
+ if (typeof parsed.id !== 'number' || !this.pendingRequests.has(parsed.id))
117
+ return;
118
+ const resolver = this.pendingRequests.get(parsed.id);
119
+ this.pendingRequests.delete(parsed.id);
120
+ resolver(parsed);
121
+ }
122
+ async send(command, params = {}, timeoutMs = 10000) {
123
+ if (!isSessionCommand(command))
124
+ throw new Error(`'${command}' is not a command in the published session contract`);
125
+ if (isAuthoringCommand(command) && !this.runtimeCapabilities.includes(AUTHORING_COMMANDS_CAPABILITY)) {
126
+ throw new Error(`runtime does not expose the harness-owned ${AUTHORING_COMMANDS_CAPABILITY} capability`);
127
+ }
128
+ const isPrivileged = PRIVILEGED_RUNTIME_COMMANDS.includes(command);
129
+ if (isPrivileged) {
130
+ const privilegedCommand = command;
131
+ const group = PRIVILEGED_RUNTIME_COMMAND_GROUPS[privilegedCommand];
132
+ if (!this.allowPrivilegedCommands && !this.allowedPrivilegedGroups.has(group)) {
133
+ throw new Error(`Privileged runtime command '${command}' is disabled. Enable group '${group}' with GODOT_MCP_PRIVILEGED_GROUPS or opt in to all privileged commands.`);
134
+ }
135
+ const capability = privilegedGroupCapability(group);
136
+ if (!this.runtimeCapabilities.includes(PRIVILEGED_RUNTIME_CAPABILITY)
137
+ && !this.runtimeCapabilities.includes(capability)) {
138
+ throw new Error(`runtime did not authorize privileged group '${group}'`);
139
+ }
140
+ }
141
+ if (!this.connected || !this.socket)
142
+ throw new Error('Not connected to game interaction server. Is the game running?');
143
+ return this.request(commandMethod(command), params, timeoutMs);
144
+ }
145
+ request(method, params, timeoutMs) {
146
+ const id = this.nextRequestId++;
147
+ const isCommand = method.startsWith('godot.runtime.')
148
+ && method !== HANDSHAKE_METHOD && method !== CANCEL_METHOD;
149
+ const correlationId = isCommand ? `mcp_${id}` : undefined;
150
+ const wireParams = correlationId === undefined ? params : { ...params, _mcp_correlation_id: correlationId };
151
+ const payload = JSON.stringify({ jsonrpc: '2.0', method, params: wireParams, id }) + '\n';
152
+ const startedAt = performance.now();
153
+ const command = method.slice('godot.runtime.'.length);
154
+ const target = requestTarget(params);
155
+ if (correlationId !== undefined) {
156
+ this.logEvent('request_started', { correlation_id: correlationId, method });
157
+ this.emitLifecycle({ event: 'request_started', correlation_id: correlationId, command, target });
158
+ }
159
+ return new Promise((resolve, reject) => {
160
+ const timeout = setTimeout(() => {
161
+ if (!this.pendingRequests.delete(id))
162
+ return;
163
+ this.requestCancellation(id);
164
+ if (correlationId !== undefined) {
165
+ const durationMs = Math.max(0, Math.round(performance.now() - startedAt));
166
+ this.logEvent('request_timed_out', { correlation_id: correlationId, method, timeout_ms: timeoutMs, duration_ms: durationMs });
167
+ this.emitLifecycle({
168
+ event: 'request_timed_out', correlation_id: correlationId, command, target,
169
+ outcome: 'timeout', duration_ms: durationMs,
170
+ });
171
+ }
172
+ reject(new Error(`Game request '${method}' timed out after ${timeoutMs / 1000}s`));
173
+ }, timeoutMs);
174
+ this.pendingRequests.set(id, response => {
175
+ clearTimeout(timeout);
176
+ if (correlationId !== undefined) {
177
+ const durationMs = Math.max(0, Math.round(performance.now() - startedAt));
178
+ const outcome = 'error' in response ? 'error' : 'success';
179
+ this.logEvent('request_finished', {
180
+ correlation_id: correlationId, method,
181
+ outcome, duration_ms: durationMs,
182
+ ...('error' in response ? { error_code: response.error.code } : {}),
183
+ });
184
+ this.emitLifecycle({
185
+ event: 'request_finished', correlation_id: correlationId, command, target,
186
+ outcome, duration_ms: durationMs,
187
+ ...('error' in response ? { error_code: response.error.code } : {}),
188
+ });
189
+ }
190
+ resolve(response);
191
+ });
192
+ this.socket.write(payload);
193
+ });
194
+ }
195
+ connectOnce(attempt, generation) {
196
+ return new Promise((resolve, reject) => {
197
+ const socket = createConnection({ host: this.host, port: this.port }, () => {
198
+ if (!this.isCurrentGeneration(generation)) {
199
+ socket.destroy();
200
+ resolve(false);
201
+ return;
202
+ }
203
+ this.connectingSocket = null;
204
+ this.socket = socket;
205
+ this.connected = true;
206
+ this.responseBuffer = '';
207
+ this.runtimeCapabilities = [];
208
+ this.pendingRequests.clear();
209
+ this.logEvent('connection_opened', { attempt });
210
+ socket.on('data', data => { if (this.isCurrentSocket(socket, generation))
211
+ this.handleData(data); });
212
+ socket.on('close', () => {
213
+ this.logEvent('connection_closed');
214
+ if (!this.isCurrentSocket(socket, generation))
215
+ return;
216
+ this.connected = false;
217
+ this.socket = null;
218
+ this.rejectAllPending(this.connectionError(null, 'Connection closed'));
219
+ });
220
+ socket.on('error', () => { this.logEvent('connection_error'); });
221
+ this.negotiateProtocol().then(() => {
222
+ resolve(true);
223
+ }).catch(error => {
224
+ socket.destroy();
225
+ reject(error instanceof Error ? error : new Error(String(error)));
226
+ });
227
+ });
228
+ this.connectingSocket = socket;
229
+ socket.on('error', error => { if (this.isCurrentGeneration(generation))
230
+ reject(error);
231
+ else
232
+ resolve(false); });
233
+ socket.on('close', () => { if (!this.isCurrentGeneration(generation))
234
+ resolve(false); });
235
+ });
236
+ }
237
+ async negotiateProtocol() {
238
+ const requestedCapabilities = [...RUNTIME_CAPABILITIES];
239
+ if (this.authSecret !== undefined)
240
+ requestedCapabilities.push(SESSION_AUTHENTICATION_CAPABILITY);
241
+ if (this.allowPrivilegedCommands)
242
+ requestedCapabilities.push(PRIVILEGED_RUNTIME_CAPABILITY);
243
+ for (const group of this.allowedPrivilegedGroups)
244
+ requestedCapabilities.push(privilegedGroupCapability(group));
245
+ const response = await this.request(HANDSHAKE_METHOD, {
246
+ protocolVersion: RUNTIME_PROTOCOL_VERSION,
247
+ capabilities: requestedCapabilities,
248
+ ...(this.authSecret === undefined ? {} : { secret: this.authSecret }),
249
+ }, 5000);
250
+ if ('error' in response)
251
+ throw new Error(response.error.message);
252
+ if (!isHandshakeResult(response.result))
253
+ throw new Error('invalid handshake result');
254
+ const handshake = response.result;
255
+ if (handshake.protocolVersion !== RUNTIME_PROTOCOL_VERSION)
256
+ throw new Error(`unsupported runtime protocol version ${handshake.protocolVersion}`);
257
+ const missing = RUNTIME_CAPABILITIES.filter(capability => !handshake.capabilities.includes(capability));
258
+ if (missing.length > 0)
259
+ throw new Error(`runtime does not support ${missing.join(', ')}`);
260
+ if (this.authSecret !== undefined && !handshake.capabilities.includes(SESSION_AUTHENTICATION_CAPABILITY)) {
261
+ throw new Error('runtime did not confirm session authentication');
262
+ }
263
+ if (this.allowPrivilegedCommands && !handshake.capabilities.includes(PRIVILEGED_RUNTIME_CAPABILITY)) {
264
+ throw new Error(`runtime does not support privileged runtime commands; enable ${PRIVILEGED_RUNTIME_CAPABILITY} in the Godot interaction server`);
265
+ }
266
+ for (const group of this.allowedPrivilegedGroups) {
267
+ if (!handshake.capabilities.includes(PRIVILEGED_RUNTIME_CAPABILITY)
268
+ && !handshake.capabilities.includes(privilegedGroupCapability(group))) {
269
+ throw new Error(`runtime does not support privileged group '${group}'`);
270
+ }
271
+ }
272
+ this.runtimeCapabilities = handshake.capabilities;
273
+ }
274
+ isCurrentGeneration(generation) { return generation === this.connectionGeneration; }
275
+ isCurrentSocket(socket, generation) { return this.isCurrentGeneration(generation) && this.socket === socket; }
276
+ delay(milliseconds, generation) {
277
+ return new Promise(resolve => {
278
+ const timer = setTimeout(() => { this.pendingDelay = null; resolve(this.isCurrentGeneration(generation)); }, milliseconds);
279
+ this.pendingDelay = { timer, resolve };
280
+ });
281
+ }
282
+ cancelPendingDelay() {
283
+ if (this.pendingDelay !== null) {
284
+ clearTimeout(this.pendingDelay.timer);
285
+ this.pendingDelay.resolve(false);
286
+ this.pendingDelay = null;
287
+ }
288
+ }
289
+ destroySocket() {
290
+ const sockets = [this.socket, this.connectingSocket];
291
+ this.socket = null;
292
+ this.connectingSocket = null;
293
+ for (const socket of sockets)
294
+ socket?.destroy();
295
+ }
296
+ /** Best-effort cooperative cancellation; servers acknowledge it separately. */
297
+ requestCancellation(requestId) {
298
+ if (!this.connected || !this.socket)
299
+ return;
300
+ const id = this.nextRequestId++;
301
+ this.socket.write(`${JSON.stringify({ jsonrpc: '2.0', method: CANCEL_METHOD, params: { request_id: requestId }, id })}\n`);
302
+ }
303
+ handleData(data) {
304
+ this.responseBuffer += data.toString();
305
+ while (this.responseBuffer.includes('\n')) {
306
+ const newlinePos = this.responseBuffer.indexOf('\n');
307
+ const line = this.responseBuffer.substring(0, newlinePos).trim();
308
+ this.responseBuffer = this.responseBuffer.substring(newlinePos + 1);
309
+ if (!line)
310
+ continue;
311
+ try {
312
+ this.resolveResponse(JSON.parse(line));
313
+ }
314
+ catch {
315
+ this.logEvent('response_parse_failed', { response_bytes: Buffer.byteLength(line) });
316
+ }
317
+ }
318
+ }
319
+ logEvent(event, details = {}) {
320
+ this.log(JSON.stringify({ component: 'godot-agent-loop-server', event, ...details }));
321
+ }
322
+ emitLifecycle(event) {
323
+ try {
324
+ this.onLifecycleEvent(event);
325
+ }
326
+ catch { /* Observation must never break command delivery. */ }
327
+ }
328
+ connectionError(id, message) { return { jsonrpc: '2.0', id, error: { code: -32000, message } }; }
329
+ }
330
+ function requestTarget(params) {
331
+ for (const key of ['node_path', 'scene_path', 'resource_path', 'parent_path', 'group', 'action']) {
332
+ const value = params[key];
333
+ if (typeof value === 'string' && value.length > 0)
334
+ return value.slice(0, 256);
335
+ }
336
+ return 'game';
337
+ }
@@ -0,0 +1,156 @@
1
+ import { execFile } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { normalize } from 'path';
4
+ import { promisify } from 'util';
5
+ import { GODOT_VERSION_OPTIONS } from './godot-subprocess.js';
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * Resolves and validates the executable used by Godot-facing services.
9
+ *
10
+ * Keeping this mutable state in one service prevents tool handlers from
11
+ * coordinating a pair of get-path/detect-path callbacks themselves.
12
+ */
13
+ export class GodotExecutableService {
14
+ validator;
15
+ strictPathValidation;
16
+ logDebug;
17
+ godotPath = null;
18
+ constructor(validator, strictPathValidation, logDebug = () => undefined) {
19
+ this.validator = validator;
20
+ this.strictPathValidation = strictPathValidation;
21
+ this.logDebug = logDebug;
22
+ }
23
+ get path() {
24
+ return this.godotPath;
25
+ }
26
+ set path(path) {
27
+ this.godotPath = path;
28
+ }
29
+ isValidSync(path) {
30
+ return this.validator.isValidSync(path);
31
+ }
32
+ isValid(path) {
33
+ return this.validator.isValid(path);
34
+ }
35
+ async detect() {
36
+ this.godotPath = await detectGodotExecutablePath({
37
+ currentPath: this.godotPath,
38
+ strictPathValidation: this.strictPathValidation,
39
+ isValid: path => this.isValid(path),
40
+ logDebug: this.logDebug,
41
+ });
42
+ return this.godotPath;
43
+ }
44
+ async requirePath() {
45
+ if (!this.godotPath)
46
+ await this.detect();
47
+ return this.godotPath;
48
+ }
49
+ async setPath(path) {
50
+ const normalizedPath = normalize(path);
51
+ if (!await this.isValid(normalizedPath))
52
+ return false;
53
+ this.godotPath = normalizedPath;
54
+ this.logDebug(`Godot path set to: ${normalizedPath}`);
55
+ return true;
56
+ }
57
+ }
58
+ export class GodotExecutableValidator {
59
+ logDebug;
60
+ validatedPaths = new Map();
61
+ constructor(logDebug = () => undefined) {
62
+ this.logDebug = logDebug;
63
+ }
64
+ isValidSync(path) {
65
+ try {
66
+ this.logDebug(`Quick-validating Godot path: ${path}`);
67
+ return path === 'godot' || existsSync(path);
68
+ }
69
+ catch (error) {
70
+ this.logDebug(`Invalid Godot path: ${path}, error: ${error}`);
71
+ return false;
72
+ }
73
+ }
74
+ async isValid(path) {
75
+ const cached = this.validatedPaths.get(path);
76
+ if (cached !== undefined)
77
+ return cached;
78
+ try {
79
+ this.logDebug(`Validating Godot path: ${path}`);
80
+ if (path !== 'godot' && !existsSync(path)) {
81
+ this.logDebug(`Path does not exist: ${path}`);
82
+ this.validatedPaths.set(path, false);
83
+ return false;
84
+ }
85
+ await execFileAsync(path, ['--version'], GODOT_VERSION_OPTIONS);
86
+ this.logDebug(`Valid Godot path: ${path}`);
87
+ this.validatedPaths.set(path, true);
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ this.logDebug(`Invalid Godot path: ${path}, error: ${error}`);
92
+ this.validatedPaths.set(path, false);
93
+ return false;
94
+ }
95
+ }
96
+ }
97
+ export async function detectGodotExecutablePath(options) {
98
+ const logDebug = options.logDebug ?? (() => undefined);
99
+ if (options.currentPath && await options.isValid(options.currentPath)) {
100
+ logDebug(`Using existing Godot path: ${options.currentPath}`);
101
+ return options.currentPath;
102
+ }
103
+ if (process.env.GODOT_PATH) {
104
+ const environmentPath = normalize(process.env.GODOT_PATH);
105
+ logDebug(`Checking GODOT_PATH environment variable: ${environmentPath}`);
106
+ if (await options.isValid(environmentPath)) {
107
+ logDebug(`Using Godot path from environment: ${environmentPath}`);
108
+ return environmentPath;
109
+ }
110
+ // An explicit executable selection is authoritative. Falling through to a
111
+ // different auto-detected engine makes a stale or deliberately invalid
112
+ // configuration silently target the wrong binary. GodotServer.run() applies
113
+ // the strict/compatibility policy after this returns: strict mode exits,
114
+ // while compatibility mode keeps structured tool failures available.
115
+ logDebug(`GODOT_PATH environment variable is invalid: ${environmentPath}`);
116
+ return environmentPath;
117
+ }
118
+ const platform = process.platform;
119
+ logDebug(`Auto-detecting Godot path for platform: ${platform}`);
120
+ const possiblePaths = getCandidatePaths(platform);
121
+ for (const candidate of possiblePaths) {
122
+ const normalizedPath = normalize(candidate);
123
+ if (await options.isValid(normalizedPath)) {
124
+ logDebug(`Found Godot at: ${normalizedPath}`);
125
+ return normalizedPath;
126
+ }
127
+ }
128
+ logDebug(`Warning: Could not find Godot in common locations for ${platform}`);
129
+ console.error(`[SERVER] Could not find Godot in common locations for ${platform}`);
130
+ console.error("[SERVER] Set GODOT_PATH=/path/to/godot environment variable or pass { godotPath: '/path/to/godot' } in the config to specify the correct path.");
131
+ if (options.strictPathValidation) {
132
+ throw new Error('Could not find a valid Godot executable. Set GODOT_PATH or provide a valid path in config.');
133
+ }
134
+ const fallback = normalize(platform === 'win32'
135
+ ? 'C:\\Program Files\\Godot\\Godot.exe'
136
+ : platform === 'darwin'
137
+ ? '/Applications/Godot.app/Contents/MacOS/Godot'
138
+ : '/usr/bin/godot');
139
+ logDebug(`Using default path: ${fallback}, but this may not work.`);
140
+ console.error(`[SERVER] Using default path: ${fallback}, but this may not work.`);
141
+ console.error('[SERVER] This fallback behavior will be removed in a future version. Set strictPathValidation: true to opt-in to the new behavior.');
142
+ return fallback;
143
+ }
144
+ function getCandidatePaths(platform) {
145
+ const paths = ['godot'];
146
+ if (platform === 'darwin') {
147
+ paths.push('/Applications/Godot.app/Contents/MacOS/Godot', '/Applications/Godot_4.app/Contents/MacOS/Godot', `${process.env.HOME}/Applications/Godot.app/Contents/MacOS/Godot`, `${process.env.HOME}/Applications/Godot_4.app/Contents/MacOS/Godot`, `${process.env.HOME}/Library/Application Support/Steam/steamapps/common/Godot Engine/Godot.app/Contents/MacOS/Godot`);
148
+ }
149
+ else if (platform === 'win32') {
150
+ paths.push('C:\\Program Files\\Godot\\Godot.exe', 'C:\\Program Files (x86)\\Godot\\Godot.exe', 'C:\\Program Files\\Godot_4\\Godot.exe', 'C:\\Program Files (x86)\\Godot_4\\Godot.exe', `${process.env.USERPROFILE}\\Godot\\Godot.exe`);
151
+ }
152
+ else if (platform === 'linux') {
153
+ paths.push('/usr/bin/godot', '/usr/local/bin/godot', '/snap/bin/godot', `${process.env.HOME}/.local/bin/godot`);
154
+ }
155
+ return paths;
156
+ }
@@ -0,0 +1,112 @@
1
+ import { spawn } from 'child_process';
2
+ import { GODOT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, GODOT_PROCESS_LOG_LINE_LIMIT, } from './godot-subprocess.js';
3
+ /** Owns the active Godot child process and its captured output. */
4
+ export class GodotProcessManager {
5
+ log;
6
+ logLineLimit;
7
+ gracefulShutdownTimeoutMs;
8
+ spawnProcess;
9
+ activeProcess = null;
10
+ lastErrorIndex = 0;
11
+ lastLogIndex = 0;
12
+ constructor(log = () => undefined, logLineLimit = GODOT_PROCESS_LOG_LINE_LIMIT, gracefulShutdownTimeoutMs = GODOT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, spawnProcess = spawn) {
13
+ this.log = log;
14
+ this.logLineLimit = logLineLimit;
15
+ this.gracefulShutdownTimeoutMs = gracefulShutdownTimeoutMs;
16
+ this.spawnProcess = spawnProcess;
17
+ }
18
+ get active() {
19
+ return this.activeProcess !== null;
20
+ }
21
+ start(options) {
22
+ const child = this.spawnProcess(options.executable, options.args, {
23
+ stdio: 'pipe',
24
+ env: options.env === undefined ? undefined : { ...process.env, ...options.env },
25
+ });
26
+ const record = { process: child, output: [], errors: [] };
27
+ this.activeProcess = record;
28
+ this.resetCursors();
29
+ child.stdout?.on('data', (data) => {
30
+ const lines = data.toString().split('\n');
31
+ this.appendLines(record.output, lines, 'logs');
32
+ for (const line of lines)
33
+ if (line.trim())
34
+ this.log(`[Godot stdout] ${line}`);
35
+ });
36
+ child.stderr?.on('data', (data) => {
37
+ const lines = data.toString().split('\n');
38
+ this.appendLines(record.errors, lines, 'errors');
39
+ for (const line of lines)
40
+ if (line.trim())
41
+ this.log(`[Godot stderr] ${line}`);
42
+ });
43
+ child.on('exit', (code) => {
44
+ this.log(`Godot process exited with code ${code}`);
45
+ if (this.activeProcess?.process === child)
46
+ this.activeProcess = null;
47
+ options.onExit?.(code);
48
+ });
49
+ child.on('error', (error) => {
50
+ if (this.activeProcess?.process === child)
51
+ this.activeProcess = null;
52
+ options.onError?.(error);
53
+ });
54
+ return record;
55
+ }
56
+ stop() {
57
+ const record = this.activeProcess;
58
+ if (!record)
59
+ return null;
60
+ this.activeProcess = null;
61
+ this.resetCursors();
62
+ let exited = false;
63
+ const forceKill = setTimeout(() => {
64
+ if (!exited) {
65
+ this.log(`Godot process did not exit after ${this.gracefulShutdownTimeoutMs}ms; forcing termination`);
66
+ record.process.kill('SIGKILL');
67
+ }
68
+ }, this.gracefulShutdownTimeoutMs);
69
+ if (typeof record.process.on === 'function') {
70
+ record.process.on('exit', () => {
71
+ exited = true;
72
+ clearTimeout(forceKill);
73
+ });
74
+ }
75
+ record.process.kill('SIGTERM');
76
+ return record;
77
+ }
78
+ seedActiveProcess(process) {
79
+ this.activeProcess = process;
80
+ }
81
+ readNewErrors(limit = 1000) {
82
+ if (!this.activeProcess)
83
+ return { items: [], remaining: 0 };
84
+ const end = Math.min(this.lastErrorIndex + limit, this.activeProcess.errors.length);
85
+ const items = this.activeProcess.errors.slice(this.lastErrorIndex, end);
86
+ this.lastErrorIndex = end;
87
+ return { items, remaining: this.activeProcess.errors.length - end };
88
+ }
89
+ readNewLogs(limit = 1000) {
90
+ if (!this.activeProcess)
91
+ return { items: [], remaining: 0 };
92
+ const end = Math.min(this.lastLogIndex + limit, this.activeProcess.output.length);
93
+ const items = this.activeProcess.output.slice(this.lastLogIndex, end);
94
+ this.lastLogIndex = end;
95
+ return { items, remaining: this.activeProcess.output.length - end };
96
+ }
97
+ resetCursors() {
98
+ this.lastErrorIndex = 0;
99
+ this.lastLogIndex = 0;
100
+ }
101
+ appendLines(target, lines, stream) {
102
+ target.push(...lines);
103
+ const excess = target.length - this.logLineLimit;
104
+ if (excess > 0) {
105
+ target.splice(0, excess);
106
+ if (stream === 'logs')
107
+ this.lastLogIndex = Math.max(0, this.lastLogIndex - excess);
108
+ else
109
+ this.lastErrorIndex = Math.max(0, this.lastErrorIndex - excess);
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,23 @@
1
+ /** Shared resource limits for short-lived Godot CLI invocations. */
2
+ export const GODOT_COMMAND_TIMEOUT_MS = 30_000;
3
+ export const GODOT_VERSION_TIMEOUT_MS = 10_000;
4
+ export const GODOT_EXPORT_TIMEOUT_MS = 120_000;
5
+ export const GODOT_COMMAND_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
6
+ export const GODOT_COMMAND_OPTIONS = {
7
+ timeout: GODOT_COMMAND_TIMEOUT_MS,
8
+ maxBuffer: GODOT_COMMAND_MAX_BUFFER_BYTES,
9
+ encoding: 'utf8',
10
+ };
11
+ export const GODOT_VERSION_OPTIONS = {
12
+ timeout: GODOT_VERSION_TIMEOUT_MS,
13
+ maxBuffer: GODOT_COMMAND_MAX_BUFFER_BYTES,
14
+ encoding: 'utf8',
15
+ };
16
+ export const GODOT_EXPORT_OPTIONS = {
17
+ timeout: GODOT_EXPORT_TIMEOUT_MS,
18
+ maxBuffer: GODOT_COMMAND_MAX_BUFFER_BYTES,
19
+ encoding: 'utf8',
20
+ };
21
+ /** Long-running game processes retain only recent output to protect server memory. */
22
+ export const GODOT_PROCESS_LOG_LINE_LIMIT = 1_000;
23
+ export const GODOT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;