# Implementation Plan — Sprint 5b: Editor Exec + Save Scene

## Overview

GodotIQ currently has 27 MCP tools. The `godotiq_exec` tool only works game-side — code runs in a RefCounted sandbox with no access to `get_tree()`, `EditorInterface`, or scene nodes. There's also no way for an agent to save its changes after modifying a scene via `node_ops`.

Sprint 5b adds:
1. **Editor-side execution** — A `context` parameter on `godotiq_exec` that routes code to run directly in the editor process with full access to the scene tree, EditorInterface, and all editor APIs.
2. **Save scene** — A dedicated `godotiq_save_scene` tool that calls `EditorInterface.save_scene()` to persist changes to disk.

After this sprint: 29 MCP tools (27 + save_scene; exec upgrade is an enhancement, not a new tool).

---

## Architecture

### Editor Exec: Dual-Context Execution

The existing `godotiq_exec` tool gains a `context` parameter:

- **`context="game"` (default, backward compatible)**: Python sends `bridge.request("exec", ...)` → WebSocket → `godotiq_server.gd` forwards via `_handle_game_forward` → EngineDebugger → `godotiq_runtime.gd` handles it in the game process. This path is completely unchanged.

- **`context="editor"` (new)**: Python sends `bridge.request("exec_editor", ...)` → WebSocket → `godotiq_server.gd` handles it directly. A temporary GDScript is compiled in-memory with `@tool` + `extends Node`, instantiated via `script.new()`, added as a child of the server node (giving it `get_tree()` access), its `run()` method is called, then it's cleaned up. This is a high-trust operation — editor exec has full access to the editor process.

The dispatch table gets a new `"exec_editor"` entry. The existing `"exec"` entry stays untouched.

### Save Scene: Editor-Side Handler

`godotiq_save_scene` sends `bridge.request("save_scene", ...)` → `godotiq_server.gd` calls `EditorInterface.save_scene()`. In-place save only — `save_scene_as(path)` does not exist in Godot 4's EditorInterface API. The response includes the scene path, scene name, and whether save succeeded.

### Dynamic Script Compilation in @tool Mode

**Godot Bug #87046**: Dynamically created GDScripts don't work in @tool mode if the source code lacks the `@tool` annotation. The workaround (confirmed by the Godot community) is to prepend `@tool` to the dynamic source code.

Our implementation prepends `"@tool\nextends Node\n\n"` to user code, which should avoid this bug. As insurance, a file-based fallback writes the script to a temp file, loads it via `load()`, and cleans up afterward. The fallback is synchronous (no `await`).

**Why `extends Node`**: The dynamic script extends Node (not RefCounted or EditorScript) so it can be `add_child()`'d to the server node, giving it access to `get_tree()`, which is essential for accessing the edited scene root, editor nodes, and other tree-dependent APIs.

---

## Detailed Design

### 1. Python: Modify `exec_code.py`

**Current signature**: `async def exec_code(code: str = "", timeout_ms: int = 5000) -> dict`

**New signature**: `async def exec_code(code: str = "", context: str = "game", timeout_ms: int = 5000) -> dict`

Note: `context="game"` is the default to maintain backward compatibility with existing callers.

Behavior:
- Validate `context` is `"editor"` or `"game"`, return error otherwise
- Empty code check remains
- If `context == "editor"`: call `bridge.request("exec_editor", params={code, timeout_ms}, timeout=10.0)`
- If `context == "game"`: call `bridge.request("exec", params={code, timeout_ms}, timeout=10.0)` (existing behavior)

The docstring should include editor-context examples (accessing `get_tree().edited_scene_root`, `EditorInterface`, node positions) and game-context examples (Engine API calls).

### 2. Python: Create `save_scene.py`

New module following the existing bridge tool pattern.

```python
async def save_scene() -> dict:
    """Save the currently open scene in the Godot editor."""
```

No parameters — in-place save only. Calls `bridge.request("save_scene", params={}, timeout=5.0)`.

### 3. Python: Update `bridge/__init__.py` Registration

**Modify existing `godotiq_exec` registration**:
- Add `context: str = "game"` parameter (default game for backward compatibility)
- Update docstring to document both contexts
- Pass `context` to the underlying `_exec_code()` call

**Add new `godotiq_save_scene` registration**:
- `@mcp.tool(name="godotiq_save_scene", annotations={readOnlyHint: False, destructiveHint: False, idempotentHint: True, openWorldHint: False})`
- Standard bridge error handling wrapper (BridgeConnectionError, BridgeTimeoutError, BridgeError)
- No parameters (save in place)

### 4. GDScript: Add `_handle_exec_editor()` to `godotiq_server.gd`

**Dispatch entry**: `"exec_editor": _handle_exec_editor(peer_id, id, params)`

**Handler logic**:
1. Extract `code` and `timeout_ms` from params
2. Validate code is non-empty
3. Validate code *contains* `func run():` or `func run() ->` (not starts_with — allows helper functions, constants, and comments before run())
4. Check blocked patterns: `DirAccess.remove`, `OS.execute`, `OS.kill`, `OS.shell_open` (NOT FileAccess — editor needs file access)
5. Compile: `GDScript.new()`, set `source_code` to `"@tool\nextends Node\n\n" + code`, call `reload()`
6. If compilation fails → return `COMPILE_ERROR`
7. Instantiate: `script.new()`
8. If null (Godot bug) → fall back to `_exec_editor_via_file(code)`
9. `add_child(obj)` → call `obj.run()` → capture result → **always** `remove_child(obj)` → `obj.queue_free()` (cleanup must run regardless of run() success/failure — GDScript has no try/catch, but errors are logged without crashing; structure cleanup as unconditional)
10. Return `{status: "OK", result: str(result), error: ""}`

**Fallback `_exec_editor_via_file(code)`**:
1. Write `"@tool\nextends Node\n\n" + code` to `res://addons/godotiq/_temp_exec_%d.gd` (unique counter to avoid race conditions with concurrent requests)
2. `load(tmp_path)` → instantiate → `add_child` → `run()` → cleanup → `DirAccess.remove_absolute(tmp_path)`
3. Synchronous — no `await`. If `load()` returns null, return error suggesting retry.

**GDScript traps to avoid**:
- No Python ternaries (`x if cond else y`)
- Use `%` string formatting, not f-strings
- Use `is_empty()` not `== ""`
- `var result_str: String` with explicit if/else for null check (not ternary)

### 5. GDScript: Add `_handle_save_scene()` to `godotiq_server.gd`

**Dispatch entry**: `"save_scene": _handle_save_scene(peer_id, id, params)`

**Handler logic**:
1. Get `EditorInterface.get_edited_scene_root()` — if null, return `NO_SCENE` error
2. Call `EditorInterface.save_scene()` — returns Error code (may vary across Godot versions)
3. Check error code defensively — if return value is an Error and not OK, return error; if void/null, treat as success
4. Return `{saved: true, scene_path: scene_root.scene_file_path, scene_name: scene_root.name}`

### 6. Update MCP Prompt

Modify `src/godotiq/prompts/godot_development.md`:
- Update `godotiq_exec` description to document the `context` parameter
- Add `godotiq_save_scene` to the EDIT tools category
- Update the "Moving/Placing Objects" workflow to include save_scene as step 5
- Update tool count references (29 total)
- Add guidance: "Prefer dedicated tools (node_ops, script_ops, file_ops) over exec_editor. Use exec_editor only for operations not covered by other tools."

---

## File Changes Summary

```
src/godotiq/tools/bridge/
  exec_code.py                    # MODIFY — add context parameter
  save_scene.py                   # CREATE — new tool
  __init__.py                     # MODIFY — update exec registration, add save_scene

godot-addon/addons/godotiq/
  godotiq_server.gd               # MODIFY — add exec_editor + save_scene dispatch & handlers

src/godotiq/prompts/
  godot_development.md            # MODIFY — document new capabilities

tests/test_bridge/
  test_exec_editor.py             # CREATE — 7 test cases
  test_save_scene.py              # CREATE — 3-4 test cases
```

---

## Testing Strategy

### Unit Tests (Python, mocked bridge)

**test_exec_editor.py** — 7 cases:
1. Default context is "game" (backward compatible, sends "exec" to bridge)
2. Explicit editor context works
3. Game context sends "exec" to bridge (backward compatible)
4. Invalid context returns error immediately (no bridge call)
5. Empty code returns error
6. Blocked pattern returns BLOCKED status (via mock bridge response)
7. Compile error returns COMPILE_ERROR status (via mock bridge response)

**test_save_scene.py** — 3-4 cases:
1. Save in place — returns saved=True with scene info
2. Timeout value — verifies 5.0s timeout passed to bridge
3. No scene open — bridge raises exception (handled by registration layer)

### Integration Testing (Live Godot)

Must test live because GDScript compilation bugs only manifest at runtime:

1. Basic editor exec: `func run(): return 'hello'`
2. get_tree() access: `func run(): return get_tree().edited_scene_root.name`
3. Node access: `func run(): return get_tree().edited_scene_root.get_child_count()`
4. EditorInterface: `func run(): return str(EditorInterface.get_edited_scene_root().scene_file_path)`
5. Save scene after node_ops change

---

## Risks

| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| `script.new()` returns null in @tool mode | Low (we prepend @tool) | Medium | File-based fallback `_exec_editor_via_file()` |
| File-based fallback fails (Godot hasn't scanned) | Low | Medium | Return error with retry hint; synchronous `load()` usually works for existing addon paths |
| `EditorInterface.save_scene()` returns error | Low | Low | Return error code to agent; scene may be unsaved |
| Python ternary in GDScript | Medium (habit) | High (breaks all editor tools) | Grep audit in verification |
| Race condition in temp file fallback | Low | Low | Use unique filenames per request (`_temp_exec_%d.gd`) |

---

## Dispatch Table After Sprint 5b

Editor-side (8): `ping`, `editor_context`, `scene_tree`, `node_ops`, `run`, `stop`, `undo_history`, **`exec_editor`**, **`save_scene`**
Game-side forward (7): `screenshot`, `perf_snapshot`, `input`, `exec`, `state_inspect`, `nav_query`, `watch`

Total: 16 dispatch entries (was 14).
