summer-engine 2.6.0 → 2.6.3

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.
@@ -6,6 +6,7 @@ export const AGENT_CLIENTS = [
6
6
  "windsurf",
7
7
  "cline",
8
8
  "roo-code",
9
+ "kilo-code",
9
10
  "gemini",
10
11
  "github-copilot",
11
12
  "vscode-copilot",
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { EngineApiClient } from "../lib/api-client.js";
5
5
  import { registerSceneTools } from "./tools/scene-tools.js";
6
6
  import { registerDebugTools } from "./tools/debug-tools.js";
7
+ import { registerVisualTools } from "./tools/visual-tools.js";
7
8
  import { registerProjectTools } from "./tools/project-tools.js";
8
9
  import { registerAssetTools } from "./tools/asset-tools.js";
9
10
  import { registerGenerateTools } from "./tools/generate-tools.js";
@@ -30,7 +31,17 @@ async function probeBootDrift() {
30
31
  let cachedClient = null;
31
32
  export async function getClient() {
32
33
  if (cachedClient) {
33
- return cachedClient;
34
+ // The engine rotates its api-token (and can change ports) on every launch, so
35
+ // a cached client can outlive the engine instance it was built for. If the
36
+ // on-disk creds drifted, a silent engine restart happened — drop the stale
37
+ // client and reconnect transparently, instead of surfacing the resulting 401
38
+ // as a "disconnected from the project" error.
39
+ if (await cachedClient.credentialsChanged()) {
40
+ cachedClient = null;
41
+ }
42
+ else {
43
+ return cachedClient;
44
+ }
34
45
  }
35
46
  try {
36
47
  cachedClient = await EngineApiClient.connect();
@@ -113,6 +124,7 @@ export async function startMcpServer() {
113
124
  installResultSizeLogger(server);
114
125
  registerSceneTools(server);
115
126
  registerDebugTools(server);
127
+ registerVisualTools(server);
116
128
  registerProjectTools(server);
117
129
  registerAssetTools(server);
118
130
  registerGenerateTools(server);
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Visual capture tools. Unlike the in-product chat agent (a text-only "brain"
4
+ * that needs a separate vision model to describe frames for it), an MCP client
5
+ * like Claude Code can SEE images directly. So we hand the raw engine frame back
6
+ * as an MCP image content block — no vision-model prepass, no paraphrase. The
7
+ * model reviews the actual pixels.
8
+ */
9
+ export declare function registerVisualTools(server: McpServer): void;
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ import { withEngine } from "./with-engine.js";
3
+ /**
4
+ * Visual capture tools. Unlike the in-product chat agent (a text-only "brain"
5
+ * that needs a separate vision model to describe frames for it), an MCP client
6
+ * like Claude Code can SEE images directly. So we hand the raw engine frame back
7
+ * as an MCP image content block — no vision-model prepass, no paraphrase. The
8
+ * model reviews the actual pixels.
9
+ */
10
+ export function registerVisualTools(server) {
11
+ server.tool("summer_screenshot", `Capture a frame from Summer Engine and return it as an image you can look at directly.
12
+
13
+ Use this to visually verify your work: scene layout, asset placement, scale, framing, lighting, materials, missing/untextured assets, or runtime gameplay state. You see the actual pixels — no description layer in between.
14
+
15
+ target:
16
+ "viewport" (default) — the editor's current 3D/2D scene view. Works without running the game. Use for edit-time checks of how the scene looks.
17
+ "game" — a frame from the running game. The game must be started first (summer_play). Use to verify runtime behavior.
18
+
19
+ Static frame only — one moment, not motion. On macOS the game often runs in a floating window that cannot be captured; if a game capture fails, prefer "viewport", or ask the user to share a screenshot.`, {
20
+ target: z
21
+ .enum(["viewport", "game"])
22
+ .optional()
23
+ .default("viewport")
24
+ .describe('"viewport" = editor scene view (default), "game" = running game frame'),
25
+ }, async ({ target }) => withEngine(async (client) => target === "game" ? client.gameSnapshot() : client.viewportSnapshot(), {
26
+ toContent: (snap) => {
27
+ // withEngine only calls toContent on success (ok:true, error cleared
28
+ // by extractOpError). Missing image bytes on a "success" is still
29
+ // possible defensively, so fall back to text rather than emit a
30
+ // broken image block.
31
+ if (!snap.base64) {
32
+ return [
33
+ {
34
+ type: "text",
35
+ text: snap.error ||
36
+ "Snapshot succeeded but returned no image data. Try again, or use summer_get_scene_tree to inspect structurally.",
37
+ },
38
+ ];
39
+ }
40
+ const dims = snap.width && snap.height ? `${snap.width}x${snap.height}` : "unknown size";
41
+ const label = target === "game" ? "Running game frame" : "Editor viewport";
42
+ return [
43
+ { type: "image", data: snap.base64, mimeType: snap.mime || "image/jpeg" },
44
+ {
45
+ type: "text",
46
+ text: `${label} (${dims}). Saved to ${snap.localPath ?? "n/a"}. Review the image above and describe what you actually see.`,
47
+ },
48
+ ];
49
+ },
50
+ }));
51
+ }
@@ -1,11 +1,23 @@
1
1
  import { getClient } from "../server.js";
2
+ type ToolResultContent = {
3
+ type: "text";
4
+ text: string;
5
+ } | {
6
+ type: "image";
7
+ data: string;
8
+ mimeType: string;
9
+ };
2
10
  type ToolResult = {
3
- content: {
4
- type: "text";
5
- text: string;
6
- }[];
11
+ content: ToolResultContent[];
7
12
  isError?: boolean;
8
13
  };
14
+ /** Options for {@link withEngine}. `toContent` maps a successful engine result
15
+ * to MCP content blocks — used by the screenshot tool to hand the raw frame
16
+ * back as an image block instead of JSON-stringified text. Only runs on genuine
17
+ * success (after extractOpError clears); failures still surface as text. */
18
+ export interface WithEngineOptions<T> {
19
+ toContent?: (result: T) => ToolResultContent[];
20
+ }
9
21
  /**
10
22
  * Decide whether an engine result envelope represents a FAILURE, and if so
11
23
  * return a model-visible message. Returns null only for genuine success.
@@ -20,5 +32,15 @@ type ToolResult = {
20
32
  * Exported for unit tests.
21
33
  */
22
34
  export declare function extractOpError(result: unknown): string | null;
23
- export declare function withEngine<T>(fn: (client: Awaited<ReturnType<typeof getClient>>) => Promise<T>): Promise<ToolResult>;
35
+ /**
36
+ * A thrown transport error is safe to retry only when the engine provably never
37
+ * applied the op: a stale-token rejection (401/403, after the engine rotated its
38
+ * api-token on relaunch) or an unreachable/closed port (connection refused/reset,
39
+ * after the engine moved ports or is mid-restart). A timeout or any 5xx may have
40
+ * mutated, so those surface instead of risking a double-apply on retry.
41
+ *
42
+ * Exported for unit tests.
43
+ */
44
+ export declare function isReconnectableThrow(err: unknown): boolean;
45
+ export declare function withEngine<T>(fn: (client: Awaited<ReturnType<typeof getClient>>) => Promise<T>, opts?: WithEngineOptions<T>): Promise<ToolResult>;
24
46
  export {};
@@ -56,6 +56,43 @@ export function extractOpError(result) {
56
56
  }
57
57
  return null;
58
58
  }
59
+ // Terminal states where the engine GUARANTEES the op never landed, so dropping
60
+ // the cached client and retrying once is safe (no double-mutation). Everything
61
+ // else — timed_out (may still be running), content_mismatch / denied / canceled
62
+ // (intentional) — must surface, never silently retry.
63
+ const RECONNECTABLE_TERMINAL_STATES = new Set([
64
+ "not_connected",
65
+ "identity_mismatch",
66
+ ]);
67
+ function terminalStateOf(result) {
68
+ if (!result || typeof result !== "object")
69
+ return null;
70
+ const ts = result.terminalState;
71
+ return typeof ts === "string" && ts.length > 0 ? ts : null;
72
+ }
73
+ /**
74
+ * A thrown transport error is safe to retry only when the engine provably never
75
+ * applied the op: a stale-token rejection (401/403, after the engine rotated its
76
+ * api-token on relaunch) or an unreachable/closed port (connection refused/reset,
77
+ * after the engine moved ports or is mid-restart). A timeout or any 5xx may have
78
+ * mutated, so those surface instead of risking a double-apply on retry.
79
+ *
80
+ * Exported for unit tests.
81
+ */
82
+ export function isReconnectableThrow(err) {
83
+ const m = (err instanceof Error ? err.message : String(err)).toLowerCase();
84
+ if (m.includes("timed out") || m.includes("timeout") || m.includes("aborted")) {
85
+ return false;
86
+ }
87
+ return (m.includes("401") ||
88
+ m.includes("403") ||
89
+ m.includes("unauthorized") ||
90
+ m.includes("econnrefused") ||
91
+ m.includes("econnreset") ||
92
+ m.includes("fetch failed") ||
93
+ m.includes("not running") ||
94
+ m.includes("not responding"));
95
+ }
59
96
  function buildActionHint(message) {
60
97
  const normalized = message.toLowerCase();
61
98
  if (normalized.includes("no scene open") || normalized.includes("no edited scene")) {
@@ -69,24 +106,52 @@ function buildActionHint(message) {
69
106
  }
70
107
  return null;
71
108
  }
72
- export async function withEngine(fn) {
109
+ export async function withEngine(fn, opts) {
73
110
  // Best-effort, fire-and-forget: count this MCP session as DAU for attribution.
74
111
  // No await, no throw, no quota gating.
75
112
  recordMcpSession();
76
- try {
77
- const client = await getClient();
78
- const result = await fn(client);
79
- const opError = extractOpError(result);
80
- if (opError) {
81
- const hint = buildActionHint(opError);
82
- const message = hint ? `${opError}\n\nHint: ${hint}` : opError;
83
- return { content: [{ type: "text", text: message }], isError: true };
113
+ // The engine rotates its api-token and can move ports on every launch, so a
114
+ // restart that lands DURING a tool call shows up as a stale-token 401, an
115
+ // ECONNREFUSED on the old port, or a soft not_connected / identity_mismatch
116
+ // terminal state. Drop the cached client and retry ONCE so a transient drop
117
+ // heals itself (getClient reconnects with the fresh creds) instead of
118
+ // surfacing as a "disconnected" error. Only provably-not-applied failures are
119
+ // retried — see RECONNECTABLE_TERMINAL_STATES / isReconnectableThrow.
120
+ const MAX_ATTEMPTS = 2;
121
+ let lastError;
122
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
123
+ try {
124
+ const client = await getClient();
125
+ const result = await fn(client);
126
+ const opError = extractOpError(result);
127
+ if (opError) {
128
+ const ts = terminalStateOf(result);
129
+ if (ts && RECONNECTABLE_TERMINAL_STATES.has(ts) && attempt < MAX_ATTEMPTS) {
130
+ resetClient();
131
+ lastError = new Error(opError);
132
+ continue;
133
+ }
134
+ const hint = buildActionHint(opError);
135
+ const message = hint ? `${opError}\n\nHint: ${hint}` : opError;
136
+ return { content: [{ type: "text", text: message }], isError: true };
137
+ }
138
+ if (opts?.toContent) {
139
+ return { content: opts.toContent(result) };
140
+ }
141
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
142
+ }
143
+ catch (err) {
144
+ // A thrown error means the cached client may be pointed at a dead/rotated
145
+ // engine — always drop it (prior behavior). Retry once only for
146
+ // connection-class throws that provably did not mutate.
147
+ resetClient();
148
+ lastError = err;
149
+ if (attempt < MAX_ATTEMPTS && isReconnectableThrow(err)) {
150
+ continue;
151
+ }
152
+ break;
84
153
  }
85
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
86
- }
87
- catch (err) {
88
- resetClient();
89
- const msg = err instanceof Error ? err.message : String(err);
90
- return { content: [{ type: "text", text: msg }], isError: true };
91
154
  }
155
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
156
+ return { content: [{ type: "text", text: msg }], isError: true };
92
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summer-engine",
3
- "version": "2.6.0",
3
+ "version": "2.6.3",
4
4
  "description": "Summer CLI and MCP server for Summer Engine, the AI game engine. Install the engine, connect Claude Code, Cursor, Codex, Gemini, and other agents, and build real games. Skills, hooks, and plugins included.",
5
5
  "keywords": [
6
6
  "summer-engine",