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.
@@ -1,5 +1,14 @@
1
1
  import { getClient, resetClient } from "../server.js";
2
2
  import { recordMcpSession } from "../../lib/telemetry.js";
3
+ export const WITH_ENGINE_META = Symbol("summerWithEngineMeta");
4
+ function attachMeta(result, meta) {
5
+ Object.defineProperty(result, WITH_ENGINE_META, {
6
+ value: meta,
7
+ enumerable: false,
8
+ configurable: true,
9
+ });
10
+ return result;
11
+ }
3
12
  // 0.5.34 Block E contract (publicsummerengine src/lib/tools/contract.ts §0.1).
4
13
  // The async lifecycle (async-op-lifecycle.ts pollOpToTerminal) merges
5
14
  // terminalState/errorClass onto the apply dict it returns. ONLY these two are
@@ -17,6 +26,23 @@ const TERMINAL_STATE_MESSAGES = {
17
26
  denied: "Operation denied (terminalState: denied). Nothing was applied.",
18
27
  canceled: "Operation canceled (terminalState: canceled). Nothing was applied.",
19
28
  };
29
+ /** Pull the failure classifiers off an engine envelope for logging (best-effort,
30
+ * shape-tolerant). Does not decide success/failure — that stays in
31
+ * extractOpError. */
32
+ function readClassifiers(result) {
33
+ if (!result || typeof result !== "object")
34
+ return {};
35
+ const op = result;
36
+ return {
37
+ terminalState: typeof op.terminalState === "string" ? op.terminalState : undefined,
38
+ errorClass: typeof op.errorClass === "string" ? op.errorClass : undefined,
39
+ failureReason: typeof op.failureReason === "string"
40
+ ? op.failureReason
41
+ : typeof op.failure_reason === "string"
42
+ ? op.failure_reason
43
+ : undefined,
44
+ };
45
+ }
20
46
  /**
21
47
  * Decide whether an engine result envelope represents a FAILURE, and if so
22
48
  * return a model-visible message. Returns null only for genuine success.
@@ -56,45 +82,35 @@ export function extractOpError(result) {
56
82
  }
57
83
  return null;
58
84
  }
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
85
  /**
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.
86
+ * An auth failure is the ONE class of thrown error that is provably pre-apply:
87
+ * the engine rejects on the Bearer-token check (tool_net_thread.cpp::_validate_auth)
88
+ * BEFORE the op is queued or applied, so reconnecting with the fresh api-token and
89
+ * retrying cannot double-apply a mutation. This is exactly the stale-token case
90
+ * after the engine rotates its api-token on relaunch.
91
+ *
92
+ * Deliberately NARROW. A generic connection error (ECONNREFUSED / "fetch failed")
93
+ * thrown from inside the tool closure is NOT retriable here, because it may be a
94
+ * disconnect mid-operation where a mutation already partially applied — and the
95
+ * MCP sends no idempotency key the engine can dedup on, so a blind retry would
96
+ * re-apply it. The restart-between-calls case is handled proactively by
97
+ * getClient()'s credential-drift check; the connect/preflight-failure case is
98
+ * retried separately in withEngine (nothing is submitted there).
79
99
  *
80
100
  * Exported for unit tests.
81
101
  */
82
- export function isReconnectableThrow(err) {
102
+ export function isAuthError(err) {
83
103
  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"));
104
+ return m.includes("401") || m.includes("403") || m.includes("unauthorized");
95
105
  }
96
106
  function buildActionHint(message) {
97
107
  const normalized = message.toLowerCase();
108
+ if (normalized.includes("identity_mismatch") ||
109
+ normalized.includes("projectidhash mismatch") ||
110
+ normalized.includes("projectid mismatch") ||
111
+ normalized.includes("wrong project/instance")) {
112
+ return "Summer Engine is now on a DIFFERENT project than this session is bound to — nothing was applied (your edit did NOT land in the wrong project). If you meant to work on the now-open project, call `summer_get_project_context` to rebind to it, then retry. If not, switch the engine back to the original project first.";
113
+ }
98
114
  if (normalized.includes("no scene open") || normalized.includes("no edited scene")) {
99
115
  return "No scene is currently open. Call `summer_get_project_context` first, then `summer_open_main_scene` (or `summer_open_scene` with a known .tscn path).";
100
116
  }
@@ -110,48 +126,69 @@ export async function withEngine(fn, opts) {
110
126
  // Best-effort, fire-and-forget: count this MCP session as DAU for attribution.
111
127
  // No await, no throw, no quota gating.
112
128
  recordMcpSession();
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.
129
+ // The engine rotates its api-token (and can move ports) on every launch. The
130
+ // proactive credential-drift check in getClient() reconnects when a restart
131
+ // happened BETWEEN calls; this loop only covers the race where the restart
132
+ // lands DURING a call. We retry ONLY where nothing could have been applied:
133
+ // - a connect/preflight failure (getClient threw — request never submitted)
134
+ // - a stale-token 401/403 (engine rejects at auth, before queue/apply)
135
+ // A generic connection error thrown from inside fn() is NOT retried: it may be
136
+ // a disconnect mid-operation where a mutation already partially applied, and
137
+ // the MCP sends no idempotency key the engine can dedup on. A soft failure
138
+ // terminal state (e.g. identity_mismatch from a project switch) is surfaced,
139
+ // never silently retried — a retry cannot fix a project switch.
120
140
  const MAX_ATTEMPTS = 2;
121
141
  let lastError;
142
+ let retried = false;
143
+ let boundProjectIdHash;
122
144
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
145
+ if (attempt > 1)
146
+ retried = true;
147
+ let client;
148
+ try {
149
+ client = await getClient();
150
+ }
151
+ catch (err) {
152
+ // Pre-submission: the request never went out, so reconnect and retry.
153
+ resetClient();
154
+ lastError = err;
155
+ if (attempt < MAX_ATTEMPTS)
156
+ continue;
157
+ break;
158
+ }
159
+ boundProjectIdHash =
160
+ typeof client.getBoundProjectIdHash === "function"
161
+ ? client.getBoundProjectIdHash()
162
+ : undefined;
123
163
  try {
124
- const client = await getClient();
125
164
  const result = await fn(client);
126
165
  const opError = extractOpError(result);
127
166
  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
167
  const hint = buildActionHint(opError);
135
168
  const message = hint ? `${opError}\n\nHint: ${hint}` : opError;
136
- return { content: [{ type: "text", text: message }], isError: true };
169
+ return attachMeta({ content: [{ type: "text", text: message }], isError: true }, { ...readClassifiers(result), retried, boundProjectIdHash });
170
+ }
171
+ const meta = { ...readClassifiers(result), retried, boundProjectIdHash };
172
+ if (opts?.onResult) {
173
+ const short = opts.onResult(result);
174
+ if (short)
175
+ return attachMeta(short, meta);
137
176
  }
138
177
  if (opts?.toContent) {
139
- return { content: opts.toContent(result) };
178
+ return attachMeta({ content: opts.toContent(result) }, meta);
140
179
  }
141
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
180
+ return attachMeta({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, meta);
142
181
  }
143
182
  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.
183
+ // Drop the cached client (it may point at a dead/rotated engine). Retry
184
+ // once only for a provably pre-apply auth failure; anything else surfaces.
147
185
  resetClient();
148
186
  lastError = err;
149
- if (attempt < MAX_ATTEMPTS && isReconnectableThrow(err)) {
187
+ if (attempt < MAX_ATTEMPTS && isAuthError(err))
150
188
  continue;
151
- }
152
189
  break;
153
190
  }
154
191
  }
155
192
  const msg = lastError instanceof Error ? lastError.message : String(lastError);
156
- return { content: [{ type: "text", text: msg }], isError: true };
193
+ return attachMeta({ content: [{ type: "text", text: msg }], isError: true }, { errorClass: "transport", retried, boundProjectIdHash });
157
194
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "summer-engine",
3
- "version": "2.6.3",
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.",
3
+ "version": "2.6.6",
4
+ "description": "Local Summer CLI and MCP server for Summer Engine. Install and run the engine, connect Claude Code, Cursor, Codex, Gemini, and other agents, and build real games with bundled skills, hooks, and plugins.",
5
5
  "keywords": [
6
6
  "summer-engine",
7
7
  "summer-cli",
@@ -27,6 +27,10 @@
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/SummerEngine/summer-engine-agent.git"
29
29
  },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org"
33
+ },
30
34
  "license": "MIT",
31
35
  "author": "Summer Engine <founders@summerengine.com>",
32
36
  "type": "module",