summer-engine 2.6.3 → 2.6.6

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "summer",
4
4
  "version": "2.5.1",
5
- "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 44-tool MCP bridge to the local desktop app.",
5
+ "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 52-tool MCP bridge to the local desktop app.",
6
6
  "author": {
7
7
  "name": "Summer Engine",
8
8
  "email": "founders@summerengine.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "summer",
3
3
  "version": "2.5.1",
4
- "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 44-tool MCP bridge. Build games by talking.",
4
+ "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 52-tool MCP bridge. Build games by talking.",
5
5
  "author": {
6
6
  "name": "Summer Engine",
7
7
  "email": "founders@summerengine.com",
@@ -92,7 +92,7 @@
92
92
  "interface": {
93
93
  "displayName": "Summer",
94
94
  "shortDescription": "Superpowers for AI game dev. Build games by talking.",
95
- "longDescription": "Summer turns Codex into a game-dev studio for Summer Engine, the AI-native game engine. It adds game-dev skills, a using-summer workflow skill, lifecycle hooks, and a 44-tool MCP bridge for scene editing, asset import, play mode, diagnostics, and generation. File, git, shell, and grep stay with the host agent. Summer Engine is compatible with Godot 4.5. Write code in GDScript and drive the engine through MCP. Session-start orients the session. Pre-commit doctor (opt-in) blocks bad commits.",
95
+ "longDescription": "Summer turns Codex into a game-dev studio for Summer Engine, the AI-native game engine. It adds game-dev skills, a using-summer workflow skill, lifecycle hooks, and a 52-tool MCP bridge for scene editing, asset import, play mode, diagnostics, and generation. File, git, shell, and grep stay with the host agent. Summer Engine is compatible with Godot 4.5. Write code in GDScript and drive the engine through MCP. Session-start orients the session. Pre-commit doctor (opt-in) blocks bad commits.",
96
96
  "developerName": "Summer Engine",
97
97
  "category": "Coding",
98
98
  "capabilities": ["Interactive", "Read", "Write"],
@@ -3,7 +3,7 @@
3
3
  "name": "summer",
4
4
  "displayName": "Summer",
5
5
  "version": "2.5.1",
6
- "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 44-tool MCP bridge to the local desktop app.",
6
+ "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 52-tool MCP bridge to the local desktop app.",
7
7
  "author": { "name": "Summer Engine", "email": "founders@summerengine.com" },
8
8
  "publisher": "summer-engine",
9
9
  "homepage": "https://summerengine.com",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summer",
3
- "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 44-tool MCP bridge to the local desktop app.",
3
+ "description": "Agent tooling for Summer Engine: game-dev skills, lifecycle hooks, and a 52-tool MCP bridge to the local desktop app.",
4
4
  "version": "2.5.1",
5
5
  "author": {
6
6
  "name": "Summer Engine",
package/AGENTS.md CHANGED
@@ -22,11 +22,12 @@ This file is for any AI agent that loads context from `AGENTS.md` (Codex CLI, Fa
22
22
 
23
23
  ## MCP tool palette (engine on `localhost:6550`)
24
24
 
25
- 44 tools total. Categories:
25
+ 52 tools total. Categories:
26
26
 
27
27
  - Scene: `summer_get_scene_tree`, `summer_open_scene`, `summer_create_scene`, `summer_add_node`, `summer_set_prop`, `summer_set_resource_property`, `summer_remove_node`, `summer_save_scene`, `summer_instantiate_scene`, `summer_replace_node`, `summer_select_node`, `summer_inspect_node`, `summer_inspect_resource`, `summer_connect_signal`, `summer_batch`.
28
28
  - Diagnostics: `summer_get_script_errors`, `summer_get_diagnostics`, `summer_get_console`, `summer_clear_console`, `summer_get_debugger_errors`, `summer_get_debugger_warnings`.
29
29
  - Runtime: `summer_play`, `summer_stop`, `summer_is_running`.
30
+ - Visual: `summer_screenshot` (capture the editor viewport or running game as an image to verify it).
30
31
  - Project: `summer_get_project_context`, `summer_open_main_scene`, `summer_project_setting`, `summer_input_map_bind`, `summer_get_agent_playbook`.
31
32
  - Assets: `summer_search_assets`, `summer_list_my_assets`, `summer_get_asset`, `summer_get_asset_download_url`, `summer_import_asset`, `summer_import_asset_by_id`, `summer_import_from_url`, `summer_import_from_url_batch`.
32
33
  - Generation: `summer_generate_image`, `summer_generate_3d`, `summer_generate_audio`, `summer_generate_video`, `summer_generate_motion`, `summer_check_job`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to summer-engine will be documented here. Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [2.6.6] — 2026-07-15 — "Project-bound engine requests"
6
+
7
+ ### Fixed
8
+ - Every local engine request now carries the engine instance ID, stable project ID, project ID hash, and identity protocol version captured when the CLI connects. This lets compatible engine builds reject stale requests after a project or engine switch instead of acting on the wrong target.
9
+ - An explicit project rebind now refreshes the complete engine and project identity, while keeping the existing project-hash mutation guard and screenshot drift checks.
10
+ - Summer Cloud's engine bridge now binds its save, rescan, and scene reload requests to the project it verified on disk.
11
+
12
+ ### Changed
13
+ - Summer Cloud is documented as an optional Research Preview instead of part of the core local CLI and MCP workflow.
14
+ - Release metadata and the manual npm runbook now pin the public registry and require a clean, reviewed public source checkout.
15
+
16
+ ## [2.6.5] — 2026-07-04 — "Cloud tools don't need the engine"
17
+
18
+ ### Fixed
19
+ - Tool descriptions now say explicitly which tools run in Summer's cloud and work WITHOUT the engine open (`summer_generate_*`, `summer_search_assets`, `summer_list_my_assets`, `summer_get_asset`, `summer_get_asset_download_url`, `summer_check_job`) and which need the engine (imports, scene ops). Agents were misreading a missing `npx summer-engine login` as "MCP requires the engine".
20
+ - The "Summer Engine is not running" error now tells the agent that cloud tools still work without the engine.
21
+
22
+ ## [2.6.4] — 2026-07-03 — "See-Work + project binding" (unpublished, ships with 2.6.5)
23
+
24
+ ### Added
25
+ - MCP session binds to its project; the engine rejects wrong-project writes (`identity_mismatch`) instead of applying them to whatever project is open.
26
+ - Structured per-tool-call stderr logging; agent playbook rewritten around the MCP verification ladder; honest game-capture failure states and identity-stamped reads.
27
+
28
+ ## [2.6.3] — 2026-06-30 — "Agent vision"
29
+
30
+ ### Added
31
+ - `summer_screenshot` MCP tool: capture the editor viewport or the running game as an image the agent sees directly (`target: "viewport" | "game"`, viewport by default). Lets the agent visually verify scene layout, asset placement, scale, framing, and runtime state — the client reads the actual frame, with no description step in between. Total MCP tool surface is now 52.
32
+
33
+ ### Fixed
34
+ - MCP/CLI session now reconnects automatically after a transient engine restart (the engine rotates its api-token and can move its port on relaunch), instead of surfacing as a "disconnected" error.
35
+
5
36
  ## [2.6.0] — 2026-06-10 — "Summer Cloud"
6
37
 
7
38
  ### Added
package/GEMINI.md CHANGED
@@ -20,12 +20,13 @@ The single most important skill to know is `summer:using-summer` — it explains
20
20
 
21
21
  ## MCP tools (when the engine is running on `localhost:6550`)
22
22
 
23
- The `summer-engine` MCP server exposes 44 focused tools. The most important:
23
+ The `summer-engine` MCP server exposes 52 focused tools. The most important:
24
24
 
25
25
  - **Scene mutation**: `summer_add_node`, `summer_set_prop`, `summer_set_resource_property`, `summer_remove_node`, `summer_save_scene`, `summer_replace_node`, `summer_batch`.
26
26
  - **Scene inspection**: `summer_get_scene_tree`, `summer_inspect_node`, `summer_inspect_resource`.
27
27
  - **Diagnostics**: `summer_get_script_errors` (cheapest), `summer_get_diagnostics`, `summer_get_console`, `summer_get_debugger_errors`, `summer_get_debugger_warnings`.
28
28
  - **Run game**: `summer_play`, `summer_stop`, `summer_is_running`.
29
+ - **Visual**: `summer_screenshot` (capture the viewport or running game as an image you can see).
29
30
  - **Project**: `summer_get_project_context`, `summer_open_main_scene`, `summer_project_setting`, `summer_input_map_bind`.
30
31
  - **Assets**: `summer_search_assets`, `summer_import_asset`, `summer_import_from_url`, `summer_generate_image`, `summer_generate_3d`, `summer_generate_audio`, `summer_generate_video`, `summer_check_job`.
31
32
 
package/README.md CHANGED
@@ -18,27 +18,11 @@ It just works. Open your agent, say *"let's make an FPS in Summer Engine,"* and
18
18
  Three names, one npm package (`summer-engine`):
19
19
 
20
20
  - **Summer CLI** (`npx -y summer-engine@latest`): installs the engine, signs you in, scaffolds and runs projects, and writes your agent's config. See [www.summerengine.com/cli](https://www.summerengine.com/cli).
21
- - **Summer MCP**: the local MCP server that gives your agent 44 engine tools to build, run, and debug a real game. See [www.summerengine.com/mcp](https://www.summerengine.com/mcp).
21
+ - **Summer MCP**: the local MCP server that gives your agent 52 engine tools to build, run, and debug a real game. See [www.summerengine.com/mcp](https://www.summerengine.com/mcp).
22
22
  - **Summer agent layer**: the game-dev skills, hooks, and plugin manifests that give your AI agent judgment, not just a chat box.
23
23
 
24
24
  All MIT, all free to use. One paste sets up all three.
25
25
 
26
- ## Summer Cloud (new in 2.6.0)
27
-
28
- `summer cloud` syncs your whole game project across machines: scenes, scripts, settings, and the big binary assets that never fit in git. Files are stored by content hash, every push becomes a restorable version, and local checkpoints are taken before anything destructive touches your disk.
29
-
30
- ```bash
31
- summer cloud init # bind this project to your account
32
- summer cloud push # upload what changed (only missing bytes travel)
33
- summer cloud pull # make this machine match the cloud, verified by hash
34
- summer cloud status # what differs between here and the cloud
35
- summer cloud restore # roll the project back to any version
36
- ```
37
-
38
- The same operations are available to agents as MCP tools (`summer_cloud_push`, `summer_cloud_pull`, and friends). See [www.summerengine.com/cloud](https://www.summerengine.com/cloud).
39
-
40
- ---
41
-
42
26
  ## Get started: one prompt
43
27
 
44
28
  Open your AI agent (Claude Code, Cursor, Codex, Copilot, Devin Desktop, etc.) and paste:
@@ -269,13 +253,15 @@ Two pieces, plus the glue.
269
253
 
270
254
  Skills don't list steps. They encode the **order of operations**: diagnose before editing, scope before building, ask before guessing. [Agent Skills](https://agentskills.io) format, so any conformant tool picks them up.
271
255
 
272
- **MCP bridge.** The `summer-engine` MCP server gives the agent 44 tools that talk to your local engine on `localhost:6550`:
256
+ **MCP bridge.** The `summer-engine` MCP server gives the agent 52 tools that talk to your local engine on `localhost:6550`:
273
257
 
274
258
  | | |
275
259
  |---|---|
276
260
  | Scene | `summer_add_node`, `summer_set_prop`, `summer_instantiate_scene`, `summer_replace_node`, `summer_get_scene_tree`, `summer_inspect_node`, `summer_batch` |
277
261
  | Diagnostics | `summer_get_script_errors`, `summer_get_diagnostics`, `summer_get_console`, `summer_get_debugger_errors`, `summer_get_debugger_warnings` |
278
262
  | Runtime | `summer_play`, `summer_stop`, `summer_is_running` |
263
+ | Visual | `summer_screenshot` (see the editor viewport, an offscreen scene render, or the running game) |
264
+ | Interactive | `SimulateInput` / `RunVerification` raw ops via `summer_batch` — drive the running game or run a hidden GDScript probe (engine-build dependent) |
279
265
  | Project | `summer_get_project_context`, `summer_open_main_scene`, `summer_project_setting`, `summer_input_map_bind` |
280
266
  | Assets | `summer_search_assets`, `summer_import_asset`, `summer_import_from_url`, `summer_generate_image`, `summer_generate_3d`, `summer_generate_audio`, `summer_generate_video` |
281
267
 
@@ -285,6 +271,36 @@ File ops, git, shell, and grep already belong to your agent. We don't shadow the
285
271
 
286
272
  For the full architecture, see [docs/OVERVIEW.md](docs/OVERVIEW.md).
287
273
 
274
+ ### Using Summer via MCP: the verification loop
275
+
276
+ There is no single "verify" tool. You **compose** the checks, climbing only as high as the change demands. Call `summer_get_agent_playbook` for the full ladder; the short version:
277
+
278
+ **1. Does it compile?** After writing a `.gd`, call `summer_get_script_errors res://scripts/player.gd` — no game boot. For a project-wide sweep, `summer_get_diagnostics` (it tells you whether to then read `summer_get_console` / `summer_get_debugger_errors`).
279
+
280
+ **2. Does it look right?** `summer_screenshot` returns the actual pixels — you see them, no description layer. Pick the target on purpose:
281
+ - `viewport` (default) — the editor's current tab, as it looks now.
282
+ - `scene` — an offscreen render of a scene *file* (`scenePath`). No boot; **static at t=0**. Confesses if the scene has no `Camera3D` (renders grey/black when played) or no light — read those warnings.
283
+ - `game` — a frame from the **running** game (`summer_play` first). Over plain local HTTP this returns an honest failure until the desktop bridge is present; when it fails, drop to `viewport`/`scene` or ask the user.
284
+
285
+ **3. Does it run?** Compose the loop:
286
+
287
+ ```
288
+ summer_clear_console # clean slate
289
+ summer_play # boot the game (or a specific scene)
290
+ summer_get_debugger_errors # runtime errors: null refs, missing nodes, physics
291
+ summer_screenshot target:game # optional: look at the live frame
292
+ summer_stop # ALWAYS stop — scene edits are refused while running
293
+ ```
294
+
295
+ **4. Does the interaction work?** To prove input-driven behavior (jump/move/shoot), send a raw op through `summer_batch` (engine-build dependent — a `failure_reason:"unsupported"` comes back verbatim on older builds):
296
+ - `SimulateInput` — inject into the **running** game: `summer_batch ops:[{op:"SimulateInput", type:"action", action:"jump", pressed:true}]`, then re-check screenshot/debugger.
297
+ - `RunVerification` — spawn a hidden, disposable game instance that runs a GDScript probe (press inputs, read state, save frames) and dies, never touching the editor: `summer_batch ops:[{op:"RunVerification", probe_source:"…", max_seconds:20}]` → `{ok, results, frames, out_dir}`.
298
+ - If neither is available on this build, **ask the user to try it** and report what they see — don't fake it.
299
+
300
+ **Honesty.** Never describe an image you didn't receive; a failed capture is a *result* — report it and climb down (`scene`→`viewport`) or ask the user. Pass structured failures (`failure_reason`, `terminalState`, `identity_mismatch`) through verbatim. `summer_get_project_context` binds the session to the open project; a `projectMismatch` warning on a screenshot means the engine switched projects and the frame may be from the wrong one — rebind before trusting it.
301
+
302
+ **Debugging the MCP itself.** Set `SUMMER_MCP_DEBUG=1` in the server's environment to log a structured stderr line per tool call (`tool`, `ok`, `durationMs`, `terminalState`, `errorClass`, `failureReason`, `retried`). With the flag off, only failures are logged.
303
+
288
304
  ---
289
305
 
290
306
  ## The basic workflow
@@ -524,6 +540,24 @@ Skills evolve fast. Two ways to help:
524
540
 
525
541
  ---
526
542
 
543
+ ## Research Preview: Summer Cloud
544
+
545
+ Summer Cloud is an experimental R&D preview. It is optional, it may change or break, and it is **not** the core Summer CLI or MCP workflow. You do not need it to install or run Summer Engine, work with local projects, or use the local MCP server. The stable workflow is the local engine, CLI, MCP bridge, and agent skills described above.
546
+
547
+ The preview explores content-addressed project sync across machines, including large assets that do not fit comfortably in git:
548
+
549
+ ```bash
550
+ summer cloud init
551
+ summer cloud status
552
+ summer cloud push
553
+ summer cloud pull
554
+ summer cloud restore
555
+ ```
556
+
557
+ Matching `summer_cloud_*` MCP tools are also experimental. Keep an independent backup and do not use this preview as the only recovery path for an important project. See [www.summerengine.com/cloud](https://www.summerengine.com/cloud).
558
+
559
+ ---
560
+
527
561
  ## License
528
562
 
529
563
  MIT for everything in this repo. Summer Engine itself is proprietary. See [What's open and what's not](#whats-open-and-whats-not).
@@ -16,12 +16,49 @@ export type EngineSnapshot = {
16
16
  bytes?: number;
17
17
  error?: string;
18
18
  metadata?: Record<string, unknown>;
19
+ /** Structured failure classifier the engine returns on a game snapshot over
20
+ * local HTTP (409 `bridge_required`) — surfaced verbatim so the tool can give
21
+ * an honest, actionable message instead of a truncated generic 409 string.
22
+ * When P4.4 lands (game snapshots answer 200/202 over HTTP) this simply
23
+ * never fires and the tool works. */
24
+ failureReason?: string;
25
+ /** Scene-preview confession fields (P4.3). Populated only for target:"scene".
26
+ * A scene with no Camera3D renders grey/black when played; the engine reports
27
+ * whether it had to synthesize a camera/light so the model can warn honestly. */
28
+ sceneHasCamera?: boolean;
29
+ sceneHadLight?: boolean;
30
+ usedSyntheticCamera?: boolean;
31
+ /** Set true when the bound projectIdHash no longer matches the engine's live
32
+ * health hash at capture time — the frame may be from the WRONG project
33
+ * (item 4, client-side drift check). */
34
+ projectMismatch?: boolean;
35
+ };
36
+ type EngineTargetIdentity = {
37
+ instanceId?: string;
38
+ projectId?: string;
39
+ projectIdHash?: string;
19
40
  };
20
41
  export declare class EngineApiClient {
21
42
  private port;
22
43
  private token;
23
- constructor(port: number, token: string);
44
+ private targetIdentity;
45
+ constructor(port: number, token: string, targetIdentity?: EngineTargetIdentity | string);
24
46
  static connect(): Promise<EngineApiClient>;
47
+ /** The projectIdHash this session is bound to (undefined if none was reported
48
+ * at connect — e.g. no project open yet). */
49
+ getBoundProjectIdHash(): string | undefined;
50
+ /**
51
+ * Re-read health and rebind to the currently-open project. Called by
52
+ * summer_get_project_context so the agent can INTENTIONALLY follow a project
53
+ * switch (the deliberate escape hatch after an identity_mismatch). Returns the
54
+ * new bound hash. The direct health probe is intentionally unscoped, so it can
55
+ * reach the current engine even when bound requests would be rejected.
56
+ */
57
+ rebind(): Promise<string | undefined>;
58
+ /** Identity to attach to a MUTATING command's options so the engine can reject
59
+ * a wrong-project write. Empty when unbound (engine then skips the check). */
60
+ private identityOptions;
61
+ private targetUrl;
25
62
  private request;
26
63
  /** Raw fetch with auth + timeout — returns the Response so callers can inspect
27
64
  * the status (202 queued vs 200 legacy). */
@@ -51,8 +88,34 @@ export declare class EngineApiClient {
51
88
  getSelection(): Promise<unknown>;
52
89
  private writeSnapshotFile;
53
90
  private snapshot;
91
+ /** Shared: turn an ops/snapshot response envelope into an EngineSnapshot.
92
+ * Used by viewport/game snapshots and by scenePreview. */
93
+ private _parseSnapshotResponse;
94
+ /**
95
+ * Probe /api/snapshot/game for the structural 409 `bridge_required` shape
96
+ * (tool_net_thread.cpp:495-503). Returns a structured failure only when the
97
+ * engine actually answers 409 with `bridge_required` / `unsupported_transport`;
98
+ * ANY other status (200/202/other) returns null so the caller proceeds to the
99
+ * normal queued path — so the moment the engine starts answering over HTTP
100
+ * (P4.4), game capture just works with no code change here.
101
+ */
102
+ private _detectBridgeRequired;
54
103
  viewportSnapshot(): Promise<EngineSnapshot>;
55
104
  gameSnapshot(): Promise<EngineSnapshot>;
105
+ /**
106
+ * Offscreen scene render via the `ScenePreview` op over /api/ops (no game
107
+ * boot; physics/animations are static). Mirrors the web previewScene op input
108
+ * (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
109
+ * node_path? } — scene_path optional, engine defaults to the open scene. The
110
+ * result carries image_base64 + mime (+ width/height) plus the P4.3 confession
111
+ * fields (scene_has_camera / scene_had_light / used_synthetic_camera).
112
+ */
113
+ scenePreview(input?: {
114
+ scenePath?: string;
115
+ framing?: "auto" | "top" | "front" | "iso";
116
+ size?: [number, number];
117
+ nodePath?: string;
118
+ }): Promise<EngineSnapshot>;
56
119
  getPort(): number;
57
120
  /**
58
121
  * Cheap drift probe for the MCP client cache. The engine mints a fresh
@@ -68,3 +131,4 @@ export declare class EngineApiClient {
68
131
  */
69
132
  credentialsChanged(): Promise<boolean>;
70
133
  }
134
+ export {};
@@ -14,6 +14,9 @@ function stringFrom(value) {
14
14
  function numberFrom(value) {
15
15
  return typeof value === "number" ? value : undefined;
16
16
  }
17
+ function boolFrom(value) {
18
+ return typeof value === "boolean" ? value : undefined;
19
+ }
17
20
  function findSnapshotPayload(value) {
18
21
  const record = asRecord(value);
19
22
  if (!record)
@@ -21,7 +24,8 @@ function findSnapshotPayload(value) {
21
24
  if (typeof record.base64 === "string" ||
22
25
  typeof record.image_base64 === "string" ||
23
26
  record.op === "ViewportSnapshot" ||
24
- record.op === "GameSnapshot") {
27
+ record.op === "GameSnapshot" ||
28
+ record.op === "ScenePreview") {
25
29
  return record;
26
30
  }
27
31
  if (Array.isArray(record.results)) {
@@ -63,9 +67,19 @@ function withoutImageData(payload) {
63
67
  export class EngineApiClient {
64
68
  port;
65
69
  token;
66
- constructor(port, token) {
70
+ // The engine and project this session is bound to. Every request carries the
71
+ // identity captured from health so the engine can reject requests after an
72
+ // in-place project switch or an unexpected instance change. The string form
73
+ // preserves the pre-2.6.6 constructor contract for existing callers that only
74
+ // supplied a projectIdHash.
75
+ targetIdentity;
76
+ constructor(port, token, targetIdentity = {}) {
67
77
  this.port = port;
68
78
  this.token = token;
79
+ this.targetIdentity =
80
+ typeof targetIdentity === "string"
81
+ ? { projectIdHash: targetIdentity }
82
+ : { ...targetIdentity };
69
83
  }
70
84
  static async connect() {
71
85
  const port = await getApiPort();
@@ -77,10 +91,62 @@ export class EngineApiClient {
77
91
  if (!health) {
78
92
  throw new Error(`Summer Engine is not responding on port ${port}. Make sure it's open.`);
79
93
  }
80
- return new EngineApiClient(port, token);
94
+ // Bind to whatever project is open right now. On a genuine engine restart the
95
+ // token rotates and getClient() rebuilds this client, so a restart naturally
96
+ // rebinds to the current project. An in-place project switch keeps the same
97
+ // token, so the cached client retains its original binding and the engine
98
+ // rejects mismatched mutations until the agent explicitly rebinds.
99
+ return new EngineApiClient(port, token, {
100
+ instanceId: health.instanceId,
101
+ projectId: health.projectId,
102
+ projectIdHash: health.projectIdHash,
103
+ });
104
+ }
105
+ /** The projectIdHash this session is bound to (undefined if none was reported
106
+ * at connect — e.g. no project open yet). */
107
+ getBoundProjectIdHash() {
108
+ return this.targetIdentity.projectIdHash;
109
+ }
110
+ /**
111
+ * Re-read health and rebind to the currently-open project. Called by
112
+ * summer_get_project_context so the agent can INTENTIONALLY follow a project
113
+ * switch (the deliberate escape hatch after an identity_mismatch). Returns the
114
+ * new bound hash. The direct health probe is intentionally unscoped, so it can
115
+ * reach the current engine even when bound requests would be rejected.
116
+ */
117
+ async rebind() {
118
+ const health = await checkEngineHealth(this.port);
119
+ if (health) {
120
+ this.targetIdentity = {
121
+ instanceId: health.instanceId,
122
+ projectId: health.projectId,
123
+ projectIdHash: health.projectIdHash,
124
+ };
125
+ }
126
+ return this.targetIdentity.projectIdHash;
127
+ }
128
+ /** Identity to attach to a MUTATING command's options so the engine can reject
129
+ * a wrong-project write. Empty when unbound (engine then skips the check). */
130
+ identityOptions() {
131
+ const projectIdHash = this.targetIdentity.projectIdHash;
132
+ return projectIdHash ? { projectIdHash } : {};
133
+ }
134
+ targetUrl(path) {
135
+ const url = new URL(path, `http://127.0.0.1:${this.port}`);
136
+ const { instanceId, projectId, projectIdHash } = this.targetIdentity;
137
+ if (instanceId)
138
+ url.searchParams.set("instanceId", instanceId);
139
+ if (projectId)
140
+ url.searchParams.set("projectId", projectId);
141
+ if (projectIdHash)
142
+ url.searchParams.set("projectIdHash", projectIdHash);
143
+ if (instanceId && projectId && projectIdHash) {
144
+ url.searchParams.set("projectIdentityVersion", "1");
145
+ }
146
+ return url.toString();
81
147
  }
82
148
  async request(method, path, body) {
83
- const url = `http://127.0.0.1:${this.port}${path}`;
149
+ const url = this.targetUrl(path);
84
150
  const headers = {
85
151
  Authorization: `Bearer ${this.token}`,
86
152
  "Content-Type": "application/json",
@@ -100,7 +166,7 @@ export class EngineApiClient {
100
166
  /** Raw fetch with auth + timeout — returns the Response so callers can inspect
101
167
  * the status (202 queued vs 200 legacy). */
102
168
  async _fetchRaw(method, path, body, timeoutMs) {
103
- return fetch(`http://127.0.0.1:${this.port}${path}`, {
169
+ return fetch(this.targetUrl(path), {
104
170
  method,
105
171
  headers: {
106
172
  Authorization: `Bearer ${this.token}`,
@@ -150,7 +216,11 @@ export class EngineApiClient {
150
216
  // Ops may include long-running work (ImportFromUrlBatch, GitCommit); the
151
217
  // engine keeps it "running" and we poll. 120s budget. Resolves legacy-200 or
152
218
  // async-202 (Block E).
153
- return this._requestQueued("POST", "/api/ops", { ops, options }, 120_000);
219
+ // Attach the bound project identity so the engine rejects a write aimed at a
220
+ // different project (identity_mismatch, atomic — before any op applies).
221
+ const merged = { ...this.identityOptions(), ...(options ?? {}) };
222
+ const body = Object.keys(merged).length ? { ops, options: merged } : { ops };
223
+ return this._requestQueued("POST", "/api/ops", body, 120_000);
154
224
  }
155
225
  async getSceneState() {
156
226
  return this.request("GET", "/api/state/scene");
@@ -174,11 +244,14 @@ export class EngineApiClient {
174
244
  // Cold-load on large projects can take 25-40s. 60s budget.
175
245
  // The engine reads play params from body.options (tool_net_thread.cpp:503),
176
246
  // and the play handler reads options["scene"] — a top-level { scene } is
177
- // dropped, so the scene MUST be nested inside options.
178
- return this._requestQueued("POST", "/api/play", scene ? { options: { scene } } : {}, 60_000);
247
+ // dropped, so the scene MUST be nested inside options. The bound identity
248
+ // rides in the same options dict so play is refused on a mismatched project.
249
+ const options = { ...this.identityOptions(), ...(scene ? { scene } : {}) };
250
+ return this._requestQueued("POST", "/api/play", Object.keys(options).length ? { options } : {}, 60_000);
179
251
  }
180
252
  async stop() {
181
- return this._requestQueued("POST", "/api/stop", undefined, 15_000);
253
+ const options = this.identityOptions();
254
+ return this._requestQueued("POST", "/api/stop", Object.keys(options).length ? { options } : undefined, 15_000);
182
255
  }
183
256
  async readFile(path, maxBytes) {
184
257
  const params = new URLSearchParams({ path });
@@ -211,6 +284,18 @@ export class EngineApiClient {
211
284
  // The terminal result is the apply dict; the image rides as base64 inside it
212
285
  // (no raw-binary channel). Legacy/dormant engines answer 200 synchronously
213
286
  // with the same payload shape — _requestQueued resolves both.
287
+ //
288
+ // Game capture over local HTTP structurally 409s today with a STRUCTURED
289
+ // reason (`failure_reason:"unsupported_transport"`, `bridge_required:true`,
290
+ // tool_net_thread.cpp:495-503). Detect that specific shape and return it
291
+ // verbatim so the tool can give an honest message — do NOT hardcode "game
292
+ // always fails": once the engine answers 200/202 (P4.4), the normal path
293
+ // below just works.
294
+ if (kind === "game") {
295
+ const bridge = await this._detectBridgeRequired(`/api/snapshot/${kind}`);
296
+ if (bridge)
297
+ return bridge;
298
+ }
214
299
  let response;
215
300
  try {
216
301
  response = await this._requestQueued("GET", `/api/snapshot/${kind}`, undefined, 60_000);
@@ -218,11 +303,16 @@ export class EngineApiClient {
218
303
  catch (err) {
219
304
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
220
305
  }
306
+ return this._parseSnapshotResponse(response, kind);
307
+ }
308
+ /** Shared: turn an ops/snapshot response envelope into an EngineSnapshot.
309
+ * Used by viewport/game snapshots and by scenePreview. */
310
+ async _parseSnapshotResponse(response, kind) {
221
311
  const payload = findSnapshotPayload(response);
222
312
  if (!payload) {
223
313
  return {
224
314
  ok: false,
225
- error: "Snapshot response did not include a viewport/game payload.",
315
+ error: "Snapshot response did not include an image payload.",
226
316
  };
227
317
  }
228
318
  const base64 = stringFrom(payload.base64) ?? stringFrom(payload.image_base64);
@@ -230,12 +320,34 @@ export class EngineApiClient {
230
320
  return {
231
321
  ok: false,
232
322
  error: stringFrom(payload.error) ?? "Snapshot response did not include image data.",
323
+ failureReason: stringFrom(payload.failure_reason),
324
+ sceneHasCamera: boolFrom(payload.scene_has_camera),
325
+ sceneHadLight: boolFrom(payload.scene_had_light),
326
+ usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
233
327
  metadata: withoutImageData(payload),
234
328
  };
235
329
  }
236
330
  const { format, mime, ext } = snapshotFormat(payload);
237
331
  const buffer = Buffer.from(base64, "base64");
238
332
  const localPath = await this.writeSnapshotFile(kind, buffer, ext);
333
+ // Client-side identity drift check (item 4). Current engines validate the
334
+ // identity query on snapshot reads, but older builds may ignore it. Re-read
335
+ // health and compare to the bound hash so a frame from a switched project is
336
+ // still marked as suspect. Best-effort: a failed health read never blocks the
337
+ // already captured image.
338
+ let projectMismatch;
339
+ const boundProjectIdHash = this.targetIdentity.projectIdHash;
340
+ if (boundProjectIdHash) {
341
+ try {
342
+ const health = await checkEngineHealth(this.port);
343
+ if (health?.projectIdHash && health.projectIdHash !== boundProjectIdHash) {
344
+ projectMismatch = true;
345
+ }
346
+ }
347
+ catch {
348
+ // ignore — never fail a capture on a transient health read
349
+ }
350
+ }
239
351
  return {
240
352
  ok: payload.ok !== false,
241
353
  localPath,
@@ -249,15 +361,86 @@ export class EngineApiClient {
249
361
  op: stringFrom(payload.op),
250
362
  provenance: payload.provenance,
251
363
  bytes: buffer.byteLength,
364
+ sceneHasCamera: boolFrom(payload.scene_has_camera),
365
+ sceneHadLight: boolFrom(payload.scene_had_light),
366
+ usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
367
+ projectMismatch,
252
368
  metadata: withoutImageData(payload),
253
369
  };
254
370
  }
371
+ /**
372
+ * Probe /api/snapshot/game for the structural 409 `bridge_required` shape
373
+ * (tool_net_thread.cpp:495-503). Returns a structured failure only when the
374
+ * engine actually answers 409 with `bridge_required` / `unsupported_transport`;
375
+ * ANY other status (200/202/other) returns null so the caller proceeds to the
376
+ * normal queued path — so the moment the engine starts answering over HTTP
377
+ * (P4.4), game capture just works with no code change here.
378
+ */
379
+ async _detectBridgeRequired(path) {
380
+ let res;
381
+ try {
382
+ res = await this._fetchRaw("GET", path, undefined, 15_000);
383
+ }
384
+ catch {
385
+ // Transport error — let the normal path surface it uniformly.
386
+ return null;
387
+ }
388
+ if (res.status !== 409)
389
+ return null;
390
+ const body = (await res.json().catch(() => ({})));
391
+ const reason = stringFrom(body.failure_reason);
392
+ const bridge = body.bridge_required === true;
393
+ if (!bridge && reason !== "unsupported_transport") {
394
+ // A 409 we don't recognize — surface it as a plain error rather than
395
+ // silently claiming "bridge required".
396
+ return {
397
+ ok: false,
398
+ error: stringFrom(body.error) ?? "Engine returned 409 for game snapshot.",
399
+ };
400
+ }
401
+ return {
402
+ ok: false,
403
+ failureReason: reason ?? "bridge_required",
404
+ error: stringFrom(body.error) ?? "Game snapshots require the desktop bridge async transport.",
405
+ };
406
+ }
255
407
  async viewportSnapshot() {
256
408
  return this.snapshot("viewport");
257
409
  }
258
410
  async gameSnapshot() {
259
411
  return this.snapshot("game");
260
412
  }
413
+ /**
414
+ * Offscreen scene render via the `ScenePreview` op over /api/ops (no game
415
+ * boot; physics/animations are static). Mirrors the web previewScene op input
416
+ * (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
417
+ * node_path? } — scene_path optional, engine defaults to the open scene. The
418
+ * result carries image_base64 + mime (+ width/height) plus the P4.3 confession
419
+ * fields (scene_has_camera / scene_had_light / used_synthetic_camera).
420
+ */
421
+ async scenePreview(input) {
422
+ const opInput = { op: "ScenePreview" };
423
+ const trimmed = input?.scenePath?.trim();
424
+ if (trimmed && trimmed !== "." && trimmed !== "./")
425
+ opInput.scene_path = trimmed;
426
+ if (input?.framing)
427
+ opInput.framing = input.framing;
428
+ if (input?.size)
429
+ opInput.size = input.size;
430
+ if (input?.nodePath)
431
+ opInput.node_path = input.nodePath;
432
+ let response;
433
+ try {
434
+ // executeOps stamps the bound identity so a drifted project is rejected
435
+ // (identity_mismatch) before rendering — ScenePreview reads no game state
436
+ // but still targets a project's resources.
437
+ response = await this.executeOps([opInput]);
438
+ }
439
+ catch (err) {
440
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
441
+ }
442
+ return this._parseSnapshotResponse(response, "scene");
443
+ }
261
444
  getPort() {
262
445
  return this.port;
263
446
  }
@@ -50,7 +50,15 @@ async function connectForProject(projectRoot) {
50
50
  // rescan; the executor rejects mismatches too, but skip the round trip.
51
51
  if (health.projectIdHash && health.projectIdHash !== projectIdHash)
52
52
  return null;
53
- return { client: new EngineApiClient(port, token), projectIdHash, scene: health.scene };
53
+ return {
54
+ client: new EngineApiClient(port, token, {
55
+ instanceId: health.instanceId,
56
+ projectId: health.projectId,
57
+ projectIdHash,
58
+ }),
59
+ projectIdHash,
60
+ scene: health.scene,
61
+ };
54
62
  }
55
63
  catch {
56
64
  return null;