unity-open-mcp 1.1.0 → 1.3.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.
Files changed (174) hide show
  1. package/README.md +33 -2
  2. package/dist/agent-identity.d.ts +12 -0
  3. package/dist/agent-identity.js +2 -0
  4. package/dist/agent-identity.js.map +1 -1
  5. package/dist/batch-spawn.d.ts +39 -0
  6. package/dist/batch-spawn.js +287 -69
  7. package/dist/batch-spawn.js.map +1 -1
  8. package/dist/capabilities/build-capabilities.js +2 -0
  9. package/dist/capabilities/build-capabilities.js.map +1 -1
  10. package/dist/capabilities/discovery.d.ts +51 -0
  11. package/dist/capabilities/discovery.js +87 -0
  12. package/dist/capabilities/discovery.js.map +1 -0
  13. package/dist/capabilities/lifecycle.d.ts +6 -0
  14. package/dist/capabilities/lifecycle.js +17 -1
  15. package/dist/capabilities/lifecycle.js.map +1 -1
  16. package/dist/capabilities/tool-groups.js +1 -0
  17. package/dist/capabilities/tool-groups.js.map +1 -1
  18. package/dist/child-supervision.d.ts +6 -0
  19. package/dist/child-supervision.js +12 -0
  20. package/dist/child-supervision.js.map +1 -1
  21. package/dist/cli/args.d.ts +24 -2
  22. package/dist/cli/args.js +97 -2
  23. package/dist/cli/args.js.map +1 -1
  24. package/dist/cli/cli.js +42 -3
  25. package/dist/cli/cli.js.map +1 -1
  26. package/dist/cli/help-text.d.ts +1 -0
  27. package/dist/cli/help-text.js +71 -3
  28. package/dist/cli/help-text.js.map +1 -1
  29. package/dist/cli/json-guards.d.ts +2 -0
  30. package/dist/cli/json-guards.js +5 -0
  31. package/dist/cli/json-guards.js.map +1 -0
  32. package/dist/cli/setup-command.d.ts +88 -0
  33. package/dist/cli/setup-command.js +444 -0
  34. package/dist/cli/setup-command.js.map +1 -0
  35. package/dist/cli/update-command.d.ts +30 -0
  36. package/dist/cli/update-command.js +271 -0
  37. package/dist/cli/update-command.js.map +1 -0
  38. package/dist/compile-verify.d.ts +3 -0
  39. package/dist/compile-verify.js +4 -0
  40. package/dist/compile-verify.js.map +1 -1
  41. package/dist/constants.d.ts +10 -2
  42. package/dist/constants.js +10 -2
  43. package/dist/constants.js.map +1 -1
  44. package/dist/dialog-dismiss.d.ts +108 -12
  45. package/dist/dialog-dismiss.js +294 -109
  46. package/dist/dialog-dismiss.js.map +1 -1
  47. package/dist/dialog-policy.d.ts +55 -9
  48. package/dist/dialog-policy.js +85 -15
  49. package/dist/dialog-policy.js.map +1 -1
  50. package/dist/editor-search.d.ts +2 -0
  51. package/dist/editor-search.js +8 -0
  52. package/dist/editor-search.js.map +1 -0
  53. package/dist/gate-error.d.ts +3 -0
  54. package/dist/gate-error.js.map +1 -1
  55. package/dist/index.js +55 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/instance-discovery.d.ts +16 -0
  58. package/dist/instance-discovery.js +45 -0
  59. package/dist/instance-discovery.js.map +1 -1
  60. package/dist/jobs/adapters.d.ts +15 -0
  61. package/dist/jobs/adapters.js +147 -0
  62. package/dist/jobs/adapters.js.map +1 -0
  63. package/dist/jobs/handler.d.ts +3 -0
  64. package/dist/jobs/handler.js +29 -0
  65. package/dist/jobs/handler.js.map +1 -0
  66. package/dist/jobs/job-manager.d.ts +123 -0
  67. package/dist/jobs/job-manager.js +290 -0
  68. package/dist/jobs/job-manager.js.map +1 -0
  69. package/dist/live-client.d.ts +50 -2
  70. package/dist/live-client.js +221 -26
  71. package/dist/live-client.js.map +1 -1
  72. package/dist/local-tools.d.ts +17 -0
  73. package/dist/local-tools.js +47 -0
  74. package/dist/local-tools.js.map +1 -0
  75. package/dist/process-diagnostics.d.ts +47 -20
  76. package/dist/process-diagnostics.js +30 -15
  77. package/dist/process-diagnostics.js.map +1 -1
  78. package/dist/project-path.d.ts +86 -0
  79. package/dist/project-path.js +159 -0
  80. package/dist/project-path.js.map +1 -0
  81. package/dist/release-lookup.d.ts +32 -0
  82. package/dist/release-lookup.js +147 -0
  83. package/dist/release-lookup.js.map +1 -0
  84. package/dist/routers.d.ts +17 -3
  85. package/dist/routers.js +28 -8
  86. package/dist/routers.js.map +1 -1
  87. package/dist/running-unity.d.ts +21 -0
  88. package/dist/running-unity.js +50 -0
  89. package/dist/running-unity.js.map +1 -1
  90. package/dist/schema-defaults.d.ts +4 -3
  91. package/dist/schema-defaults.js +20 -6
  92. package/dist/schema-defaults.js.map +1 -1
  93. package/dist/server-instructions.js +7 -1
  94. package/dist/server-instructions.js.map +1 -1
  95. package/dist/server.d.ts +10 -3
  96. package/dist/server.js +24 -25
  97. package/dist/server.js.map +1 -1
  98. package/dist/setup/portable-config.d.ts +60 -0
  99. package/dist/setup/portable-config.js +249 -0
  100. package/dist/setup/portable-config.js.map +1 -0
  101. package/dist/skill/SKILL.md +68 -0
  102. package/dist/skill/copy-references.d.ts +2 -0
  103. package/dist/skill/copy-references.js +16 -0
  104. package/dist/skill/copy-references.js.map +1 -0
  105. package/dist/skill/generate-skill.js +5 -3
  106. package/dist/skill/generate-skill.js.map +1 -1
  107. package/dist/skill/references/batch-and-gates.md +142 -0
  108. package/dist/skill/references/compile-and-safe-mode.md +163 -0
  109. package/dist/skill/references/discovery-and-groups.md +30 -0
  110. package/dist/skill/references/routing-and-lifecycle.md +62 -0
  111. package/dist/skill/references/senses-and-tests.md +41 -0
  112. package/dist/skill/references/yaml-and-offline-work.md +33 -0
  113. package/dist/templates/mcp-wrapper.sh +35 -0
  114. package/dist/tool-contract.d.ts +8 -0
  115. package/dist/tool-contract.js +253 -0
  116. package/dist/tool-contract.js.map +1 -0
  117. package/dist/tool-router.d.ts +62 -7
  118. package/dist/tool-router.js +535 -61
  119. package/dist/tool-router.js.map +1 -1
  120. package/dist/tool-session-state.d.ts +6 -0
  121. package/dist/tool-session-state.js +14 -0
  122. package/dist/tool-session-state.js.map +1 -1
  123. package/dist/tools/agent-capabilities.js +9 -0
  124. package/dist/tools/agent-capabilities.js.map +1 -1
  125. package/dist/tools/batch-execute.js +17 -14
  126. package/dist/tools/batch-execute.js.map +1 -1
  127. package/dist/tools/bridge-status.js +4 -5
  128. package/dist/tools/bridge-status.js.map +1 -1
  129. package/dist/tools/compile-check.js +6 -4
  130. package/dist/tools/compile-check.js.map +1 -1
  131. package/dist/tools/component-get.js +2 -2
  132. package/dist/tools/component-get.js.map +1 -1
  133. package/dist/tools/component-modify.js +4 -4
  134. package/dist/tools/component-modify.js.map +1 -1
  135. package/dist/tools/execute-csharp.js +8 -4
  136. package/dist/tools/execute-csharp.js.map +1 -1
  137. package/dist/tools/gameobject-modify.js +2 -1
  138. package/dist/tools/gameobject-modify.js.map +1 -1
  139. package/dist/tools/index.d.ts +2 -0
  140. package/dist/tools/index.js +10 -2
  141. package/dist/tools/index.js.map +1 -1
  142. package/dist/tools/inputsim-key.js +12 -14
  143. package/dist/tools/inputsim-key.js.map +1 -1
  144. package/dist/tools/inputsim-pointer.js +3 -3
  145. package/dist/tools/inputsim-pointer.js.map +1 -1
  146. package/dist/tools/inputsim-step.js +7 -8
  147. package/dist/tools/inputsim-step.js.map +1 -1
  148. package/dist/tools/inputsim-touch.js +9 -10
  149. package/dist/tools/inputsim-touch.js.map +1 -1
  150. package/dist/tools/jobs.d.ts +40 -0
  151. package/dist/tools/jobs.js +18 -0
  152. package/dist/tools/jobs.js.map +1 -0
  153. package/dist/tools/manage-tools.js +10 -5
  154. package/dist/tools/manage-tools.js.map +1 -1
  155. package/dist/tools/project-commands.d.ts +40 -0
  156. package/dist/tools/project-commands.js +23 -0
  157. package/dist/tools/project-commands.js.map +1 -0
  158. package/dist/tools/read-compile-errors.js +14 -93
  159. package/dist/tools/read-compile-errors.js.map +1 -1
  160. package/dist/tools/resource-pressure.js +27 -17
  161. package/dist/tools/resource-pressure.js.map +1 -1
  162. package/dist/tools/scan-paths.js +4 -4
  163. package/dist/tools/scan-paths.js.map +1 -1
  164. package/dist/tools/schema-fragments.d.ts +15 -3
  165. package/dist/tools/schema-fragments.js +23 -13
  166. package/dist/tools/schema-fragments.js.map +1 -1
  167. package/dist/tools/upgrade.d.ts +40 -0
  168. package/dist/tools/upgrade.js +55 -0
  169. package/dist/tools/upgrade.js.map +1 -0
  170. package/dist/unity-install-discovery.d.ts +24 -0
  171. package/dist/unity-install-discovery.js +113 -4
  172. package/dist/unity-install-discovery.js.map +1 -1
  173. package/package.json +4 -3
  174. package/templates/mcp-wrapper.sh +35 -0
@@ -1,13 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PingCache } from "./ping-cache.js";
1
3
  import { BridgeToolsCache } from "./bridge-tools-cache.js";
2
4
  import { StaleAssemblyCache } from "./stale-assembly-cache.js";
3
5
  import { EditorWedgeCache, scanForFdExhaustion } from "./editor-wedge.js";
4
6
  import { detectStaleAssembly } from "./unity-log.js";
5
7
  import { deriveIsError } from "./gate-error.js";
6
- import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
8
+ import { readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from "node:fs";
7
9
  import { join } from "node:path";
8
10
  import { pollAndDismissDialogs, readDismissConfig, } from "./dialog-dismiss.js";
9
- import { readInstanceLock, classifyInstance, lockPath, isPidAlive, statusDir, hasRecentPendingTestRun, resolveRefreshedEndpoint } from "./instance-discovery.js";
10
- import { bridgeBaseUrl, BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS, BRIDGE_MIN_TIMEOUT_MS, PORT_ENV_VAR, PROJECT_PATH_ENV_VAR, } from "./constants.js";
11
+ import { readInstanceLock, classifyInstance, lockPath, isPidAlive, statusDir, hasRecentPendingTestRun, resolveRefreshedEndpoint, normalizePath, findInstanceLockByPort, computePort } from "./instance-discovery.js";
12
+ import { bridgeBaseUrl, BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS, BRIDGE_MIN_TIMEOUT_MS, COMPILE_STATE_HEADER, PORT_ENV_VAR, PROJECT_PATH_ENV_VAR, } from "./constants.js";
11
13
  import { findUnityForProject } from "./running-unity.js";
12
14
  import { makeErrorResult } from "./results.js";
13
15
  import { checkBridgeCompat, isVersionCheckSuppressed, } from "./compat.js";
@@ -21,10 +23,19 @@ import { readRetryTunables, } from "./retry-policy.js";
21
23
  // an agent can branch when a recompile reported success but the compiled state
22
24
  // did not advance.
23
25
  import { detectCompileVerify, buildCompileVerifyAnnotation, } from "./compile-verify.js";
24
- import { lifecycleFor } from "./capabilities/lifecycle.js";
26
+ import { lifecycleForCall } from "./capabilities/lifecycle.js";
25
27
  // M23 Plan 3 — per-process agent identity (sent as X-Agent-Id so the bridge's
26
28
  // fair round-robin queue can schedule across agents).
27
29
  import { PROCESS_AGENT_ID } from "./agent-identity.js";
30
+ function canonicalProjectPath(p) {
31
+ let real = p;
32
+ try {
33
+ real = realpathSync.native(p);
34
+ }
35
+ catch { /* unresolvable here: compare as spelled */ }
36
+ const norm = normalizePath(real);
37
+ return process.platform === "win32" ? norm.toLowerCase() : norm;
38
+ }
28
39
  const PING_TIMEOUT_MS = 5_000;
29
40
  // BRIDGE_DEFAULT_TIMEOUT_MS now lives in constants.ts (single source shared
30
41
  // with the bridge C# parity test). The client fetch timeout must never
@@ -85,6 +96,7 @@ const COMPILE_RELOAD_ANNOTATION_TOOLS = new Set([
85
96
  "unity_open_mcp_package_remove",
86
97
  "unity_open_mcp_reimport_package",
87
98
  "unity_open_mcp_compile_check",
99
+ "unity_open_mcp_recompile_scripts",
88
100
  ]);
89
101
  /**
90
102
  * M4 — should the compile-verify annotation run for this tool? Returns true
@@ -182,6 +194,11 @@ function buildOfflineHint(projectPath) {
182
194
  }
183
195
  return `${base} (lock not readable — Unity may not be running).`;
184
196
  }
197
+ /** `/project-command-jobs` answered without a job snapshot; `status` is its non-OK HTTP status. */
198
+ export class ProjectJobHttpError extends Error {
199
+ status;
200
+ constructor(status) { super(`Project job transport HTTP ${status}`); this.status = status; }
201
+ }
185
202
  export class LiveClient {
186
203
  baseUrl;
187
204
  pingCache;
@@ -216,6 +233,8 @@ export class LiveClient {
216
233
  /** specs/feedback.md 2026-07-03 — opt-in for the "Unsaved changes to scene"
217
234
  * modal (destructive under every policy, off by default). */
218
235
  allowUnsavedSceneDismiss;
236
+ /** Explicit opt-in for continuing through a non-matching Editor warning. */
237
+ allowVersionMismatch;
219
238
  /** M14 — per-session bearer token auto-discovered from the instance lock.
220
239
  * Undefined when no live lock was found (older bridge / env port override);
221
240
  * in that case no Authorization header is sent and the bridge must be in
@@ -246,6 +265,8 @@ export class LiveClient {
246
265
  * the constructor used; without it a refresh would silently switch from an
247
266
  * env-pinned port to the lock port. */
248
267
  envPort;
268
+ /** Memoized outcome of the bridge-vs-server project-path comparison. */
269
+ projectMatch = null;
249
270
  constructor(port, pingCache, authToken, projectPath, agentId = PROCESS_AGENT_ID, envPort) {
250
271
  this.baseUrl = bridgeBaseUrl(port);
251
272
  this.pingCache = pingCache;
@@ -281,6 +302,7 @@ export class LiveClient {
281
302
  this.dialogPolicy = dismissCfg.policy;
282
303
  this.allowProjectUpgrade = dismissCfg.allowProjectUpgrade;
283
304
  this.allowUnsavedSceneDismiss = dismissCfg.allowUnsavedSceneDismiss;
305
+ this.allowVersionMismatch = dismissCfg.allowVersionMismatch;
284
306
  this.compatWarned = false;
285
307
  }
286
308
  /**
@@ -303,6 +325,57 @@ export class LiveClient {
303
325
  console.warn(result.message);
304
326
  }
305
327
  }
328
+ /** Bounded read-only main-thread snapshot; unavailable bridges fail open to logs. */
329
+ async readCompileState() {
330
+ try {
331
+ this.refreshEndpointFromLock();
332
+ const res = await this.fetchWithTimeout("/compile-state", { method: "GET" }, 2000);
333
+ const body = await res.json();
334
+ return res.ok ? this.acceptCompileState(body) : null;
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ }
340
+ /** The compile state that qualifies a tool result: the snapshot the bridge
341
+ * took inside the call's own dispatch when the call asked for it
342
+ * (COMPILE_STATE_HEADER), else a fresh read. An older bridge never attaches
343
+ * one, and the bridge leaves it off once a compile ran during the call's
344
+ * settle wait, so neither path can qualify a result with a state older
345
+ * than the call. */
346
+ async compileStateFor(res) {
347
+ const header = res.headers.get(COMPILE_STATE_HEADER);
348
+ if (header !== null) {
349
+ try {
350
+ return this.acceptCompileState(JSON.parse(Buffer.from(header, "base64").toString("utf8")));
351
+ }
352
+ catch { /* malformed: read it instead */ }
353
+ }
354
+ return this.readCompileState();
355
+ }
356
+ /** A well-formed compile state reported for this client's project, else null. */
357
+ acceptCompileState(body) {
358
+ if (body === null || typeof body !== "object")
359
+ return null;
360
+ if (typeof body.generation !== "number" || typeof body.status !== "string")
361
+ return null;
362
+ if (this.projectPath && !this.sameProject(body.projectPath))
363
+ return null;
364
+ return body;
365
+ }
366
+ /** The bridge reports DirectoryInfo.FullName while the server holds a
367
+ * path.resolve'd string; separators, drive-letter case and /private-style
368
+ * symlinks can differ for the same project, and a strict compare would
369
+ * silently disable live compile state. Compare canonical forms instead. */
370
+ sameProject(reported) {
371
+ if (typeof reported !== "string" || !this.projectPath)
372
+ return false;
373
+ if (this.projectMatch?.reported === reported)
374
+ return this.projectMatch.same;
375
+ const same = canonicalProjectPath(reported) === canonicalProjectPath(this.projectPath);
376
+ this.projectMatch = { reported, same };
377
+ return same;
378
+ }
306
379
  async isLiveAvailable(fetchTimeoutMs) {
307
380
  try {
308
381
  const res = await this.fetchWithTimeout("/ping", { method: "GET" }, fetchTimeoutMs);
@@ -328,14 +401,14 @@ export class LiveClient {
328
401
  getCachedPing() {
329
402
  return this.pingCache.get();
330
403
  }
331
- async route(toolName, args) {
404
+ async route(toolName, args, commandLifecycle) {
332
405
  if (toolName === "unity_open_mcp_ping") {
333
406
  return this.handlePing();
334
407
  }
335
408
  if (toolName === "unity_senses_run_tests") {
336
409
  return this.handleRunTests(args);
337
410
  }
338
- return this.handleToolCall(toolName, args);
411
+ return this.handleToolCall(toolName, args, commandLifecycle);
339
412
  }
340
413
  async handlePing() {
341
414
  try {
@@ -441,7 +514,7 @@ export class LiveClient {
441
514
  });
442
515
  }
443
516
  }
444
- async handleToolCall(toolName, args) {
517
+ async handleToolCall(toolName, args, commandLifecycle) {
445
518
  const readyError = await this.ensureReady();
446
519
  if (readyError)
447
520
  return readyError;
@@ -460,15 +533,64 @@ export class LiveClient {
460
533
  // warning that told the agent to "force a rebuild" after `return 42;`.
461
534
  // The set below is the subset of compile-reload tools whose PURPOSE is to
462
535
  // trigger or observe a recompile; see `shouldAnnotateCompileVerify`.
463
- const isCompileReload = lifecycleFor(toolName).class === "compile-reload";
536
+ const isCompileReload = lifecycleForCall(toolName, args).class === "compile-reload";
464
537
  const annotate = isCompileReload && shouldAnnotateCompileVerify(toolName);
465
538
  const before = annotate ? await this.captureCompileSnapshot() : null;
466
- const result = await this.postTool(toolName, args, true);
539
+ const result = await this.postTool(toolName, args, true, 1, commandLifecycle);
467
540
  if (annotate && !result.isError && before !== null) {
468
541
  return this.annotateCompileVerify(toolName, result, before, args);
469
542
  }
470
543
  return result;
471
544
  }
545
+ /**
546
+ * Transient client for a per-request `_meta.port` override. The override
547
+ * names a bridge, not a project, so the client binds to the project whose
548
+ * instance lock claims that port: its lock-derived diagnostics
549
+ * (bridge_compile_failed, cold Safe Mode, the ping lockCheck, the offline
550
+ * hint) and its bearer token then describe the Editor actually called, not
551
+ * the configured project. With no claiming lock it binds to the configured
552
+ * project only when that project has no lock and the port is its
553
+ * deterministic one; otherwise it runs without a project, so a failure
554
+ * reports plain bridge_offline instead of another Editor's state. The port
555
+ * is passed as envPort: an explicit override never re-resolves from a lock.
556
+ * A private PingCache keeps the default bridge's readiness snapshot from
557
+ * short-circuiting this bridge's own /ping.
558
+ */
559
+ static forPortOverride(port, defaultProjectPath, agentId) {
560
+ const lock = findInstanceLockByPort(port);
561
+ const projectPath = lock
562
+ ? lock.projectPath
563
+ : defaultProjectPath && !readInstanceLock(defaultProjectPath) && computePort(defaultProjectPath) === port
564
+ ? defaultProjectPath
565
+ : undefined;
566
+ const token = lock && isPidAlive(lock.pid) ? lock.authToken : undefined;
567
+ const authToken = typeof token === "string" && token.length > 0 ? token : undefined;
568
+ return new LiveClient(port, new PingCache(), authToken, projectPath, agentId, port);
569
+ }
570
+ /** Preserve configured endpoint/auth when attaching an independent job owner. */
571
+ forAgent(agent) {
572
+ return new LiveClient(Number(new URL(this.baseUrl).port), new PingCache(), this.authToken, this.projectPath, agent, this.envPort);
573
+ }
574
+ /** Job-owned test start has no transport replay; poll the existing file handoff. */
575
+ async runTestsJob(args, onStarted) {
576
+ const readyError = await this.ensureReady();
577
+ if (readyError)
578
+ return readyError;
579
+ const { timeout_ms: _budget, ...startArgs } = args;
580
+ const response = await this.fetchWithTimeout("/tools/unity_senses_run_tests", {
581
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(startArgs),
582
+ }, 10_000);
583
+ if (!response.ok)
584
+ throw new Error(`Test start HTTP ${response.status}; execution outcome unknown.`);
585
+ const body = await response.json();
586
+ if (body.status !== "started" || body.runId !== args.run_id) {
587
+ if (body.error || body.mutation)
588
+ return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
589
+ throw new Error("Test start did not confirm the owned run id.");
590
+ }
591
+ onStarted();
592
+ return this.pollTestResults(String(args.run_id), args);
593
+ }
472
594
  async handleRunTests(args) {
473
595
  const readyError = await this.ensureReady();
474
596
  if (readyError)
@@ -669,20 +791,22 @@ export class LiveClient {
669
791
  * (1 initial attempt + N retries), matching the documented RETRY_CONFIG
670
792
  * semantics; once exhausted the offline error is surfaced instead of recursing.
671
793
  */
672
- async postTool(toolName, args, retryOn503, attempt = 1) {
794
+ async postTool(toolName, args, retryOn503, attempt = 1, commandLifecycle) {
673
795
  try {
674
- const res = await this.postToolFetch(toolName, args, retryOn503);
796
+ const res = await this.postToolFetch(toolName, args, retryOn503, commandLifecycle);
675
797
  // 503 + compile settled cleanly → re-POST once without 503 retry. This is
676
798
  // a deliberate single re-dispatch (retryOn503 flips to false, so a second
677
799
  // 503 surfaces the error), but it still counts as a POST attempt for the
678
800
  // recursion bound.
679
801
  if (isCompileRepostSentinel(res)) {
680
- return this.postTool(toolName, args, false, attempt + 1);
802
+ return this.postTool(toolName, args, false, attempt + 1, commandLifecycle);
681
803
  }
682
804
  return res;
683
805
  }
684
806
  catch (err) {
685
- return this.handlePostFailure(toolName, args, err, retryOn503, attempt);
807
+ if (err instanceof Error && err.message.startsWith("bridge_response_request_mismatch"))
808
+ return makeErrorResult({ code: "bridge_response_request_mismatch", message: err.message });
809
+ return this.handlePostFailure(toolName, args, err, retryOn503, attempt, commandLifecycle);
686
810
  }
687
811
  }
688
812
  /**
@@ -693,7 +817,7 @@ export class LiveClient {
693
817
  * should re-POST without 503 retry. Throws on transient fetch failure so the
694
818
  * caller's catch runs {@link handlePostFailure}.
695
819
  */
696
- async postToolFetch(toolName, args, retryOn503) {
820
+ async postToolFetch(toolName, args, retryOn503, commandLifecycle) {
697
821
  const rawTimeout = args.timeout_ms;
698
822
  const explicitTimeout = typeof rawTimeout === "number";
699
823
  const requestedTimeoutMs = explicitTimeout ? rawTimeout : 60_000;
@@ -719,7 +843,12 @@ export class LiveClient {
719
843
  const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
720
844
  const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
721
845
  method: "POST",
722
- headers: { "Content-Type": "application/json; charset=utf-8" },
846
+ headers: {
847
+ "Content-Type": "application/json; charset=utf-8",
848
+ // shapeToolResult qualifies these results with the compile state;
849
+ // the bridge snapshots it inside this call's dispatch on request.
850
+ ...(STALE_DOMAIN_ANNOTATION_TOOLS.has(toolName) ? { [COMPILE_STATE_HEADER]: "1" } : {}),
851
+ },
723
852
  body: JSON.stringify(forwardedArgs),
724
853
  }, fetchTimeout);
725
854
  if (res.status === 503 && retryOn503) {
@@ -743,7 +872,7 @@ export class LiveClient {
743
872
  },
744
873
  });
745
874
  }
746
- const result = await this.shapeToolResult(toolName, res);
875
+ const result = await this.shapeToolResult(toolName, res, commandLifecycle, args);
747
876
  // Surface the clamp ONLY when the caller explicitly requested a timeout
748
877
  // above the cap (feedback N1). When timeout_ms is absent the server default
749
878
  // (60s) is silently clamped to the cap — there is no caller expectation to
@@ -776,7 +905,7 @@ export class LiveClient {
776
905
  * - direct body (verbatim, isError when a top-level error field is present),
777
906
  * - unparsable body (bridge_response_unparsable corruption error).
778
907
  */
779
- async shapeToolResult(toolName, res) {
908
+ async shapeToolResult(toolName, res, commandLifecycle, args = {}) {
780
909
  // The bridge returns TWO response shapes from /tools/*:
781
910
  // 1. A MUTATION envelope: { mutation: { success, ... }, gate: {...} }
782
911
  // for gate-tracked mutating tools (gameobject_*, assets_*, etc.).
@@ -830,7 +959,27 @@ export class LiveClient {
830
959
  STALE_DOMAIN_ANNOTATION_TOOLS.has(toolName) &&
831
960
  parsed.error == null &&
832
961
  parsed._staleDomain === undefined) {
833
- const staleAsm = this.resolveStaleAssembly();
962
+ const liveCompile = await this.compileStateFor(res);
963
+ const staleAsm = liveCompile?.status === "no_errors_found" && liveCompile.sourceMatches === true
964
+ ? null : liveCompile?.status === "assembly_stale"
965
+ ? { staleAssembly: true, newerSources: [], hint: "Source content changed after the completed compile generation. Recompile before trusting this result." }
966
+ : this.resolveStaleAssembly();
967
+ if (liveCompile) {
968
+ // Compact projection only. The full body carries `errors[]` — every
969
+ // CS diagnostic of a failed compile — and would repeat it on every
970
+ // read result for the rest of the session; read_compile_errors owns
971
+ // the list. Keep just the fields that qualify THIS result.
972
+ parsed._compileState = {
973
+ status: liveCompile.status,
974
+ generation: liveCompile.generation,
975
+ sourceMatches: liveCompile.sourceMatches,
976
+ beforeAssemblyMtimeMs: liveCompile.beforeAssemblyMtimeMs,
977
+ afterAssemblyMtimeMs: liveCompile.afterAssemblyMtimeMs,
978
+ // The header snapshot carries the count; a /compile-state read, the list.
979
+ errorCount: Array.isArray(liveCompile.errors) ? liveCompile.errors.length
980
+ : typeof liveCompile.errorCount === "number" ? liveCompile.errorCount : 0,
981
+ };
982
+ }
834
983
  if (staleAsm != null && staleAsm.staleAssembly) {
835
984
  // specs/feedback.md 2026-08-14 — a stale assembly plus a wedged build
836
985
  // driver is not a footnote, it is an execution-integrity failure. In
@@ -950,7 +1099,7 @@ export class LiveClient {
950
1099
  // 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
951
1100
  if (parsed == null) {
952
1101
  const isEmptyLikeReload = rawText.trim().length === 0;
953
- if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
1102
+ if (isEmptyLikeReload && (commandLifecycle === "restart_then_settle" || lifecycleForCall(toolName, args).class === "compile-reload")) {
954
1103
  return {
955
1104
  content: [
956
1105
  {
@@ -1036,7 +1185,7 @@ export class LiveClient {
1036
1185
  * itself bounded (`transientRetryAttempts` iterations of `/ping`); this outer
1037
1186
  * bound caps how many times we re-enter that recovery + re-POST cycle.
1038
1187
  */
1039
- async handlePostFailure(toolName, args, err, retryOn503, attempt) {
1188
+ async handlePostFailure(toolName, args, err, retryOn503, attempt, commandLifecycle) {
1040
1189
  const endpointBefore = this.baseUrl;
1041
1190
  const recovered = await this.handleTransientOffline("post");
1042
1191
  if (recovered !== null)
@@ -1046,9 +1195,10 @@ export class LiveClient {
1046
1195
  // budget is the initial attempt plus the configured transient retry count.
1047
1196
  const maxAttempts = 1 + this.retry.transientRetryAttempts;
1048
1197
  const attemptsExhausted = attempt >= maxAttempts;
1049
- if (!attemptsExhausted &&
1198
+ if (toolName !== "unity_open_mcp_project_commands" &&
1199
+ !attemptsExhausted &&
1050
1200
  shouldRetryPostAfterFailure(err, endpointChanged)) {
1051
- return this.postTool(toolName, args, retryOn503, attempt + 1);
1201
+ return this.postTool(toolName, args, retryOn503, attempt + 1, commandLifecycle);
1052
1202
  }
1053
1203
  const exhaustedNote = attemptsExhausted
1054
1204
  ? ` The POST retry budget (${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}) was exhausted after ${attempt} attempt${attempt === 1 ? "" : "s"}; the bridge is reachable by /ping but POSTs keep failing.`
@@ -1461,6 +1611,7 @@ export class LiveClient {
1461
1611
  policy: this.dialogPolicy,
1462
1612
  allowProjectUpgrade: this.allowProjectUpgrade,
1463
1613
  allowUnsavedSceneDismiss: this.allowUnsavedSceneDismiss,
1614
+ allowVersionMismatch: this.allowVersionMismatch,
1464
1615
  abortSignal: dismissAbort.signal,
1465
1616
  };
1466
1617
  dismissDone = this.runDismissLoop(dismissOpts);
@@ -1558,6 +1709,36 @@ export class LiveClient {
1558
1709
  };
1559
1710
  }
1560
1711
  }
1712
+ /** Private job transport: one bounded request, never an automatic mutation retry. */
1713
+ async projectCommandJob(args) {
1714
+ const response = await this.fetchWithTimeout("/project-command-jobs", {
1715
+ method: "POST", body: JSON.stringify(args), headers: { "Content-Type": "application/json" },
1716
+ }, 10_000);
1717
+ if (!response.ok)
1718
+ throw new ProjectJobHttpError(response.status);
1719
+ return await response.json();
1720
+ }
1721
+ /** Fresh authenticated bounded catalog read; no disk/session cache can hide a reload. */
1722
+ async projectCommands(args) {
1723
+ const query = new URLSearchParams({ catalog: "project_commands" });
1724
+ for (const key of ["action", "id", "query", "group", "package", "offset", "limit"])
1725
+ if (args[key] !== undefined)
1726
+ query.set(key, String(args[key]));
1727
+ for (const tag of args.tags ?? [])
1728
+ query.append("tag", tag);
1729
+ try {
1730
+ const response = await this.fetchWithTimeout(`/tools?${query}`, { method: "GET" }, 5_000);
1731
+ if (!response.ok)
1732
+ return { error: { code: "catalog_unavailable", message: `Bridge catalog HTTP ${response.status}.` } };
1733
+ const body = await response.json();
1734
+ if (body.catalogVersion !== 1 || (!body.error && (args.action === "describe" ? !body.command : !Array.isArray(body.commands))))
1735
+ return { error: { code: "catalog_unsupported", message: "Bridge does not support project command catalog version 1. Update the bridge." } };
1736
+ return body;
1737
+ }
1738
+ catch {
1739
+ return { error: { code: "catalog_unavailable", message: "Project command catalog requires a reachable live Editor. Retry after compilation/reload settles." } };
1740
+ }
1741
+ }
1561
1742
  /**
1562
1743
  * M18 Plan 2 / T18.2.3 — fetch the compiled-state tool inventory from the
1563
1744
  * bridge (`GET /tools`). Returns the set of tool names the bridge compiled
@@ -1637,6 +1818,12 @@ export class LiveClient {
1637
1818
  const dllMtime = this.newestScriptAssembliesMtime();
1638
1819
  if (dllMtime !== undefined)
1639
1820
  snap.dllMtimeMs = dllMtime;
1821
+ const compile = await this.readCompileState();
1822
+ if (compile) {
1823
+ snap.compileGeneration = compile.generation;
1824
+ snap.compileStatus = compile.status;
1825
+ snap.sourceMatches = compile.sourceMatches === true && compile.status === "no_errors_found";
1826
+ }
1640
1827
  return snap;
1641
1828
  }
1642
1829
  /**
@@ -1739,8 +1926,6 @@ export class LiveClient {
1739
1926
  : undefined;
1740
1927
  const detection = detectCompileVerify({ before, after, sourceMtimeMs });
1741
1928
  const annotation = buildCompileVerifyAnnotation(detection);
1742
- if (annotation === null)
1743
- return result;
1744
1929
  // Inject into the first text content block's JSON body. Mirrors the
1745
1930
  // injectRouteMeta shape in tool-router.ts.
1746
1931
  const textIndex = result.content.findIndex((c) => c.type === "text");
@@ -1751,7 +1936,7 @@ export class LiveClient {
1751
1936
  return result;
1752
1937
  try {
1753
1938
  const body = JSON.parse(block.text);
1754
- body._compileVerify = annotation;
1939
+ body._compileVerify = { ...annotation, before, after };
1755
1940
  const newContent = result.content.slice();
1756
1941
  newContent[textIndex] = { type: "text", text: JSON.stringify(body) };
1757
1942
  return { ...result, content: newContent };
@@ -1819,6 +2004,8 @@ export class LiveClient {
1819
2004
  // from the instance lock. Merge with any caller-supplied headers so we
1820
2005
  // never clobber a per-request value.
1821
2006
  const headers = new Headers(init.headers);
2007
+ const requestId = randomUUID();
2008
+ headers.set("X-Request-Id", requestId);
1822
2009
  if (this.authToken && !headers.has("Authorization")) {
1823
2010
  headers.set("Authorization", `Bearer ${this.authToken}`);
1824
2011
  }
@@ -1833,6 +2020,12 @@ export class LiveClient {
1833
2020
  headers,
1834
2021
  signal: controller.signal,
1835
2022
  }).then((res) => {
2023
+ const echoedId = res.headers.get("X-Request-Id");
2024
+ if (echoedId !== null && echoedId !== requestId) {
2025
+ controller.abort();
2026
+ dispose();
2027
+ throw new Error("bridge_response_request_mismatch: response belongs to another request");
2028
+ }
1836
2029
  // Wrap the body-reading accessors so the abort timer is cleared when
1837
2030
  // the body is consumed (the common path) or errors. The wrappers
1838
2031
  // delegate to the original methods; they only add the dispose() side
@@ -1855,8 +2048,10 @@ export class LiveClient {
1855
2048
  // body WAS claimed, the fallback is a no-op — the abort timer
1856
2049
  // remains armed to protect the in-progress read.
1857
2050
  claimTimer = setTimeout(() => {
1858
- if (!bodyClaimed)
2051
+ if (!bodyClaimed) {
2052
+ controller.abort();
1859
2053
  dispose();
2054
+ }
1860
2055
  }, 100);
1861
2056
  claimTimer.unref();
1862
2057
  }