@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
@@ -875,8 +875,8 @@ func _cmd_pause(params: Dictionary) -> void:
875
875
  # --- Get Performance ---
876
876
  func _cmd_get_performance(params: Dictionary) -> void:
877
877
  var action: String = str(params.get("action", "sample"))
878
- if not ["sample", "start", "stop", "report", "leaks"].has(action):
879
- _send_response({"error": "action must be sample, start, stop, report, or leaks"})
878
+ if not ["sample", "start", "stop", "report", "stress", "leaks"].has(action):
879
+ _send_response({"error": "action must be sample, start, stop, report, stress, or leaks"})
880
880
  return
881
881
  if action == "start":
882
882
  _profile_active = true
@@ -911,6 +911,16 @@ func _cmd_get_performance(params: Dictionary) -> void:
911
911
  result["samples"] = samples
912
912
  result["profiling"] = _profile_active
913
913
  result["requested_sample_count"] = sample_count
914
+ result["timing_mode"] = OS.get_environment("GODOT_MCP_TIMING_MODE") if not OS.get_environment("GODOT_MCP_TIMING_MODE").is_empty() else "external"
915
+ result["distribution"] = _performance_distribution(samples)
916
+ result["metric_availability"] = {
917
+ "realtime_fps": {"available": true, "metric": "fps"},
918
+ "process_time": {"available": true, "metric": "process_time_ms"},
919
+ "rendering_time": {"available": true, "metric": "render_setup_cpu_ms", "scope": "CPU render setup only"},
920
+ "gpu_time": {"available": false, "reason": "Renderer/platform does not expose a stable GPU frame timer through the public runtime API"},
921
+ }
922
+ if action == "stress":
923
+ result["stress_window"] = _stress_comparison(samples)
914
924
  if action == "leaks":
915
925
  result["leak_diagnostics"] = {
916
926
  "object_count": result.get("object_count", 0),
@@ -926,7 +936,11 @@ func _performance_snapshot() -> Dictionary:
926
936
  return {
927
937
  "fps": Performance.get_monitor(Performance.TIME_FPS),
928
938
  "frame_time": Performance.get_monitor(Performance.TIME_PROCESS),
939
+ "process_time_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
929
940
  "physics_frame_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
941
+ "physics_process_time_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
942
+ "render_setup_cpu_ms": RenderingServer.get_frame_setup_time_cpu() * 1000.0,
943
+ "gpu_time_ms": null,
930
944
  "memory_static": Performance.get_monitor(Performance.MEMORY_STATIC),
931
945
  "memory_static_max": Performance.get_monitor(Performance.MEMORY_STATIC_MAX),
932
946
  "object_count": Performance.get_monitor(Performance.OBJECT_COUNT),
@@ -954,15 +968,75 @@ func _profile_report() -> Dictionary:
954
968
  "latest_object_count": latest.get("object_count", 0),
955
969
  "latest_orphan_node_count": latest.get("object_orphan_node_count", 0),
956
970
  }
971
+ report["distribution"] = _performance_distribution(_profile_samples)
972
+ report["metric_availability"] = {
973
+ "gpu_time": {"available": false, "reason": "Unavailable from the public runtime API on this renderer/platform"},
974
+ "render_setup_cpu": {"available": true},
975
+ }
957
976
  return report
958
977
 
959
978
 
979
+ func _performance_distribution(samples: Array[Dictionary]) -> Dictionary:
980
+ if samples.is_empty(): return {}
981
+ var fps_values: Array[float] = []
982
+ var frame_values: Array[float] = []
983
+ for sample: Dictionary in samples:
984
+ fps_values.append(_metric_float(sample.get("fps", 0.0)))
985
+ frame_values.append(_metric_float(sample.get("process_time_ms", 0.0)))
986
+ fps_values.sort()
987
+ frame_values.sort()
988
+ var percentile_index: int = mini(frame_values.size() - 1, floori(float(frame_values.size() - 1) * 0.95))
989
+ return {
990
+ "sample_count": samples.size(),
991
+ "fps_min": fps_values[0], "fps_max": fps_values[fps_values.size() - 1],
992
+ "fps_average": _array_average(fps_values),
993
+ "process_time_ms_min": frame_values[0], "process_time_ms_max": frame_values[frame_values.size() - 1],
994
+ "process_time_ms_average": _array_average(frame_values),
995
+ "process_time_ms_p95": frame_values[percentile_index],
996
+ }
997
+
998
+
999
+ func _stress_comparison(samples: Array[Dictionary]) -> Dictionary:
1000
+ if samples.size() < 3:
1001
+ return {"available": false, "reason": "stress comparison requires at least 3 samples"}
1002
+ var baseline: Dictionary = samples[0]
1003
+ var recovery: Dictionary = samples[samples.size() - 1]
1004
+ var peak_process_ms: float = 0.0
1005
+ var peak_objects: int = 0
1006
+ for sample: Dictionary in samples:
1007
+ peak_process_ms = maxf(peak_process_ms, _metric_float(sample.get("process_time_ms", 0.0)))
1008
+ peak_objects = maxi(peak_objects, _metric_int(sample.get("object_count", 0)))
1009
+ return {
1010
+ "available": true,
1011
+ "baseline": baseline,
1012
+ "peak": {"process_time_ms": peak_process_ms, "object_count": peak_objects},
1013
+ "recovery": recovery,
1014
+ "object_count_delta_recovery": _metric_int(recovery.get("object_count", 0)) - _metric_int(baseline.get("object_count", 0)),
1015
+ "process_time_ms_delta_recovery": _metric_float(recovery.get("process_time_ms", 0.0)) - _metric_float(baseline.get("process_time_ms", 0.0)),
1016
+ }
1017
+
1018
+
1019
+ func _array_average(values: Array[float]) -> float:
1020
+ var total: float = 0.0
1021
+ for value: float in values: total += value
1022
+ return total / values.size()
1023
+
1024
+
960
1025
  func _metric_float(value: Variant) -> float:
961
1026
  if value is int or value is float:
962
1027
  return value
963
1028
  return 0.0
964
1029
 
965
1030
 
1031
+ func _metric_int(value: Variant) -> int:
1032
+ if value is int:
1033
+ return value
1034
+ if value is float:
1035
+ var float_value: float = value
1036
+ return roundi(float_value)
1037
+ return 0
1038
+
1039
+
966
1040
  # --- Wait N Frames ---
967
1041
  func _cmd_wait(params: Dictionary) -> void:
968
1042
  var reader := CommandParams.new(params)
@@ -35,7 +35,8 @@ func _cmd_get_scene_tree(params: Dictionary) -> void:
35
35
  return
36
36
  var traversal: Dictionary = {"count": 0, "truncated": false, "max_nodes": max_nodes}
37
37
  var tree: Dictionary = _build_tree_node(get_tree().root, traversal)
38
- respond({"success": true, "tree": tree, "node_count": traversal["count"], "truncated": traversal["truncated"], "max_nodes": max_nodes})
38
+ respond({"success": true, "tree": tree, "node_count": traversal["count"], "truncated": traversal["truncated"], "max_nodes": max_nodes,
39
+ "current_scene": "" if get_tree().current_scene == null else get_tree().current_scene.scene_file_path})
39
40
 
40
41
  func _build_tree_node(node: Node, traversal: Dictionary) -> Dictionary:
41
42
  var count_value: Variant = traversal.get("count", 0)
@@ -77,6 +77,8 @@ func _cmd_time_scale(params: Dictionary) -> void:
77
77
  "success": true,
78
78
  "time_scale": Engine.time_scale,
79
79
  "fixed_fps": _configured_fixed_fps(),
80
+ "timing_mode": OS.get_environment("GODOT_MCP_TIMING_MODE") if not OS.get_environment("GODOT_MCP_TIMING_MODE").is_empty() else "external",
81
+ "display_pacing": OS.get_environment("GODOT_MCP_TIMING_MODE") == "realtime",
80
82
  "ticks_msec": Time.get_ticks_msec(),
81
83
  "fps": Engine.get_frames_per_second(),
82
84
  })
@@ -2,10 +2,10 @@
2
2
  * Initialization guidance is paid for in every MCP session. Keep this to the
3
3
  * durable operating method; detailed procedures belong in plugin skills.
4
4
  */
5
- export const SERVER_INSTRUCTIONS = `Build and verify Godot changes with an author → run → observe → assert loop.
5
+ export const SERVER_INSTRUCTIONS = `Use an author → run → observe → assert loop.
6
6
 
7
- 1. Author scenes, scripts, resources, and project settings with project tools.
8
- 2. Run with run_project, then observe through game_get_scene_tree, game_get_ui, game_screenshot, and get_debug_output.
9
- 3. Assert outcomes with verify_project or run_project_tests instead of assembling fragile manual checks. Stop the project when finished.
7
+ For watched work, call editor_session ensure and prefer editor_transaction or editor-routed tools. Report fallbacks and conflicts. Persist structure in scenes/resources and behavior in scripts; justify procedural construction.
10
8
 
11
- Prefer compound tools when they cover the task. Runtime bridge injection and cleanup are automatic; do not add MCP autoloads or addon files to the project. Reflection, code execution, and network tools are denied by default. For trusted local work, enable only required groups with GODOT_MCP_PRIVILEGED_GROUPS (reflection, code-execution, network), or explicitly enable all with GODOT_MCP_ALLOW_PRIVILEGED_COMMANDS=true.`;
9
+ Use realtime run_project when watched. Observe with scene/UI queries, screenshots, and logs. Assert with deterministic verify_project, game_wait_until, game_scenario, or run_project_tests. Stop and report diagnostics, unsupported metrics, and cleanup.
10
+
11
+ Prefer compound tools. Runtime injection and cleanup are automatic; never add MCP files. Reflection, code execution, and network are denied by default. Enable only needed GODOT_MCP_PRIVILEGED_GROUPS, or explicitly allow all with GODOT_MCP_ALLOW_PRIVILEGED_COMMANDS=true.`;
@@ -2,6 +2,7 @@
2
2
  export const GODOT_SESSION_FIXED_FPS = 60;
3
3
  export const GODOT_SESSION_INITIAL_TIME_SCALE = 1;
4
4
  export const GODOT_SESSION_FIXED_FPS_ENV = 'GODOT_MCP_FIXED_FPS';
5
+ export const GODOT_SESSION_TIMING_MODE_ENV = 'GODOT_MCP_TIMING_MODE';
5
6
  /**
6
7
  * Fixed FPS makes each rendered frame advance the same simulation delta.
7
8
  * max-fps retains a wall-clock cap so an idle persistent session does not spin
@@ -16,5 +17,20 @@ export function deterministicSessionArguments() {
16
17
  }
17
18
  /** Metadata mirrored into the child because Godot strips recognized CLI flags from script-visible args. */
18
19
  export function deterministicSessionEnvironment() {
19
- return { [GODOT_SESSION_FIXED_FPS_ENV]: String(GODOT_SESSION_FIXED_FPS) };
20
+ return {
21
+ [GODOT_SESSION_FIXED_FPS_ENV]: String(GODOT_SESSION_FIXED_FPS),
22
+ [GODOT_SESSION_TIMING_MODE_ENV]: 'deterministic',
23
+ };
24
+ }
25
+ /** Watched runs retain normal display/VSync pacing and do not force fixed simulation deltas. */
26
+ export function realtimeSessionArguments() {
27
+ return ['--time-scale', String(GODOT_SESSION_INITIAL_TIME_SCALE)];
28
+ }
29
+ export function realtimeSessionEnvironment() {
30
+ return { [GODOT_SESSION_FIXED_FPS_ENV]: '', [GODOT_SESSION_TIMING_MODE_ENV]: 'realtime' };
31
+ }
32
+ export function timingPolicy(mode) {
33
+ return mode === 'deterministic'
34
+ ? { mode, fixed_fps: GODOT_SESSION_FIXED_FPS, max_fps: GODOT_SESSION_FIXED_FPS, time_scale: 1, display_pacing: false }
35
+ : { mode, fixed_fps: null, max_fps: null, time_scale: 1, display_pacing: true };
20
36
  }
@@ -17,7 +17,7 @@ export const toolDefinitions = [
17
17
  },
18
18
  {
19
19
  name: 'launch_editor',
20
- description: 'Launch Godot editor for a specific project',
20
+ description: 'Attach to an existing matching editor or launch one when needed',
21
21
  inputSchema: {
22
22
  type: 'object',
23
23
  properties: {
@@ -29,6 +29,20 @@ export const toolDefinitions = [
29
29
  required: ['projectPath'],
30
30
  },
31
31
  },
32
+ {
33
+ name: 'editor_session',
34
+ description: 'Discover, attach, inspect, or disconnect a per-project Godot editor session',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ projectPath: { type: 'string', description: 'Godot project path' },
39
+ action: { type: 'string', enum: ['ensure', 'status', 'disconnect'], description: 'Session action' },
40
+ launchIfNeeded: { type: 'boolean', description: 'For ensure, launch an editor only after discovery finds none. Default: false' },
41
+ timeoutSeconds: { type: 'number', minimum: 0, maximum: 30, description: 'Bounded discovery/attach wait. Default: 2' },
42
+ },
43
+ required: ['projectPath', 'action'],
44
+ },
45
+ },
32
46
  {
33
47
  name: 'editor_control',
34
48
  description: 'Inspect and edit open scenes through an authenticated editor bridge',
@@ -47,6 +61,37 @@ export const toolDefinitions = [
47
61
  required: ['projectPath', 'action'],
48
62
  },
49
63
  },
64
+ {
65
+ name: 'editor_transaction',
66
+ description: 'Apply one validated compound scene edit as one editor undo step',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ projectPath: { type: 'string', description: 'Godot project path whose editor is attached' },
71
+ scenePath: { type: 'string', description: 'Project-relative or res:// scene path' },
72
+ name: { type: 'string', minLength: 1, maxLength: 128, description: 'Human-readable undo action name' },
73
+ rootType: { type: 'string', maxLength: 128, description: 'Root node type when creating a missing scene' },
74
+ operations: {
75
+ type: 'array', minItems: 1, maxItems: 256, description: 'Ordered editor-native scene operations',
76
+ items: {
77
+ type: 'object',
78
+ properties: {
79
+ op: { type: 'string', enum: ['add_node', 'remove_node', 'rename_node', 'duplicate_node', 'reparent_node', 'set_properties', 'instantiate_scene', 'attach_script', 'assign_resource', 'save'] },
80
+ nodePath: { type: 'string' }, parentPath: { type: 'string' }, newParentPath: { type: 'string' },
81
+ nodeType: { type: 'string' }, nodeName: { type: 'string' }, name: { type: 'string' },
82
+ properties: { type: 'object' }, property: { type: 'string' }, value: {},
83
+ scenePath: { type: 'string' }, scriptPath: { type: 'string' }, resourcePath: { type: 'string' },
84
+ keepGlobalTransform: { type: 'boolean' },
85
+ },
86
+ required: ['op'],
87
+ },
88
+ },
89
+ focusPath: { type: 'string', description: 'Node to reveal after commit' },
90
+ save: { type: 'boolean', description: 'Save and independently reopen/read the scene. Default: true' },
91
+ },
92
+ required: ['projectPath', 'scenePath', 'name', 'operations'],
93
+ },
94
+ },
50
95
  {
51
96
  name: 'run_project',
52
97
  description: 'Run the Godot project and capture output',
@@ -61,6 +106,10 @@ export const toolDefinitions = [
61
106
  type: 'string',
62
107
  description: 'Optional: Specific scene to run',
63
108
  },
109
+ timingMode: {
110
+ type: 'string', enum: ['realtime', 'deterministic'],
111
+ description: 'realtime follows display/VSync; deterministic uses fixed 60 FPS. Default: realtime',
112
+ },
64
113
  },
65
114
  required: ['projectPath'],
66
115
  },
@@ -143,6 +192,7 @@ export const toolDefinitions = [
143
192
  sourcePath: { type: 'string', description: 'Existing project-relative path for rename preview' },
144
193
  destinationPath: { type: 'string', description: 'Proposed project-relative rename destination' },
145
194
  maxFiles: { type: 'integer', minimum: 1, maximum: 50000, description: 'Resource scan limit. Default: 10000' },
195
+ allowProceduralMainScene: { type: 'boolean', description: 'Suppress the trivial-main-scene warning when procedural construction is an explicit design requirement. Default: false' },
146
196
  },
147
197
  required: ['projectPath', 'action'],
148
198
  },
@@ -426,10 +476,12 @@ export const toolDefinitions = [
426
476
  },
427
477
  {
428
478
  name: 'game_screenshot',
429
- description: 'Screenshot the running game (returns base64 PNG)',
479
+ description: 'Capture a PNG preview with dimensions, digest, and optional temp artifact',
430
480
  inputSchema: {
431
481
  type: 'object',
432
- properties: {},
482
+ properties: {
483
+ retainArtifact: { type: 'boolean', description: 'Retain a PNG in the system temp artifact directory. Default: false' },
484
+ },
433
485
  required: [],
434
486
  },
435
487
  },
@@ -704,7 +756,7 @@ export const toolDefinitions = [
704
756
  inputSchema: {
705
757
  type: 'object',
706
758
  properties: {
707
- action: { type: 'string', enum: ['sample', 'start', 'stop', 'report', 'leaks'], description: 'Profiler action. Default: sample' },
759
+ action: { type: 'string', enum: ['sample', 'start', 'stop', 'report', 'stress', 'leaks'], description: 'Profiler action. Default: sample' },
708
760
  sampleCount: { type: 'integer', minimum: 1, maximum: 120, description: 'Number of samples for a bounded session. Default: 1' },
709
761
  },
710
762
  required: [],
@@ -729,6 +781,47 @@ export const toolDefinitions = [
729
781
  required: [],
730
782
  },
731
783
  },
784
+ {
785
+ name: 'game_wait_until',
786
+ description: 'Wait once for a bounded runtime condition and return the last observation',
787
+ inputSchema: {
788
+ type: 'object',
789
+ properties: {
790
+ projectPath: { type: 'string', description: 'Godot project path for trace correlation' },
791
+ condition: { type: 'string', enum: ['connection', 'node', 'property', 'signal', 'log', 'scene'], description: 'Condition kind' },
792
+ nodePath: { type: 'string' }, property: { type: 'string' }, value: {}, signal: { type: 'string' },
793
+ text: { type: 'string', maxLength: 1000 }, scenePath: { type: 'string' },
794
+ timeoutSeconds: { type: 'number', minimum: 0.05, maximum: 60, description: 'Maximum wait. Default: 10' },
795
+ pollIntervalMs: { type: 'integer', minimum: 20, maximum: 1000, description: 'Internal poll interval. Default: 100' },
796
+ },
797
+ required: ['condition'],
798
+ },
799
+ },
800
+ {
801
+ name: 'game_scenario',
802
+ description: 'Run bounded input, wait, assertion, screenshot, and performance steps',
803
+ inputSchema: {
804
+ type: 'object',
805
+ properties: {
806
+ projectPath: { type: 'string', description: 'Godot project path for trace correlation' },
807
+ name: { type: 'string', minLength: 1, maxLength: 128 },
808
+ timeoutSeconds: { type: 'number', minimum: 0.1, maximum: 120, description: 'Whole scenario timeout. Default: 60' },
809
+ steps: {
810
+ type: 'array', minItems: 1, maxItems: 100,
811
+ items: {
812
+ type: 'object',
813
+ properties: {
814
+ type: { type: 'string', enum: ['input', 'wait', 'observe', 'assert', 'screenshot', 'performance'] },
815
+ tool: { type: 'string' }, arguments: { type: 'object' }, condition: { type: 'object' },
816
+ label: { type: 'string', maxLength: 200 },
817
+ },
818
+ required: ['type'],
819
+ },
820
+ },
821
+ },
822
+ required: ['name', 'steps'],
823
+ },
824
+ },
732
825
  {
733
826
  name: 'read_scene',
734
827
  description: 'Read scene file as JSON node tree (headless)',
@@ -2704,7 +2797,7 @@ export const toolDefinitions = [
2704
2797
  projectPath: { type: 'string', description: 'Absolute path to Godot project' },
2705
2798
  action: { type: 'string', enum: ['create', 'read'], description: 'Action: create or read' },
2706
2799
  platforms: { type: 'array', description: 'Target platforms: windows, linux, macos, web', items: { type: 'string', enum: ['windows', 'linux', 'macos', 'web'] } },
2707
- godotVersion: { type: 'string', pattern: '^4\\.\\d+(?:\\.\\d+)?(?:-stable)?$', description: 'Godot 4 version (e.g. 4.3-stable)' },
2800
+ godotVersion: { type: 'string', pattern: '^4\\.(?:[7-9]|\\d{2,})(?:\\.\\d+)?(?:-stable)?$', description: 'Supported Godot 4.7+ version (e.g. 4.7-stable)' },
2708
2801
  },
2709
2802
  required: ['projectPath', 'action'],
2710
2803
  },
@@ -2717,7 +2810,7 @@ export const toolDefinitions = [
2717
2810
  properties: {
2718
2811
  projectPath: { type: 'string', description: 'Absolute path to Godot project' },
2719
2812
  action: { type: 'string', enum: ['create', 'read'], description: 'Action: create or read' },
2720
- godotVersion: { type: 'string', pattern: '^4\\.\\d+(?:\\.\\d+)?(?:-stable)?$', description: 'Godot 4 version (e.g. 4.3-stable)' },
2813
+ godotVersion: { type: 'string', pattern: '^4\\.(?:[7-9]|\\d{2,})(?:\\.\\d+)?(?:-stable)?$', description: 'Supported Godot 4.7+ version (e.g. 4.7-stable)' },
2721
2814
  exportPreset: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9 _./-]{0,127}$', description: 'Export preset name (letters, digits, spaces, _, ., /, and -)' },
2722
2815
  baseImage: { type: 'string', enum: ['ubuntu:22.04', 'ubuntu:24.04'], description: 'Supported base Docker image (default: ubuntu:22.04)' },
2723
2816
  },
@@ -1,5 +1,9 @@
1
1
  import { createErrorResponse, errorMessage, normalizeParameters } from '../utils.js';
2
2
  import { VisualRegressionService } from './visual-regression-service.js';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
3
7
  /**
4
8
  * ParticleProcessMaterial fields the runtime reads, keyed by the camelCase name
5
9
  * the MCP surface uses. `processMaterial` is a free-form object, so its keys are
@@ -44,7 +48,7 @@ export class GameToolHandlers {
44
48
  };
45
49
  this.visualRegression = new VisualRegressionService(context.commands);
46
50
  }
47
- async handleGameScreenshot() {
51
+ async handleGameScreenshot(args = {}) {
48
52
  if (!this.context.commands.hasActiveProcess()) {
49
53
  return createErrorResponse('No active Godot process. Use run_project first.');
50
54
  }
@@ -57,6 +61,15 @@ export class GameToolHandlers {
57
61
  return createErrorResponse(`Screenshot failed: ${response.error.message}`);
58
62
  }
59
63
  const result = response.result;
64
+ const bytes = Buffer.from(result.data ?? '', 'base64');
65
+ const digest = createHash('sha256').update(bytes).digest('hex');
66
+ let artifactPath = null;
67
+ if (args.retainArtifact === true && bytes.length > 0) {
68
+ const artifactDirectory = join(tmpdir(), 'godot-agent-loop-artifacts');
69
+ mkdirSync(artifactDirectory, { recursive: true });
70
+ artifactPath = join(artifactDirectory, `${digest}.png`);
71
+ writeFileSync(artifactPath, bytes);
72
+ }
60
73
  return {
61
74
  content: [
62
75
  {
@@ -66,7 +79,11 @@ export class GameToolHandlers {
66
79
  },
67
80
  {
68
81
  type: 'text',
69
- text: `Screenshot captured: ${result.width}x${result.height}`,
82
+ text: JSON.stringify({
83
+ captured: bytes.length > 0, width: result.width, height: result.height,
84
+ bytes: bytes.length, sha256: digest, artifact_path: artifactPath,
85
+ project_artifact: false, visual_regression_metadata: null,
86
+ }, null, 2),
70
87
  },
71
88
  ],
72
89
  };
@@ -174,8 +191,8 @@ export class GameToolHandlers {
174
191
  async handleGamePerformance(args = {}) {
175
192
  args = normalizeParameters(args || {});
176
193
  const action = args.action ?? 'sample';
177
- if (!['sample', 'start', 'stop', 'report', 'leaks'].includes(action)) {
178
- return createErrorResponse('action must be sample, start, stop, report, or leaks.');
194
+ if (!['sample', 'start', 'stop', 'report', 'stress', 'leaks'].includes(action)) {
195
+ return createErrorResponse('action must be sample, start, stop, report, stress, or leaks.');
179
196
  }
180
197
  return this.context.gameCommand('get_performance', args, a => ({
181
198
  action: a.action ?? 'sample', sample_count: a.sampleCount ?? a.sample_count ?? 1, sampleCount: a.sampleCount ?? a.sample_count ?? 1,