@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.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { toolDefinitions } from './tool-definitions.js';
|
|
2
|
+
/** Combines domain-owned registries while rejecting duplicate or missing tools. */
|
|
3
|
+
export function composeToolHandlerRegistries(...registries) {
|
|
4
|
+
const handlers = {};
|
|
5
|
+
const toolNames = new Set(toolDefinitions.map(tool => tool.name));
|
|
6
|
+
for (const registry of registries) {
|
|
7
|
+
for (const [name, handler] of Object.entries(registry)) {
|
|
8
|
+
if (!toolNames.has(name)) {
|
|
9
|
+
throw new Error(`Unknown tool handler: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
if (handlers[name]) {
|
|
12
|
+
throw new Error(`Tool handler is registered more than once: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
handlers[name] = handler;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const missing = toolDefinitions
|
|
18
|
+
.map(tool => tool.name)
|
|
19
|
+
.filter(name => !handlers[name]);
|
|
20
|
+
if (missing.length > 0) {
|
|
21
|
+
throw new Error(`Missing tool handlers: ${missing.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
return handlers;
|
|
24
|
+
}
|
|
25
|
+
/** Builds the complete registry from independently maintained domain registries. */
|
|
26
|
+
export function createToolHandlers({ game, lifecycle, project, }) {
|
|
27
|
+
return composeToolHandlerRegistries(createLifecycleToolRegistry(lifecycle), createProjectToolRegistry(project), createGameToolRegistry(game));
|
|
28
|
+
}
|
|
29
|
+
export function createLifecycleToolRegistry(handlers) {
|
|
30
|
+
return {
|
|
31
|
+
'godot_tools': args => handlers.handleGodotTools(args),
|
|
32
|
+
'launch_editor': args => handlers.handleLaunchEditor(args),
|
|
33
|
+
'editor_control': args => handlers.handleEditorControl(args),
|
|
34
|
+
'run_project': args => handlers.handleRunProject(args),
|
|
35
|
+
'verify_project': args => handlers.handleVerifyProject(args),
|
|
36
|
+
'get_debug_output': () => handlers.handleGetDebugOutput(),
|
|
37
|
+
'stop_project': () => handlers.handleStopProject(),
|
|
38
|
+
'get_godot_version': () => handlers.handleGetGodotVersion(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function createProjectToolRegistry(handlers) {
|
|
42
|
+
return {
|
|
43
|
+
'run_project_tests': args => handlers.handleRunProjectTests(args),
|
|
44
|
+
'manage_import_pipeline': args => handlers.handleManageImportPipeline(args),
|
|
45
|
+
'analyze_project_integrity': args => handlers.handleAnalyzeProjectIntegrity(args),
|
|
46
|
+
'verify_export_readiness': args => handlers.handleVerifyExportReadiness(args),
|
|
47
|
+
'verify_dotnet_project': args => handlers.handleVerifyDotnetProject(args),
|
|
48
|
+
'manage_addon': args => handlers.handleManageAddon(args),
|
|
49
|
+
'list_projects': args => handlers.handleListProjects(args),
|
|
50
|
+
'get_project_info': args => handlers.handleGetProjectInfo(args),
|
|
51
|
+
'create_scene': args => handlers.handleCreateScene(args),
|
|
52
|
+
'add_node': args => handlers.handleAddNode(args),
|
|
53
|
+
'load_sprite': args => handlers.handleLoadSprite(args),
|
|
54
|
+
'export_mesh_library': args => handlers.handleExportMeshLibrary(args),
|
|
55
|
+
'save_scene': args => handlers.handleSaveScene(args),
|
|
56
|
+
'get_uid': args => handlers.handleGetUid(args),
|
|
57
|
+
'update_project_uids': args => handlers.handleUpdateProjectUids(args),
|
|
58
|
+
'read_scene': args => handlers.handleReadScene(args),
|
|
59
|
+
'modify_scene_node': args => handlers.handleModifySceneNode(args),
|
|
60
|
+
'remove_scene_node': args => handlers.handleRemoveSceneNode(args),
|
|
61
|
+
'read_project_settings': args => handlers.handleReadProjectSettings(args),
|
|
62
|
+
'modify_project_settings': args => handlers.handleModifyProjectSettings(args),
|
|
63
|
+
'list_project_files': args => handlers.handleListProjectFiles(args),
|
|
64
|
+
'attach_script': args => handlers.handleAttachScript(args),
|
|
65
|
+
'create_resource': args => handlers.handleCreateResource(args),
|
|
66
|
+
'read_file': args => handlers.handleReadFile(args),
|
|
67
|
+
'write_file': args => handlers.handleWriteFile(args),
|
|
68
|
+
'delete_file': args => handlers.handleDeleteFile(args),
|
|
69
|
+
'create_directory': args => handlers.handleCreateDirectory(args),
|
|
70
|
+
'create_project': args => handlers.handleCreateProject(args),
|
|
71
|
+
'create_csharp_script': args => handlers.handleCreateCsharpScript(args),
|
|
72
|
+
'manage_autoloads': args => handlers.handleManageAutoloads(args),
|
|
73
|
+
'manage_input_map': args => handlers.handleManageInputMap(args),
|
|
74
|
+
'manage_export_presets': args => handlers.handleManageExportPresets(args),
|
|
75
|
+
'export_project': args => handlers.handleExportProject(args),
|
|
76
|
+
'rename_file': args => handlers.handleRenameFile(args),
|
|
77
|
+
'manage_resource': args => handlers.handleManageResource(args),
|
|
78
|
+
'validate_script': args => handlers.handleValidateScript(args),
|
|
79
|
+
'validate_scripts': args => handlers.handleValidateScripts(args),
|
|
80
|
+
'create_script': args => handlers.handleCreateScript(args),
|
|
81
|
+
'manage_scene_signals': args => handlers.handleManageSceneSignals(args),
|
|
82
|
+
'manage_layers': args => handlers.handleManageLayers(args),
|
|
83
|
+
'manage_plugins': args => handlers.handleManagePlugins(args),
|
|
84
|
+
'manage_shader': args => handlers.handleManageShader(args),
|
|
85
|
+
'manage_theme_resource': args => handlers.handleManageThemeResource(args),
|
|
86
|
+
'set_main_scene': args => handlers.handleSetMainScene(args),
|
|
87
|
+
'manage_scene_structure': args => handlers.handleManageSceneStructure(args),
|
|
88
|
+
'manage_translations': args => handlers.handleManageTranslations(args),
|
|
89
|
+
'manage_ci_pipeline': args => handlers.handleManageCiPipeline(args),
|
|
90
|
+
'manage_docker_export': args => handlers.handleManageDockerExport(args),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function createGameToolRegistry(handlers) {
|
|
94
|
+
return {
|
|
95
|
+
'game_screenshot': () => handlers.handleGameScreenshot(),
|
|
96
|
+
'game_visual_regression': args => handlers.handleGameVisualRegression(args),
|
|
97
|
+
'game_click': args => handlers.handleGameClick(args),
|
|
98
|
+
'game_key_press': args => handlers.handleGameKeyPress(args),
|
|
99
|
+
'game_mouse_move': args => handlers.handleGameMouseMove(args),
|
|
100
|
+
'game_get_ui': () => handlers.handleGameGetUi(),
|
|
101
|
+
'game_get_scene_tree': args => handlers.handleGameGetSceneTree(args),
|
|
102
|
+
'game_eval': args => handlers.handleGameEval(args),
|
|
103
|
+
'game_get_property': args => handlers.handleGameGetProperty(args),
|
|
104
|
+
'game_set_property': args => handlers.handleGameSetProperty(args),
|
|
105
|
+
'game_call_method': args => handlers.handleGameCallMethod(args),
|
|
106
|
+
'game_get_node_info': args => handlers.handleGameGetNodeInfo(args),
|
|
107
|
+
'game_instantiate_scene': args => handlers.handleGameInstantiateScene(args),
|
|
108
|
+
'game_remove_node': args => handlers.handleGameRemoveNode(args),
|
|
109
|
+
'game_change_scene': args => handlers.handleGameChangeScene(args),
|
|
110
|
+
'game_pause': args => handlers.handleGamePause(args),
|
|
111
|
+
'game_performance': args => handlers.handleGamePerformance(args),
|
|
112
|
+
'game_wait': args => handlers.handleGameWait(args),
|
|
113
|
+
'game_connect_signal': args => handlers.handleGameConnectSignal(args),
|
|
114
|
+
'game_disconnect_signal': args => handlers.handleGameDisconnectSignal(args),
|
|
115
|
+
'game_emit_signal': args => handlers.handleGameEmitSignal(args),
|
|
116
|
+
'game_play_animation': args => handlers.handleGamePlayAnimation(args),
|
|
117
|
+
'game_tween_property': args => handlers.handleGameTweenProperty(args),
|
|
118
|
+
'game_get_nodes_in_group': args => handlers.handleGameGetNodesInGroup(args),
|
|
119
|
+
'game_find_nodes_by_class': args => handlers.handleGameFindNodesByClass(args),
|
|
120
|
+
'game_reparent_node': args => handlers.handleGameReparentNode(args),
|
|
121
|
+
'game_get_errors': args => handlers.handleGameGetErrors(args),
|
|
122
|
+
'game_get_logs': args => handlers.handleGameGetLogs(args),
|
|
123
|
+
'game_key_hold': args => handlers.handleGameKeyHold(args),
|
|
124
|
+
'game_key_release': args => handlers.handleGameKeyRelease(args),
|
|
125
|
+
'game_scroll': args => handlers.handleGameScroll(args),
|
|
126
|
+
'game_mouse_drag': args => handlers.handleGameMouseDrag(args),
|
|
127
|
+
'game_gamepad': args => handlers.handleGameGamepad(args),
|
|
128
|
+
'game_get_camera': () => handlers.handleGameGetCamera(),
|
|
129
|
+
'game_set_camera': args => handlers.handleGameSetCamera(args),
|
|
130
|
+
'game_raycast': args => handlers.handleGameRaycast(args),
|
|
131
|
+
'game_get_audio': () => handlers.handleGameGetAudio(),
|
|
132
|
+
'game_spawn_node': args => handlers.handleGameSpawnNode(args),
|
|
133
|
+
'game_set_shader_param': args => handlers.handleGameSetShaderParam(args),
|
|
134
|
+
'game_audio_play': args => handlers.handleGameAudioPlay(args),
|
|
135
|
+
'game_audio_bus': args => handlers.handleGameAudioBus(args),
|
|
136
|
+
'game_navigate_path': args => handlers.handleGameNavigatePath(args),
|
|
137
|
+
'game_tilemap': args => handlers.handleGameTilemap(args),
|
|
138
|
+
'game_add_collision': args => handlers.handleGameAddCollision(args),
|
|
139
|
+
'game_environment': args => handlers.handleGameEnvironment(args),
|
|
140
|
+
'game_manage_group': args => handlers.handleGameManageGroup(args),
|
|
141
|
+
'game_create_timer': args => handlers.handleGameCreateTimer(args),
|
|
142
|
+
'game_set_particles': args => handlers.handleGameSetParticles(args),
|
|
143
|
+
'game_create_animation': args => handlers.handleGameCreateAnimation(args),
|
|
144
|
+
'game_serialize_state': args => handlers.handleGameSerializeState(args),
|
|
145
|
+
'game_physics_body': args => handlers.handleGamePhysicsBody(args),
|
|
146
|
+
'game_create_joint': args => handlers.handleGameCreateJoint(args),
|
|
147
|
+
'game_bone_pose': args => handlers.handleGameBonePose(args),
|
|
148
|
+
'game_ui_theme': args => handlers.handleGameUiTheme(args),
|
|
149
|
+
'game_viewport': args => handlers.handleGameViewport(args),
|
|
150
|
+
'game_debug_draw': args => handlers.handleGameDebugDraw(args),
|
|
151
|
+
'game_http_request': args => handlers.handleGameHttpRequest(args),
|
|
152
|
+
'game_websocket': args => handlers.handleGameWebsocket(args),
|
|
153
|
+
'game_multiplayer': args => handlers.handleGameMultiplayer(args),
|
|
154
|
+
'game_rpc': args => handlers.handleGameRpc(args),
|
|
155
|
+
'game_touch': args => handlers.handleGameTouch(args),
|
|
156
|
+
'game_input_state': args => handlers.handleGameInputState(args),
|
|
157
|
+
'game_input_action': args => handlers.handleGameInputAction(args),
|
|
158
|
+
'game_list_signals': args => handlers.handleGameListSignals(args),
|
|
159
|
+
'game_await_signal': args => handlers.handleGameAwaitSignal(args),
|
|
160
|
+
'game_script': args => handlers.handleGameScript(args),
|
|
161
|
+
'game_window': args => handlers.handleGameWindow(args),
|
|
162
|
+
'game_os_info': args => handlers.handleGameOsInfo(args),
|
|
163
|
+
'game_time_scale': args => handlers.handleGameTimeScale(args),
|
|
164
|
+
'game_process_mode': args => handlers.handleGameProcessMode(args),
|
|
165
|
+
'game_world_settings': args => handlers.handleGameWorldSettings(args),
|
|
166
|
+
'game_csg': args => handlers.handleGameCsg(args),
|
|
167
|
+
'game_multimesh': args => handlers.handleGameMultimesh(args),
|
|
168
|
+
'game_procedural_mesh': args => handlers.handleGameProceduralMesh(args),
|
|
169
|
+
'game_light_3d': args => handlers.handleGameLight3d(args),
|
|
170
|
+
'game_mesh_instance': args => handlers.handleGameMeshInstance(args),
|
|
171
|
+
'game_gridmap': args => handlers.handleGameGridmap(args),
|
|
172
|
+
'game_3d_effects': args => handlers.handleGame3dEffects(args),
|
|
173
|
+
'game_gi': args => handlers.handleGameGi(args),
|
|
174
|
+
'game_path_3d': args => handlers.handleGamePath3d(args),
|
|
175
|
+
'game_sky': args => handlers.handleGameSky(args),
|
|
176
|
+
'game_camera_attributes': args => handlers.handleGameCameraAttributes(args),
|
|
177
|
+
'game_navigation_3d': args => handlers.handleGameNavigation3d(args),
|
|
178
|
+
'game_physics_3d': args => handlers.handleGamePhysics3d(args),
|
|
179
|
+
'game_canvas': args => handlers.handleGameCanvas(args),
|
|
180
|
+
'game_canvas_draw': args => handlers.handleGameCanvasDraw(args),
|
|
181
|
+
'game_light_2d': args => handlers.handleGameLight2d(args),
|
|
182
|
+
'game_parallax': args => handlers.handleGameParallax(args),
|
|
183
|
+
'game_shape_2d': args => handlers.handleGameShape2d(args),
|
|
184
|
+
'game_path_2d': args => handlers.handleGamePath2d(args),
|
|
185
|
+
'game_physics_2d': args => handlers.handleGamePhysics2d(args),
|
|
186
|
+
'game_animation_tree': args => handlers.handleGameAnimationTree(args),
|
|
187
|
+
'game_animation_control': args => handlers.handleGameAnimationControl(args),
|
|
188
|
+
'game_skeleton_ik': args => handlers.handleGameSkeletonIk(args),
|
|
189
|
+
'game_audio_effect': args => handlers.handleGameAudioEffect(args),
|
|
190
|
+
'game_audio_bus_layout': args => handlers.handleGameAudioBusLayout(args),
|
|
191
|
+
'game_audio_spatial': args => handlers.handleGameAudioSpatial(args),
|
|
192
|
+
'game_locale': args => handlers.handleGameLocale(args),
|
|
193
|
+
'game_ui_control': args => handlers.handleGameUiControl(args),
|
|
194
|
+
'game_ui_text': args => handlers.handleGameUiText(args),
|
|
195
|
+
'game_ui_popup': args => handlers.handleGameUiPopup(args),
|
|
196
|
+
'game_ui_tree': args => handlers.handleGameUiTree(args),
|
|
197
|
+
'game_ui_item_list': args => handlers.handleGameUiItemList(args),
|
|
198
|
+
'game_ui_tabs': args => handlers.handleGameUiTabs(args),
|
|
199
|
+
'game_ui_menu': args => handlers.handleGameUiMenu(args),
|
|
200
|
+
'game_ui_range': args => handlers.handleGameUiRange(args),
|
|
201
|
+
'game_render_settings': args => handlers.handleGameRenderSettings(args),
|
|
202
|
+
'game_resource': args => handlers.handleGameResource(args),
|
|
203
|
+
'game_visual_shader': args => handlers.handleGameVisualShader(args),
|
|
204
|
+
'game_terrain': args => handlers.handleGameTerrain(args),
|
|
205
|
+
'game_video': args => handlers.handleGameVideo(args),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EDITOR_BRIDGE_PROTOCOL_VERSION = '1';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { EDITOR_BRIDGE_PROTOCOL_VERSION } from './editor-bridge-protocol.js';
|
|
3
|
+
export class EditorBridgeCompatibilityError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Authenticated newline-delimited bridge to the optional EditorPlugin. */
|
|
6
|
+
export class EditorConnection {
|
|
7
|
+
options;
|
|
8
|
+
socket = null;
|
|
9
|
+
buffer = '';
|
|
10
|
+
nextId = 1;
|
|
11
|
+
pending = new Map();
|
|
12
|
+
connecting = null;
|
|
13
|
+
handshaking = null;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
async send(command, params = {}, timeoutMs = 10_000) {
|
|
18
|
+
await this.ensureConnected(timeoutMs);
|
|
19
|
+
if (command !== 'handshake')
|
|
20
|
+
await this.ensureHandshake(timeoutMs);
|
|
21
|
+
return this.request(command, params, timeoutMs);
|
|
22
|
+
}
|
|
23
|
+
request(command, params, timeoutMs) {
|
|
24
|
+
const id = this.nextId++;
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
this.pending.delete(id);
|
|
28
|
+
reject(new Error(`Editor request '${command}' timed out after ${timeoutMs / 1000}s`));
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
this.pending.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
31
|
+
this.socket.write(`${JSON.stringify({ id, command, params, secret: this.options.secret })}\n`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
disconnect() {
|
|
35
|
+
this.socket?.destroy();
|
|
36
|
+
this.socket = null;
|
|
37
|
+
this.buffer = '';
|
|
38
|
+
this.handshaking = null;
|
|
39
|
+
for (const resolve of this.pending.values()) {
|
|
40
|
+
resolve({ error: 'editor_disconnected' });
|
|
41
|
+
}
|
|
42
|
+
this.pending.clear();
|
|
43
|
+
}
|
|
44
|
+
async ensureHandshake(timeoutMs) {
|
|
45
|
+
if (this.handshaking)
|
|
46
|
+
return this.handshaking;
|
|
47
|
+
const expected = this.options.protocolVersion ?? EDITOR_BRIDGE_PROTOCOL_VERSION;
|
|
48
|
+
this.handshaking = this.request('handshake', {
|
|
49
|
+
protocol_version: expected,
|
|
50
|
+
server_version: this.options.serverVersion ?? 'unknown',
|
|
51
|
+
}, timeoutMs).then(result => {
|
|
52
|
+
if (result.error || result.protocol_version !== expected) {
|
|
53
|
+
const actual = typeof result.protocol_version === 'string' ? result.protocol_version : 'missing';
|
|
54
|
+
throw new EditorBridgeCompatibilityError(`Godot Agent Loop editor protocol is incompatible: server ${expected}, addon ${actual}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
await this.handshaking;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.disconnect();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async ensureConnected(timeoutMs) {
|
|
66
|
+
if (this.socket)
|
|
67
|
+
return;
|
|
68
|
+
if (this.connecting)
|
|
69
|
+
return this.connecting;
|
|
70
|
+
this.connecting = new Promise((resolve, reject) => {
|
|
71
|
+
const socket = createConnection({ host: '127.0.0.1', port: this.options.port });
|
|
72
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error('Editor bridge connection timed out')); }, timeoutMs);
|
|
73
|
+
socket.once('connect', () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
this.socket = socket;
|
|
76
|
+
socket.on('data', data => { this.receive(data.toString('utf8')); });
|
|
77
|
+
socket.on('close', () => { if (this.socket === socket)
|
|
78
|
+
this.disconnect(); });
|
|
79
|
+
socket.on('error', () => undefined);
|
|
80
|
+
resolve();
|
|
81
|
+
});
|
|
82
|
+
socket.once('error', error => { clearTimeout(timer); reject(error); });
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await this.connecting;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
this.connecting = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
receive(data) {
|
|
92
|
+
this.buffer += data;
|
|
93
|
+
while (true) {
|
|
94
|
+
const newline = this.buffer.indexOf('\n');
|
|
95
|
+
if (newline < 0)
|
|
96
|
+
return;
|
|
97
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
98
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
99
|
+
if (!line)
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(line);
|
|
103
|
+
const id = parsed.id;
|
|
104
|
+
if (typeof id === 'number')
|
|
105
|
+
this.pending.get(id)?.(parsed);
|
|
106
|
+
if (typeof id === 'number')
|
|
107
|
+
this.pending.delete(id);
|
|
108
|
+
}
|
|
109
|
+
catch { /* malformed bridge data is ignored; the request timeout is authoritative */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isToolCallMutating } from './tool-mutation-policy.js';
|
|
2
|
+
import { createErrorResponse } from './utils.js';
|
|
3
|
+
import { EditorBridgeCompatibilityError } from './editor-connection.js';
|
|
4
|
+
export const EDITOR_DRIVER_STATE_COMMAND = 'driver_state';
|
|
5
|
+
export const EDITOR_DRIVER_STATE_TIMEOUT_MS = 500;
|
|
6
|
+
export const AGENT_MUTATIONS_PAUSED_MESSAGE = 'Agent mutation refused: mutations are paused in the Godot editor. Use Resume Agent in the Agent Activity dock to continue.';
|
|
7
|
+
/** Cooperative, editor-owned gate applied after validation and before dispatch. */
|
|
8
|
+
export class EditorMutationGuard {
|
|
9
|
+
readDriverState;
|
|
10
|
+
constructor(readDriverState) {
|
|
11
|
+
this.readDriverState = readDriverState;
|
|
12
|
+
}
|
|
13
|
+
async check(name, args) {
|
|
14
|
+
if (!isToolCallMutating(name, args))
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const state = await this.readDriverState(EDITOR_DRIVER_STATE_COMMAND, {}, EDITOR_DRIVER_STATE_TIMEOUT_MS);
|
|
18
|
+
if (state.paused === true)
|
|
19
|
+
return createErrorResponse(AGENT_MUTATIONS_PAUSED_MESSAGE);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof EditorBridgeCompatibilityError) {
|
|
23
|
+
return createErrorResponse(`Agent mutation refused: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
// The addon is optional. No reachable editor means there is no human-held
|
|
26
|
+
// cooperative lock to honor, so normal unattended operation continues.
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { EDITOR_BRIDGE_PROTOCOL_VERSION } from './editor-bridge-protocol.js';
|
|
5
|
+
export const PERSISTENT_EDITOR_ADDON_DIR = 'addons/godot_agent_loop';
|
|
6
|
+
export const TRANSIENT_EDITOR_ADDON_DIR = 'addons/godot_agent_loop_transient';
|
|
7
|
+
const PERSISTENT_PLUGIN_NAME = 'godot_agent_loop';
|
|
8
|
+
const TRANSIENT_PLUGIN_NAME = 'godot_agent_loop_transient';
|
|
9
|
+
const OWNERSHIP_FILE = '.godot-agent-loop-owned.json';
|
|
10
|
+
const OWNERSHIP_ID = 'godot-agent-loop-server';
|
|
11
|
+
function hashFile(path) {
|
|
12
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
function parseConfigValue(source, key) {
|
|
15
|
+
const match = new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm').exec(source);
|
|
16
|
+
return match?.[1];
|
|
17
|
+
}
|
|
18
|
+
function enabledPlugins(source) {
|
|
19
|
+
const section = /(?:^|\n)\[editor_plugins\]\s*\n([\s\S]*?)(?=\n\[|$)/.exec(source);
|
|
20
|
+
const line = section?.[1].match(/^enabled\s*=\s*PackedStringArray\((.*)\)\s*$/m);
|
|
21
|
+
if (!line)
|
|
22
|
+
return [];
|
|
23
|
+
return [...line[1].matchAll(/"(?:\\.|[^"])*"/g)].map(match => JSON.parse(match[0]));
|
|
24
|
+
}
|
|
25
|
+
function withEnabledPlugin(source, pluginName) {
|
|
26
|
+
const plugins = enabledPlugins(source);
|
|
27
|
+
if (plugins.includes(pluginName))
|
|
28
|
+
return { content: source, changed: false };
|
|
29
|
+
plugins.push(pluginName);
|
|
30
|
+
return { content: writeEnabledPlugins(source, plugins), changed: true };
|
|
31
|
+
}
|
|
32
|
+
function withoutEnabledPlugin(source, pluginName, removeEmptySection) {
|
|
33
|
+
const plugins = enabledPlugins(source);
|
|
34
|
+
if (!plugins.includes(pluginName))
|
|
35
|
+
return source;
|
|
36
|
+
const remaining = plugins.filter(name => name !== pluginName);
|
|
37
|
+
if (removeEmptySection && remaining.length === 0) {
|
|
38
|
+
return source.replace(/(?:^|\n)\[editor_plugins\]\s*\n[\s\S]*?(?=\n\[|$)/, '');
|
|
39
|
+
}
|
|
40
|
+
return writeEnabledPlugins(source, remaining);
|
|
41
|
+
}
|
|
42
|
+
function writeEnabledPlugins(source, plugins) {
|
|
43
|
+
const value = `enabled=PackedStringArray(${plugins.map(name => JSON.stringify(name)).join(', ')})`;
|
|
44
|
+
const sectionPattern = /((?:^|\n)\[editor_plugins\]\s*\n)([\s\S]*?)(?=\n\[|$)/;
|
|
45
|
+
const section = sectionPattern.exec(source);
|
|
46
|
+
if (!section)
|
|
47
|
+
return `${source.replace(/\s*$/, '')}\n\n[editor_plugins]\n\n${value}\n`;
|
|
48
|
+
const body = section[2];
|
|
49
|
+
const nextBody = /^enabled\s*=.*$/m.test(body)
|
|
50
|
+
? body.replace(/^enabled\s*=.*$/m, value)
|
|
51
|
+
: `${body.replace(/\s*$/, '')}\n${value}\n`;
|
|
52
|
+
return source.replace(sectionPattern, `${section[1]}${nextBody}`);
|
|
53
|
+
}
|
|
54
|
+
export class EditorPluginInstaller {
|
|
55
|
+
scriptPath;
|
|
56
|
+
constructor(scriptPath) {
|
|
57
|
+
this.scriptPath = scriptPath;
|
|
58
|
+
}
|
|
59
|
+
install(projectPath) {
|
|
60
|
+
const projectFile = join(projectPath, 'project.godot');
|
|
61
|
+
const projectBefore = readFileSync(projectFile, 'utf8');
|
|
62
|
+
const persistentConfig = join(projectPath, PERSISTENT_EDITOR_ADDON_DIR, 'plugin.cfg');
|
|
63
|
+
const persistentBaseline = existsSync(persistentConfig)
|
|
64
|
+
? this.recoverOwnedTransient(projectPath, projectBefore)
|
|
65
|
+
: projectBefore;
|
|
66
|
+
const persistent = this.persistentInstallation(projectPath, persistentBaseline);
|
|
67
|
+
if (persistent)
|
|
68
|
+
return persistent;
|
|
69
|
+
return this.transientInstallation(projectPath, projectBefore);
|
|
70
|
+
}
|
|
71
|
+
remove(projectPath, installation) {
|
|
72
|
+
if (!installation)
|
|
73
|
+
return { filesRemoved: false, filesPreserved: false };
|
|
74
|
+
const projectFile = join(projectPath, 'project.godot');
|
|
75
|
+
if (installation.enabledByServer && existsSync(projectFile)) {
|
|
76
|
+
const current = readFileSync(projectFile, 'utf8');
|
|
77
|
+
const restored = current === installation.projectAfter
|
|
78
|
+
? installation.projectBefore
|
|
79
|
+
: withoutEnabledPlugin(current, installation.pluginName, !/(?:^|\n)\[editor_plugins\]\s*\n/.test(installation.projectBefore));
|
|
80
|
+
if (restored !== current)
|
|
81
|
+
writeFileSync(projectFile, restored, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
if (!installation.owned)
|
|
84
|
+
return { filesRemoved: false, filesPreserved: true };
|
|
85
|
+
const addon = join(projectPath, TRANSIENT_EDITOR_ADDON_DIR);
|
|
86
|
+
if (!this.isUnmodifiedOwnedTransient(addon))
|
|
87
|
+
return { filesRemoved: false, filesPreserved: existsSync(addon) };
|
|
88
|
+
rmSync(addon, { recursive: true });
|
|
89
|
+
return { filesRemoved: true, filesPreserved: false };
|
|
90
|
+
}
|
|
91
|
+
persistentInstallation(projectPath, projectBefore) {
|
|
92
|
+
const addon = join(projectPath, PERSISTENT_EDITOR_ADDON_DIR);
|
|
93
|
+
const configPath = join(addon, 'plugin.cfg');
|
|
94
|
+
if (!existsSync(configPath))
|
|
95
|
+
return undefined;
|
|
96
|
+
const config = readFileSync(configPath, 'utf8');
|
|
97
|
+
const script = parseConfigValue(config, 'script');
|
|
98
|
+
const protocolVersion = parseConfigValue(config, 'protocol_version');
|
|
99
|
+
if (!script || !existsSync(join(addon, script))) {
|
|
100
|
+
throw new Error('Godot Agent Loop persistent addon is incomplete: plugin script is missing');
|
|
101
|
+
}
|
|
102
|
+
if (protocolVersion !== EDITOR_BRIDGE_PROTOCOL_VERSION) {
|
|
103
|
+
throw new Error(`Godot Agent Loop editor protocol is incompatible: server ${EDITOR_BRIDGE_PROTOCOL_VERSION}, addon ${protocolVersion ?? 'missing'}`);
|
|
104
|
+
}
|
|
105
|
+
const enabled = withEnabledPlugin(projectBefore, PERSISTENT_PLUGIN_NAME);
|
|
106
|
+
if (enabled.changed)
|
|
107
|
+
writeFileSync(join(projectPath, 'project.godot'), enabled.content, 'utf8');
|
|
108
|
+
return {
|
|
109
|
+
distribution: 'persistent',
|
|
110
|
+
pluginName: PERSISTENT_PLUGIN_NAME,
|
|
111
|
+
protocolVersion,
|
|
112
|
+
owned: false,
|
|
113
|
+
enabledByServer: enabled.changed,
|
|
114
|
+
projectBefore,
|
|
115
|
+
projectAfter: enabled.content,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
recoverOwnedTransient(projectPath, projectSource) {
|
|
119
|
+
const addon = join(projectPath, TRANSIENT_EDITOR_ADDON_DIR);
|
|
120
|
+
if (!existsSync(addon))
|
|
121
|
+
return projectSource;
|
|
122
|
+
const marker = this.readUnmodifiedOwnedTransient(addon);
|
|
123
|
+
if (!marker) {
|
|
124
|
+
throw new Error(`Persistent addon cannot start while a user-managed path exists at ${TRANSIENT_EDITOR_ADDON_DIR}`);
|
|
125
|
+
}
|
|
126
|
+
const restored = marker.pluginEnabledBeforeInstall
|
|
127
|
+
? projectSource
|
|
128
|
+
: withoutEnabledPlugin(projectSource, TRANSIENT_PLUGIN_NAME, !marker.editorPluginsSectionExistedBeforeInstall);
|
|
129
|
+
if (restored !== projectSource)
|
|
130
|
+
writeFileSync(join(projectPath, 'project.godot'), restored, 'utf8');
|
|
131
|
+
rmSync(addon, { recursive: true });
|
|
132
|
+
return restored;
|
|
133
|
+
}
|
|
134
|
+
transientInstallation(projectPath, projectBefore) {
|
|
135
|
+
const addon = join(projectPath, TRANSIENT_EDITOR_ADDON_DIR);
|
|
136
|
+
let previousMarker;
|
|
137
|
+
if (existsSync(addon)) {
|
|
138
|
+
previousMarker = this.readUnmodifiedOwnedTransient(addon);
|
|
139
|
+
if (!previousMarker) {
|
|
140
|
+
throw new Error(`Refusing to overwrite user-managed editor addon at ${TRANSIENT_EDITOR_ADDON_DIR}`);
|
|
141
|
+
}
|
|
142
|
+
rmSync(addon, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
const pluginEnabledBeforeInstall = previousMarker?.pluginEnabledBeforeInstall
|
|
145
|
+
?? enabledPlugins(projectBefore).includes(TRANSIENT_PLUGIN_NAME);
|
|
146
|
+
const editorPluginsSectionExistedBeforeInstall = previousMarker?.editorPluginsSectionExistedBeforeInstall
|
|
147
|
+
?? /(?:^|\n)\[editor_plugins\]\s*\n/.test(projectBefore);
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(addon, { recursive: true });
|
|
150
|
+
copyFileSync(this.scriptPath, join(addon, 'plugin.gd'));
|
|
151
|
+
writeFileSync(join(addon, 'plugin.cfg'), [
|
|
152
|
+
'[plugin]',
|
|
153
|
+
'',
|
|
154
|
+
'name="Godot Agent Loop Transient Bridge"',
|
|
155
|
+
'description="Session-owned authenticated editor bridge"',
|
|
156
|
+
'author="Godot Agent Loop"',
|
|
157
|
+
'version="1.0.0"',
|
|
158
|
+
'script="plugin.gd"',
|
|
159
|
+
`protocol_version="${EDITOR_BRIDGE_PROTOCOL_VERSION}"`,
|
|
160
|
+
'',
|
|
161
|
+
].join('\n'));
|
|
162
|
+
const marker = {
|
|
163
|
+
owner: OWNERSHIP_ID,
|
|
164
|
+
protocolVersion: EDITOR_BRIDGE_PROTOCOL_VERSION,
|
|
165
|
+
pluginEnabledBeforeInstall,
|
|
166
|
+
editorPluginsSectionExistedBeforeInstall,
|
|
167
|
+
files: {
|
|
168
|
+
'plugin.gd': hashFile(join(addon, 'plugin.gd')),
|
|
169
|
+
'plugin.cfg': hashFile(join(addon, 'plugin.cfg')),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
writeFileSync(join(addon, OWNERSHIP_FILE), `${JSON.stringify(marker, null, 2)}\n`);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
rmSync(addon, { recursive: true, force: true });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
const enabled = withEnabledPlugin(projectBefore, TRANSIENT_PLUGIN_NAME);
|
|
179
|
+
const cleanupBaseline = previousMarker && !pluginEnabledBeforeInstall
|
|
180
|
+
? withoutEnabledPlugin(projectBefore, TRANSIENT_PLUGIN_NAME, !editorPluginsSectionExistedBeforeInstall)
|
|
181
|
+
: projectBefore;
|
|
182
|
+
try {
|
|
183
|
+
if (enabled.changed)
|
|
184
|
+
writeFileSync(join(projectPath, 'project.godot'), enabled.content, 'utf8');
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (this.isUnmodifiedOwnedTransient(addon))
|
|
188
|
+
rmSync(addon, { recursive: true });
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
distribution: 'transient',
|
|
193
|
+
pluginName: TRANSIENT_PLUGIN_NAME,
|
|
194
|
+
protocolVersion: EDITOR_BRIDGE_PROTOCOL_VERSION,
|
|
195
|
+
owned: true,
|
|
196
|
+
enabledByServer: enabled.changed || Boolean(previousMarker && !pluginEnabledBeforeInstall),
|
|
197
|
+
projectBefore: cleanupBaseline,
|
|
198
|
+
projectAfter: enabled.content,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
isUnmodifiedOwnedTransient(addon) {
|
|
202
|
+
return this.readUnmodifiedOwnedTransient(addon) !== undefined;
|
|
203
|
+
}
|
|
204
|
+
readUnmodifiedOwnedTransient(addon) {
|
|
205
|
+
const markerPath = join(addon, OWNERSHIP_FILE);
|
|
206
|
+
if (!existsSync(markerPath))
|
|
207
|
+
return undefined;
|
|
208
|
+
try {
|
|
209
|
+
const marker = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
210
|
+
if (marker.owner !== OWNERSHIP_ID
|
|
211
|
+
|| typeof marker.pluginEnabledBeforeInstall !== 'boolean'
|
|
212
|
+
|| typeof marker.editorPluginsSectionExistedBeforeInstall !== 'boolean')
|
|
213
|
+
return undefined;
|
|
214
|
+
return Object.entries(marker.files).every(([name, hash]) => (existsSync(join(addon, name)) && hashFile(join(addon, name)) === hash)) ? marker : undefined;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|