@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
package/build/index.js CHANGED
@@ -27,7 +27,9 @@ import { GameToolHandlers } from './tool-handlers/game-tool-handlers.js';
27
27
  import { ProjectToolHandlers } from './tool-handlers/project-tool-handlers.js';
28
28
  import { LifecycleToolHandlers } from './tool-handlers/lifecycle-tool-handlers.js';
29
29
  import { ProjectSupport } from './project-support.js';
30
- import { EditorConnection } from './editor-connection.js';
30
+ import { canonicalProjectPath, EditorSessionRegistry } from './editor-session-registry.js';
31
+ import { EditorSyncQueue } from './editor-sync-queue.js';
32
+ import { EditorAuthoringRouter } from './editor-authoring-router.js';
31
33
  import { EditorPluginInstaller } from './editor-plugin-installer.js';
32
34
  import { PRIVILEGED_RUNTIME_GROUPS } from './runtime-protocol.js';
33
35
  import { AuthoringSessionManager } from './authoring-session-manager.js';
@@ -35,6 +37,9 @@ import { EditorMutationGuard } from './editor-mutation-guard.js';
35
37
  import { SERVER_INSTRUCTIONS } from './server-instructions.js';
36
38
  import { advertisedToolDefinitions } from './tool-surface.js';
37
39
  import { runOpenCodeSetup } from './opencode-setup.js';
40
+ import { LifecycleTrace } from './lifecycle-trace.js';
41
+ import { toolManifest } from './tool-manifest.js';
42
+ import { isToolCallMutating } from './tool-mutation-policy.js';
38
43
  // Check if debug mode is enabled
39
44
  const DEBUG_MODE = process.env.DEBUG === 'true';
40
45
  const ALLOW_PRIVILEGED_COMMANDS = process.env.GODOT_MCP_ALLOW_PRIVILEGED_COMMANDS === 'true';
@@ -66,14 +71,16 @@ function resolveRuntimePort() {
66
71
  }
67
72
  return parsed;
68
73
  }
69
- function resolveEditorPort() {
70
- const configured = process.env.GODOT_MCP_EDITOR_PORT;
71
- if (!configured)
72
- return 9091;
73
- const parsed = Number(configured);
74
- return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : 9091;
75
- }
76
74
  const pathSecurity = new PathSecurity();
75
+ function firstString(...values) {
76
+ return values.find((value) => typeof value === 'string' && value.length > 0);
77
+ }
78
+ function toResourcePath(path) {
79
+ return path.startsWith('res://') ? path : `res://${path.replace(/^\/+/, '')}`;
80
+ }
81
+ function normalizeFocusPath(path) {
82
+ return path.replace(/^\/?root\/?/, '').replace(/^\.\//, '') || '.';
83
+ }
77
84
  // Derive __filename and __dirname in ESM
78
85
  const __filename = fileURLToPath(import.meta.url);
79
86
  const __dirname = dirname(__filename);
@@ -113,14 +120,44 @@ export class GodotServer {
113
120
  authoringSession;
114
121
  headlessOperations;
115
122
  gameCommands;
116
- editorConnection = new EditorConnection({
117
- port: resolveEditorPort(), secret: RUNTIME_SECRET, serverVersion: SERVER_VERSION,
123
+ attachedEditorProjects = new Set();
124
+ lifecycleTrace = new LifecycleTrace({
125
+ onEvent: (projectPath, event) => {
126
+ if (!this.attachedEditorProjects.has(projectPath))
127
+ return;
128
+ void this.editorSessions.send(projectPath, 'activity', event, 1_000).catch(error => {
129
+ this.logDebug(`Editor trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`);
130
+ });
131
+ },
132
+ });
133
+ editorSessions = new EditorSessionRegistry({
134
+ serverVersion: SERVER_VERSION,
135
+ log: message => { this.logDebug(message); },
136
+ onStateChange: session => {
137
+ if (session.connected) {
138
+ const wasAttached = this.attachedEditorProjects.has(session.project_path);
139
+ this.attachedEditorProjects.add(session.project_path);
140
+ if (!wasAttached)
141
+ void this.replayEditorTrace(session.project_path);
142
+ }
143
+ else {
144
+ this.attachedEditorProjects.delete(session.project_path);
145
+ }
146
+ },
147
+ });
148
+ editorSync = new EditorSyncQueue({
149
+ send: (projectPath, params, timeoutMs) => this.editorSessions.send(projectPath, 'filesystem_changed', params, timeoutMs),
150
+ status: projectPath => this.editorSessions.status(projectPath),
118
151
  });
119
- editorMutationGuard = new EditorMutationGuard((command, params, timeoutMs) => this.editorConnection.send(command, params, timeoutMs));
152
+ editorAuthoring = new EditorAuthoringRouter({
153
+ status: projectPath => this.editorSessions.status(projectPath),
154
+ ensure: (projectPath, timeoutMs) => this.editorSessions.ensure(projectPath, timeoutMs),
155
+ send: (projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs),
156
+ });
157
+ editorMutationGuard = new EditorMutationGuard((projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs));
120
158
  toolRegistry = null;
121
159
  editorPluginInstaller;
122
- editorProjectPath = null;
123
- editorPluginInstallation = null;
160
+ editorPluginInstallations = new Map();
124
161
  strictPathValidation = false;
125
162
  tcpGameConnection = new GameConnection({
126
163
  port: resolveRuntimePort(),
@@ -190,8 +227,9 @@ export class GodotServer {
190
227
  canStart: () => this.activeProcess === null,
191
228
  onLifecycleEvent: event => { this.forwardEditorActivity(event); },
192
229
  onProjectWrite: event => {
193
- void this.editorConnection.send('filesystem_changed', { ...event }, 2_000).catch(() => undefined);
230
+ return this.editorSync.enqueue(event);
194
231
  },
232
+ tryEditorOperation: (command, params, projectPath) => this.editorAuthoring.tryExecute(command, params, projectPath),
195
233
  });
196
234
  this.headlessOperations = new HeadlessOperationService(this.operationRunner, pathSecurity, this.authoringSession);
197
235
  this.gameCommands = new GameCommandService(this.processManager, this.gameConnection);
@@ -238,17 +276,34 @@ export class GodotServer {
238
276
  getInteractionPort: () => this.gameConnection.interactionPort,
239
277
  getRuntimeEnvironment: () => ({ GODOT_MCP_RUNTIME_SECRET: RUNTIME_SECRET }),
240
278
  installEditorPlugin: projectPath => {
241
- if (this.editorProjectPath) {
242
- this.editorPluginInstaller.remove(this.editorProjectPath, this.editorPluginInstallation);
243
- this.editorPluginInstallation = null;
244
- }
245
- this.editorPluginInstallation = this.editorPluginInstaller.install(projectPath);
246
- this.editorProjectPath = projectPath;
247
- return this.editorPluginInstallation;
279
+ const previous = this.editorPluginInstallations.get(projectPath);
280
+ const installation = this.editorPluginInstaller.install(projectPath);
281
+ const cleanupInstallation = previous
282
+ && previous.pluginName === installation.pluginName
283
+ && previous.distribution === installation.distribution
284
+ && previous.enabledByServer
285
+ ? {
286
+ ...installation,
287
+ enabledByServer: true,
288
+ projectBefore: previous.projectBefore,
289
+ }
290
+ : installation;
291
+ this.editorPluginInstallations.set(projectPath, cleanupInstallation);
292
+ return installation;
248
293
  },
249
- removeEditorPlugin: (projectPath, installation) => { this.editorPluginInstaller.remove(projectPath, installation); },
250
- getEditorEnvironment: () => ({ GODOT_MCP_EDITOR_PORT: String(resolveEditorPort()), GODOT_MCP_EDITOR_SECRET: RUNTIME_SECRET }),
251
- sendEditorCommand: (command, params, timeoutMs) => this.editorConnection.send(command, params, timeoutMs),
294
+ removeEditorPlugin: (projectPath, installation) => {
295
+ this.editorPluginInstaller.remove(projectPath, installation);
296
+ this.editorPluginInstallations.delete(projectPath);
297
+ },
298
+ getEditorEnvironment: () => ({
299
+ ...(process.env.GODOT_MCP_EDITOR_START_PAUSED
300
+ ? { GODOT_MCP_EDITOR_START_PAUSED: process.env.GODOT_MCP_EDITOR_START_PAUSED }
301
+ : {}),
302
+ }),
303
+ ensureEditorSession: (projectPath, timeoutMs) => this.editorSessions.ensure(projectPath, timeoutMs),
304
+ getEditorSessionStatus: projectPath => this.editorSessions.status(projectPath),
305
+ disconnectEditorSession: projectPath => this.editorSessions.disconnect(projectPath),
306
+ sendEditorCommand: (projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs),
252
307
  isGameConnected: () => this.gameConnection.isConnected,
253
308
  sendGameCommand: (command, params, timeoutMs) => this.gameCommands.send(command, params, timeoutMs),
254
309
  dispatchTool: (name, args) => {
@@ -288,7 +343,90 @@ export class GodotServer {
288
343
  }
289
344
  }
290
345
  forwardEditorActivity(event) {
291
- void this.editorConnection.send('activity', { ...event }, 1_000).catch(() => undefined);
346
+ const activeProjectPath = this.gameConnection.connectedProjectPath ?? this.authoringSession.activeProjectPath;
347
+ if (!activeProjectPath)
348
+ return;
349
+ const projectPath = this.traceProjectPath(activeProjectPath);
350
+ this.lifecycleTrace.record(projectPath, {
351
+ correlation_id: event.correlation_id,
352
+ tool: event.command,
353
+ command: event.command,
354
+ target_backend: event.target,
355
+ phase: event.event === 'request_started' ? 'start' : 'finish',
356
+ outcome: event.event === 'request_started' ? 'running'
357
+ : event.event === 'request_timed_out' ? 'timeout'
358
+ : event.outcome === 'success' ? 'success' : 'failure',
359
+ duration_ms: event.duration_ms ?? 0,
360
+ source: 'automatic',
361
+ });
362
+ }
363
+ async replayEditorTrace(projectPath) {
364
+ for (const event of this.lifecycleTrace.events(projectPath)) {
365
+ try {
366
+ await this.editorSessions.send(projectPath, 'activity', { ...event, replayed: true }, 1_000);
367
+ }
368
+ catch (error) {
369
+ this.logDebug(`Editor trace replay stopped: ${error instanceof Error ? error.message : String(error)}`);
370
+ return;
371
+ }
372
+ }
373
+ }
374
+ traceProjectPath(projectPath) {
375
+ try {
376
+ return canonicalProjectPath(projectPath);
377
+ }
378
+ catch {
379
+ return projectPath;
380
+ }
381
+ }
382
+ async synchronizePersistentToolMutation(toolName, args, projectPath, response) {
383
+ const nestedTool = toolName === 'godot_tools' && args.action === 'call' && typeof args.toolName === 'string'
384
+ ? args.toolName
385
+ : toolName;
386
+ const nestedArgs = nestedTool === toolName
387
+ ? args
388
+ : args.arguments && typeof args.arguments === 'object' ? args.arguments : {};
389
+ const manifest = toolManifest[nestedTool];
390
+ if (!manifest || manifest.domain !== 'project' || !isToolCallMutating(nestedTool, nestedArgs)
391
+ || response.isError === true)
392
+ return response;
393
+ const existingText = response.content
394
+ .filter(item => item.type === 'text' && typeof item.text === 'string')
395
+ .map(item => String(item.text)).join('\n');
396
+ if (/"backend"\s*:\s*"editor"|"sync_status"\s*:/.test(existingText))
397
+ return response;
398
+ const sceneCandidate = firstString(nestedArgs.scenePath, nestedArgs.newPath, nestedArgs.filePath);
399
+ const scenePath = sceneCandidate && /\.tscn$/i.test(sceneCandidate) ? toResourcePath(sceneCandidate) : undefined;
400
+ const resourceCandidate = scenePath ? undefined : firstString(nestedArgs.resourcePath, nestedArgs.scriptPath, nestedArgs.shaderPath, nestedArgs.themePath, nestedArgs.texturePath, nestedArgs.translationPath, nestedArgs.newPath, nestedArgs.filePath);
401
+ const sync = await this.editorSync.enqueue({
402
+ project_path: this.traceProjectPath(projectPath),
403
+ command: nestedTool,
404
+ ...(scenePath ? { scene_path: scenePath } : {}),
405
+ ...(resourceCandidate ? { resource_path: toResourcePath(resourceCandidate) }
406
+ : { resource_path: 'res://project.godot' }),
407
+ ...(typeof nestedArgs.nodePath === 'string' ? { focus_path: normalizeFocusPath(nestedArgs.nodePath) } : {}),
408
+ });
409
+ const backend = manifest.backend.kind === 'authoring-session' ? 'subprocess'
410
+ : manifest.backend.kind === 'local' ? 'file-backed'
411
+ : manifest.backend.kind;
412
+ const metadata = { backend, ...sync };
413
+ const content = response.content.map(item => {
414
+ if (item.type !== 'text' || typeof item.text !== 'string')
415
+ return item;
416
+ try {
417
+ const parsed = JSON.parse(item.text);
418
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
419
+ return { ...item, text: JSON.stringify({ ...parsed, ...metadata }, null, 2) };
420
+ }
421
+ }
422
+ catch { /* prose responses receive a separate bounded metadata block */ }
423
+ return item;
424
+ });
425
+ const merged = content.some(item => item.type === 'text' && typeof item.text === 'string'
426
+ && /"sync_status"\s*:/.test(item.text));
427
+ if (!merged)
428
+ content.push({ type: 'text', text: `Mutation metadata: ${JSON.stringify(metadata)}` });
429
+ return { ...response, content };
292
430
  }
293
431
  /**
294
432
  * Log debug messages if debug mode is enabled
@@ -376,9 +514,11 @@ export class GodotServer {
376
514
  this.logDebug('Cleaning up resources');
377
515
  this.authoringSession.stop();
378
516
  this.disconnectFromGame();
379
- this.editorConnection.disconnect();
380
- if (this.editorProjectPath)
381
- this.editorPluginInstaller.remove(this.editorProjectPath, this.editorPluginInstallation);
517
+ this.editorSessions.disconnectAll();
518
+ for (const [projectPath, installation] of this.editorPluginInstallations) {
519
+ this.editorPluginInstaller.remove(projectPath, installation);
520
+ }
521
+ this.editorPluginInstallations.clear();
382
522
  if (this.gameConnection.connectedProjectPath) {
383
523
  this.removeInteractionServer(this.gameConnection.connectedProjectPath);
384
524
  this.gameConnection.clearConnectedProject();
@@ -416,7 +556,46 @@ export class GodotServer {
416
556
  }));
417
557
  this.server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
418
558
  this.logDebug(`Handling tool request: ${request.params.name}`);
419
- return tools.dispatch(request.params.name, request.params.arguments);
559
+ const argumentsValue = request.params.arguments ?? {};
560
+ const nestedArguments = argumentsValue.arguments && typeof argumentsValue.arguments === 'object'
561
+ ? argumentsValue.arguments
562
+ : null;
563
+ const explicitProject = typeof argumentsValue.projectPath === 'string'
564
+ ? argumentsValue.projectPath
565
+ : typeof nestedArguments?.projectPath === 'string' ? nestedArguments.projectPath : null;
566
+ const projectPath = explicitProject
567
+ ?? this.gameConnection.connectedProjectPath
568
+ ?? this.authoringSession.activeProjectPath;
569
+ const toolName = request.params.name;
570
+ const manifest = toolManifest[toolName];
571
+ const span = projectPath
572
+ ? this.lifecycleTrace.begin(this.traceProjectPath(projectPath), toolName, toolName, manifest.backend.kind)
573
+ : null;
574
+ try {
575
+ let response = await tools.dispatch(toolName, argumentsValue);
576
+ if (projectPath) {
577
+ response = await this.synchronizePersistentToolMutation(toolName, argumentsValue, projectPath, response);
578
+ }
579
+ if (span) {
580
+ const firstText = response.content.find(item => item.type === 'text' && 'text' in item);
581
+ const text = firstText && 'text' in firstText && typeof firstText.text === 'string' ? firstText.text : '';
582
+ let outcome = response.isError === true ? 'failure' : 'success';
583
+ if (/unsaved_conflict|"sync_status"\s*:\s*"conflict"/.test(text))
584
+ outcome = 'conflict';
585
+ else if (/"sync_status"\s*:\s*"(?:detached|failed)"|"fallback_reason"\s*:\s*(?!null\b)/.test(text)) {
586
+ outcome = 'fallback';
587
+ }
588
+ else if (/paused/i.test(text) && response.isError === true)
589
+ outcome = 'paused';
590
+ this.lifecycleTrace.finish(span, outcome, { is_error: response.isError === true });
591
+ }
592
+ return response;
593
+ }
594
+ catch (error) {
595
+ if (span)
596
+ this.lifecycleTrace.finish(span, 'failure', { error: error instanceof Error ? error.message : String(error) });
597
+ throw error;
598
+ }
420
599
  });
421
600
  }
422
601
  async run() {
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Bounded, correlated, per-project lifecycle evidence with secret-safe payloads. */
3
+ export class LifecycleTrace {
4
+ options;
5
+ buffers = new Map();
6
+ capacity;
7
+ now;
8
+ nextEventId;
9
+ constructor(options = {}) {
10
+ this.options = options;
11
+ this.capacity = Math.max(200, options.capacity ?? 200);
12
+ this.now = options.now ?? Date.now;
13
+ this.nextEventId = Math.floor(this.now() * 1_000);
14
+ }
15
+ begin(projectPath, tool, command, backend) {
16
+ const span = {
17
+ projectPath, correlationId: randomUUID(), tool, command, backend, startedAt: this.now(),
18
+ };
19
+ this.record(projectPath, {
20
+ correlation_id: span.correlationId, tool, command, target_backend: backend,
21
+ phase: 'start', outcome: 'running', duration_ms: 0, source: 'agent',
22
+ });
23
+ return span;
24
+ }
25
+ finish(span, outcome, details) {
26
+ return this.record(span.projectPath, {
27
+ correlation_id: span.correlationId, tool: span.tool, command: span.command,
28
+ target_backend: span.backend, phase: 'finish', outcome,
29
+ duration_ms: Math.max(0, this.now() - span.startedAt), source: 'agent',
30
+ ...(details === undefined ? {} : { details }),
31
+ });
32
+ }
33
+ record(projectPath, event) {
34
+ const safe = redactAndBound(event);
35
+ const full = {
36
+ ...safe,
37
+ event_id: this.nextEventId++,
38
+ timestamp: new Date(this.now()).toISOString(),
39
+ };
40
+ const buffer = this.buffers.get(projectPath) ?? [];
41
+ buffer.push(full);
42
+ if (buffer.length > this.capacity)
43
+ buffer.splice(0, buffer.length - this.capacity);
44
+ this.buffers.set(projectPath, buffer);
45
+ this.options.onEvent?.(projectPath, full);
46
+ return full;
47
+ }
48
+ events(projectPath) {
49
+ return (this.buffers.get(projectPath) ?? []).map(event => ({ ...event }));
50
+ }
51
+ }
52
+ export function redactAndBound(value, depth = 0) {
53
+ if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number')
54
+ return value;
55
+ if (typeof value === 'string') {
56
+ if (looksLikeLargeBase64(value))
57
+ return `[binary omitted: ${value.length} characters]`;
58
+ return value.length > 500 ? `${value.slice(0, 497)}...` : value;
59
+ }
60
+ if (depth >= 4)
61
+ return '[depth omitted]';
62
+ if (Array.isArray(value)) {
63
+ const bounded = value.slice(0, 20).map(item => redactAndBound(item, depth + 1));
64
+ if (value.length > 20)
65
+ bounded.push(`[${value.length - 20} more items]`);
66
+ return bounded;
67
+ }
68
+ if (typeof value === 'object') {
69
+ const result = {};
70
+ for (const [key, item] of Object.entries(value).slice(0, 40)) {
71
+ result[key] = SECRET_KEY.test(key) ? '[redacted]' : redactAndBound(item, depth + 1);
72
+ }
73
+ return result;
74
+ }
75
+ return typeof value === 'symbol' ? value.description ?? 'symbol' : typeof value;
76
+ }
77
+ const SECRET_KEY = /(?:^|_)(?:token|secret|password|credential|authorization|api_?key|private_?key)(?:$|_)/i;
78
+ function looksLikeLargeBase64(value) {
79
+ return value.length > 1_024 && /^[A-Za-z0-9+/=_-]+$/.test(value);
80
+ }