unity-open-mcp 0.4.1 → 0.6.1
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.
- package/README.md +20 -14
- package/dist/batch-spawn.d.ts +4 -0
- package/dist/batch-spawn.js +55 -11
- package/dist/batch-spawn.js.map +1 -1
- package/dist/capabilities/build-capabilities.js +6 -0
- package/dist/capabilities/build-capabilities.js.map +1 -1
- package/dist/capabilities/cost-hints.js +30 -0
- package/dist/capabilities/cost-hints.js.map +1 -1
- package/dist/capabilities/lifecycle.js +13 -0
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.js +4 -0
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/cli/commands.js +9 -8
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/ping-poller.js +20 -0
- package/dist/cli/ping-poller.js.map +1 -1
- package/dist/constants.d.ts +89 -0
- package/dist/constants.js +109 -0
- package/dist/constants.js.map +1 -0
- package/dist/dialog-dismiss.d.ts +0 -7
- package/dist/dialog-dismiss.js +17 -19
- package/dist/dialog-dismiss.js.map +1 -1
- package/dist/hub-control.d.ts +6 -2
- package/dist/hub-control.js +8 -8
- package/dist/hub-control.js.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/instance-discovery.js +2 -1
- package/dist/instance-discovery.js.map +1 -1
- package/dist/live-client.d.ts +72 -0
- package/dist/live-client.js +387 -255
- package/dist/live-client.js.map +1 -1
- package/dist/offline/api.d.ts +171 -0
- package/dist/offline/api.js +852 -0
- package/dist/offline/api.js.map +1 -0
- package/dist/offline/hierarchy.d.ts +24 -0
- package/dist/offline/hierarchy.js +242 -0
- package/dist/offline/hierarchy.js.map +1 -0
- package/dist/offline/index-builders.d.ts +11 -0
- package/dist/offline/index-builders.js +202 -0
- package/dist/offline/index-builders.js.map +1 -0
- package/dist/offline/model.d.ts +8 -0
- package/dist/offline/model.js +188 -0
- package/dist/offline/model.js.map +1 -0
- package/dist/offline/names.d.ts +7 -0
- package/dist/offline/names.js +20 -0
- package/dist/offline/names.js.map +1 -0
- package/dist/offline/overrides.d.ts +17 -0
- package/dist/offline/overrides.js +266 -0
- package/dist/offline/overrides.js.map +1 -0
- package/dist/offline/parse.d.ts +25 -0
- package/dist/offline/parse.js +236 -0
- package/dist/offline/parse.js.map +1 -0
- package/dist/offline/paths.d.ts +4 -0
- package/dist/offline/paths.js +23 -0
- package/dist/offline/paths.js.map +1 -0
- package/dist/offline/primitives.d.ts +17 -0
- package/dist/offline/primitives.js +195 -0
- package/dist/offline/primitives.js.map +1 -0
- package/dist/offline/references.d.ts +9 -0
- package/dist/offline/references.js +55 -0
- package/dist/offline/references.js.map +1 -0
- package/dist/offline/types.d.ts +65 -0
- package/dist/offline/types.js +154 -0
- package/dist/offline/types.js.map +1 -0
- package/dist/offline.d.ts +2 -180
- package/dist/offline.js +27 -2282
- package/dist/offline.js.map +1 -1
- package/dist/project-health.d.ts +1 -1
- package/dist/project-health.js +66 -3
- package/dist/project-health.js.map +1 -1
- package/dist/routers.js +6 -5
- package/dist/routers.js.map +1 -1
- package/dist/running-unity.d.ts +105 -0
- package/dist/running-unity.js +387 -0
- package/dist/running-unity.js.map +1 -0
- package/dist/skill/client-paths.d.ts +22 -0
- package/dist/skill/client-paths.js +66 -5
- package/dist/skill/client-paths.js.map +1 -1
- package/dist/skill/generate-skill.d.ts +43 -4
- package/dist/skill/generate-skill.js +132 -34
- package/dist/skill/generate-skill.js.map +1 -1
- package/dist/tool-router.d.ts +12 -0
- package/dist/tool-router.js +112 -100
- package/dist/tool-router.js.map +1 -1
- package/dist/tools/batch-execute.d.ts +2 -0
- package/dist/tools/batch-execute.js +83 -0
- package/dist/tools/batch-execute.js.map +1 -0
- package/dist/tools/bridge-status.js +8 -1
- package/dist/tools/bridge-status.js.map +1 -1
- package/dist/tools/component-get.js +44 -11
- package/dist/tools/component-get.js.map +1 -1
- package/dist/tools/editor-status.js +3 -2
- package/dist/tools/editor-status.js.map +1 -1
- package/dist/tools/execute-csharp.js +3 -2
- package/dist/tools/execute-csharp.js.map +1 -1
- package/dist/tools/generate-skill.js +8 -0
- package/dist/tools/generate-skill.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +29 -17
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsystem-asset-create.js +4 -4
- package/dist/tools/inputsystem-asset-create.js.map +1 -1
- package/dist/tools/invoke-method.js +2 -1
- package/dist/tools/invoke-method.js.map +1 -1
- package/dist/tools/navigation-surface-add.js +6 -5
- package/dist/tools/navigation-surface-add.js.map +1 -1
- package/dist/tools/probuilder-create-shape.js +4 -4
- package/dist/tools/probuilder-create-shape.js.map +1 -1
- package/dist/tools/read-compile-errors.js +44 -31
- package/dist/tools/read-compile-errors.js.map +1 -1
- package/dist/tools/scene-get-data.js +10 -2
- package/dist/tools/scene-get-data.js.map +1 -1
- package/dist/tools/scene-save.js +16 -5
- package/dist/tools/scene-save.js.map +1 -1
- package/dist/tools/scene-set-active.js +22 -6
- package/dist/tools/scene-set-active.js.map +1 -1
- package/dist/tools/scene-unload.js +20 -6
- package/dist/tools/scene-unload.js.map +1 -1
- package/package.json +1 -1
package/dist/live-client.js
CHANGED
|
@@ -3,6 +3,8 @@ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "nod
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { pollAndDismissDialogs, readDismissConfig, } from "./dialog-dismiss.js";
|
|
5
5
|
import { readInstanceLock, classifyInstance, lockPath, isPidAlive, statusDir, hasRecentPendingTestRun } from "./instance-discovery.js";
|
|
6
|
+
import { bridgeBaseUrl, BRIDGE_DEFAULT_TIMEOUT_MS, PORT_ENV_VAR, PROJECT_PATH_ENV_VAR, } from "./constants.js";
|
|
7
|
+
import { findUnityForProject } from "./running-unity.js";
|
|
6
8
|
import { makeErrorResult } from "./results.js";
|
|
7
9
|
import { checkBridgeCompat, isVersionCheckSuppressed, } from "./compat.js";
|
|
8
10
|
// M23 Plan 3 — structured retry policy + env-overridable tunables. Replaces
|
|
@@ -20,17 +22,9 @@ import { lifecycleFor } from "./capabilities/lifecycle.js";
|
|
|
20
22
|
// fair round-robin queue can schedule across agents).
|
|
21
23
|
import { PROCESS_AGENT_ID } from "./agent-identity.js";
|
|
22
24
|
const PING_TIMEOUT_MS = 5_000;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// client aborts first it re-POSTs while the bridge is still processing the
|
|
27
|
-
// original Work, manufacturing duplicate side-effects (specs/feedback.md entry
|
|
28
|
-
// 4B/4A). postTool floors its fetch timeout at BRIDGE_DEFAULT_TIMEOUT_MS +
|
|
29
|
-
// slack so a small/absent timeout_ms can't make the client give up before the
|
|
30
|
-
// bridge. Kept as a literal here (not imported from the C# side) because the
|
|
31
|
-
// bridge assembly isn't readable from the TS server; the cross-reference above
|
|
32
|
-
// is the contract — bump both together.
|
|
33
|
-
const BRIDGE_DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
|
+
// BRIDGE_DEFAULT_TIMEOUT_MS now lives in constants.ts (single source shared
|
|
26
|
+
// with the bridge C# parity test). The client fetch timeout must never
|
|
27
|
+
// preempt the bridge's default wait — see constants.ts for the contract.
|
|
34
28
|
const DIRECT_RESPONSE_TOOLS = new Set([
|
|
35
29
|
"unity_open_mcp_validate_edit",
|
|
36
30
|
"unity_open_mcp_checkpoint_create",
|
|
@@ -62,6 +56,10 @@ const DIRECT_RESPONSE_TOOLS = new Set([
|
|
|
62
56
|
function sleep(ms) {
|
|
63
57
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
64
58
|
}
|
|
59
|
+
const COMPILE_REPOST_SENTINEL = { __compileRepost: 0 };
|
|
60
|
+
function isCompileRepostSentinel(v) {
|
|
61
|
+
return typeof v === "object" && v !== null && "__compileRepost" in v;
|
|
62
|
+
}
|
|
65
63
|
/**
|
|
66
64
|
* Decide whether {@link LiveClient#postTool}'s catch block should re-POST after
|
|
67
65
|
* a transient fetch failure. Exported so the retry policy is unit-testable in
|
|
@@ -104,9 +102,9 @@ export function shouldRetryPostAfterFailure(err, endpointChanged) {
|
|
|
104
102
|
const OFFLINE_HINT = "Ensure the Unity Editor is open with the Agent Bridge running. " +
|
|
105
103
|
"The bridge port is per-project (20000 + sha256(projectPath) % 10000), not " +
|
|
106
104
|
"fixed — if Unity is open the MCP server may be aimed at the wrong port. " +
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
`Check the instance lock at ~/.unity-open-mcp/instances/<sha256(projectPath)>.json ` +
|
|
106
|
+
`for the live port/pid, or set ${PORT_ENV_VAR}. If Unity is not ` +
|
|
107
|
+
`open, launch it (or set UNITY_PATH + ${PROJECT_PATH_ENV_VAR} for batch fallback).`;
|
|
110
108
|
/**
|
|
111
109
|
* Build a per-instance offline hint that names THIS project's lock file path
|
|
112
110
|
* and (when the lock is readable) its port/pid/state, so an agent debugging a
|
|
@@ -176,7 +174,7 @@ export class LiveClient {
|
|
|
176
174
|
* env-pinned port to the lock port. */
|
|
177
175
|
envPort;
|
|
178
176
|
constructor(port, pingCache, authToken, projectPath, agentId = PROCESS_AGENT_ID, envPort) {
|
|
179
|
-
this.baseUrl =
|
|
177
|
+
this.baseUrl = bridgeBaseUrl(port);
|
|
180
178
|
this.pingCache = pingCache;
|
|
181
179
|
this.authToken = authToken;
|
|
182
180
|
this.projectPath = projectPath;
|
|
@@ -256,6 +254,16 @@ export class LiveClient {
|
|
|
256
254
|
};
|
|
257
255
|
}
|
|
258
256
|
catch {
|
|
257
|
+
// M27 Plan 1 — ping is the first probe many agents use to decide
|
|
258
|
+
// whether to call read_compile_errors. Before returning a generic
|
|
259
|
+
// bridge_offline, check the cold Safe Mode signature (no lock + a live
|
|
260
|
+
// Unity process for this project) so the agent gets the same
|
|
261
|
+
// bridge_compile_failed recovery hint as the mid-session dead-bridge
|
|
262
|
+
// path. Without this an agent hitting ping first would see only
|
|
263
|
+
// bridge_offline and have to guess.
|
|
264
|
+
const coldSafeMode = this.coldSafeModeResult();
|
|
265
|
+
if (coldSafeMode)
|
|
266
|
+
return coldSafeMode;
|
|
259
267
|
return makeErrorResult({
|
|
260
268
|
code: "bridge_offline",
|
|
261
269
|
message: `Bridge is not reachable at ${this.baseUrl}. ${buildOfflineHint(this.projectPath)}`,
|
|
@@ -362,258 +370,298 @@ export class LiveClient {
|
|
|
362
370
|
},
|
|
363
371
|
});
|
|
364
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* POST a tool call to the bridge and shape the response. Decomposed in
|
|
375
|
+
* M28-refactoring Plan 3 (T3.3) into named steps so retry, compile-wait,
|
|
376
|
+
* transient-offline handling, and result shaping are independently readable.
|
|
377
|
+
*
|
|
378
|
+
* Pipeline:
|
|
379
|
+
* 1. {@link postToolFetch} — POST with a timeout floored above the bridge's
|
|
380
|
+
* own default wait, handling 503-compile-retry and non-OK HTTP.
|
|
381
|
+
* 2. {@link shapeToolResult} — parse the body and dispatch mutation-envelope
|
|
382
|
+
* vs direct-body vs inline-image vs reload/corruption outcomes.
|
|
383
|
+
* 3. On a transient fetch failure, {@link handlePostFailure} classifies +
|
|
384
|
+
* recovers (re-syncing the endpoint, waiting out a reload, or surfacing
|
|
385
|
+
* the structured offline/dead-bridge error) and re-POSTs only when safe.
|
|
386
|
+
*
|
|
387
|
+
* The retry/compile contract is unchanged from the former monolithic body.
|
|
388
|
+
*/
|
|
365
389
|
async postTool(toolName, args, retryOn503) {
|
|
366
390
|
try {
|
|
367
|
-
const
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
// its timeout envelope. Without this floor, a small/absent timeout_ms
|
|
371
|
-
// (MCP hosts often omit the field → schema default 30000, or smaller)
|
|
372
|
-
// makes the client re-POST while the bridge is still processing →
|
|
373
|
-
// duplicate mutations (specs/feedback.md entry 4B). An explicit large
|
|
374
|
-
// timeout_ms still wins via the Math.max.
|
|
375
|
-
const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
|
|
376
|
-
const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
|
|
377
|
-
method: "POST",
|
|
378
|
-
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
379
|
-
body: JSON.stringify(args),
|
|
380
|
-
}, fetchTimeout);
|
|
381
|
-
if (res.status === 503 && retryOn503) {
|
|
382
|
-
const compileError = await this.waitForCompile();
|
|
383
|
-
if (compileError)
|
|
384
|
-
return compileError;
|
|
391
|
+
const res = await this.postToolFetch(toolName, args, retryOn503);
|
|
392
|
+
// 503 + compile settled cleanly → re-POST once without 503 retry.
|
|
393
|
+
if (isCompileRepostSentinel(res)) {
|
|
385
394
|
return this.postTool(toolName, args, false);
|
|
386
395
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
396
|
+
return res;
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return this.handlePostFailure(toolName, args, err, retryOn503);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Step 1 of {@link postTool}: perform the HTTP POST with the timeout floor,
|
|
404
|
+
* then handle 503-compile-retry and non-OK HTTP. Returns either a shaped
|
|
405
|
+
* {@link CallToolResult} (handed straight back by postTool) or a sentinel
|
|
406
|
+
* `{ _repostAfterCompile: true }` signaling a clean compile settle that
|
|
407
|
+
* should re-POST without 503 retry. Throws on transient fetch failure so the
|
|
408
|
+
* caller's catch runs {@link handlePostFailure}.
|
|
409
|
+
*/
|
|
410
|
+
async postToolFetch(toolName, args, retryOn503) {
|
|
411
|
+
const timeoutMs = typeof args.timeout_ms === "number" ? args.timeout_ms : 60_000;
|
|
412
|
+
// Floor the client fetch timeout at the bridge's own default wait + slack
|
|
413
|
+
// so the client never aborts before the bridge has had a chance to return
|
|
414
|
+
// its timeout envelope. Without this floor, a small/absent timeout_ms
|
|
415
|
+
// (MCP hosts often omit the field → schema default 30000, or smaller)
|
|
416
|
+
// makes the client re-POST while the bridge is still processing →
|
|
417
|
+
// duplicate mutations (specs/feedback.md entry 4B). An explicit large
|
|
418
|
+
// timeout_ms still wins via the Math.max.
|
|
419
|
+
const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
|
|
420
|
+
const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
423
|
+
body: JSON.stringify(args),
|
|
424
|
+
}, fetchTimeout);
|
|
425
|
+
if (res.status === 503 && retryOn503) {
|
|
426
|
+
const compileError = await this.waitForCompile();
|
|
427
|
+
if (compileError)
|
|
428
|
+
return compileError;
|
|
429
|
+
return COMPILE_REPOST_SENTINEL;
|
|
430
|
+
}
|
|
431
|
+
if (!res.ok) {
|
|
432
|
+
const body = (await res
|
|
433
|
+
.json()
|
|
434
|
+
.catch(() => null));
|
|
435
|
+
return makeErrorResult({
|
|
436
|
+
code: "bridge_http_error",
|
|
437
|
+
message: body?.error?.message ?? `Bridge returned HTTP ${res.status}`,
|
|
438
|
+
detail: body ?? {
|
|
439
|
+
error: {
|
|
440
|
+
code: "bridge_http_error",
|
|
441
|
+
message: `HTTP ${res.status}`,
|
|
399
442
|
},
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
// Malformed bridge body — JSON.parse rejected (parsed === null) on what
|
|
472
|
-
// should be a 200 OK application/json response. The bridge's two response
|
|
473
|
-
// shapes (BuildGateEnvelope, BuildDirectToolErrorJson) are always valid
|
|
474
|
-
// JSON, so a parse failure means the response was corrupted in transit or
|
|
475
|
-
// — the case that motivated this guard — a mutating tool's serialized
|
|
476
|
-
// output was interpolated raw into the gate envelope and produced
|
|
477
|
-
// unbalanced JSON (e.g. an execute_csharp snippet whose result threw a
|
|
478
|
-
// TypeLoadException during OutputSerializer's reflective walk).
|
|
479
|
-
//
|
|
480
|
-
// Previously this branch silently degraded to isError:false with an empty
|
|
481
|
-
// `{}` body, manufacturing a fake success. For compile-reload tools,
|
|
482
|
-
// annotateCompileVerify then stamped `_compileVerify` onto that `{}`,
|
|
483
|
-
// producing a malformed envelope `{"_compileVerify":{...}}` with NO
|
|
484
|
-
// `mutation` object — violating the documented contract and confusing
|
|
485
|
-
// agents that key off mutation.success (specs/feedback.md entry
|
|
486
|
-
// 2026-07-03-c). Surface a structured error instead so the failure is
|
|
487
|
-
// visible and annotateCompileVerify (gated on !result.isError) never
|
|
488
|
-
// annotates a body that lost its mutation block.
|
|
489
|
-
//
|
|
490
|
-
// Compile-reload special case (specs/feedback.md 2026-07-05 entry): for
|
|
491
|
-
// tools with the restart_then_settle lifecycle, an EMPTY (or near-empty)
|
|
492
|
-
// body on a 200 OK is the signature of a SUCCESSFUL mutation that
|
|
493
|
-
// triggered a domain reload mid-response — the bridge tore down its HTTP
|
|
494
|
-
// socket for the reload, the in-flight response was lost, and the agent
|
|
495
|
-
// observes a body that is empty or truncated to a few bytes. The
|
|
496
|
-
// mutation almost certainly committed (reimport_package,
|
|
497
|
-
// asmdef_create/modify, package_add/remove, … all commit before the
|
|
498
|
-
// reload). Returning bridge_response_unparsable here would frame a
|
|
499
|
-
// successful operation as corruption. Instead surface a typed
|
|
500
|
-
// triggered_reload outcome that tells the agent to verify post-state
|
|
501
|
-
// rather than suspect a corrupt response.
|
|
502
|
-
//
|
|
503
|
-
// The reload vs. corruption split keys off rawText length: a torn-down
|
|
504
|
-
// socket delivers zero or a handful of bytes (no Content-Length / chunk
|
|
505
|
-
// completing), while an OutputSerializer corruption leaves a substantial
|
|
506
|
-
// half-written envelope. A SUBSTANTIAL unparseable body on a
|
|
507
|
-
// compile-reload tool is STILL treated as corruption (the original
|
|
508
|
-
// 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
|
|
509
|
-
if (parsed == null) {
|
|
510
|
-
const isEmptyLikeReload = rawText.trim().length === 0;
|
|
511
|
-
if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
|
|
512
|
-
return {
|
|
513
|
-
content: [
|
|
514
|
-
{
|
|
515
|
-
type: "text",
|
|
516
|
-
text: JSON.stringify({
|
|
517
|
-
status: "triggered_reload",
|
|
518
|
-
// The bridge returned 200 (the mutation reached the
|
|
519
|
-
// dispatcher and the response was started) but the body was
|
|
520
|
-
// empty — the hallmark of a domain reload tearing down the
|
|
521
|
-
// socket mid-response. The mutation very likely committed;
|
|
522
|
-
// treat this as "verify post-state" rather than "response
|
|
523
|
-
// corrupt, retry".
|
|
524
|
-
triggeredReload: true,
|
|
525
|
-
message: `Bridge returned HTTP 200 for '${toolName}' with an empty ` +
|
|
526
|
-
`response body. '${toolName}' is a compile-reload tool, so ` +
|
|
527
|
-
`this is the expected signature of a mutation that triggered ` +
|
|
528
|
-
`a domain reload mid-response — the bridge tore down its HTTP ` +
|
|
529
|
-
`socket for the reload and the in-flight response was lost ` +
|
|
530
|
-
`before any bytes were written. The mutation very likely ` +
|
|
531
|
-
`committed (compile-reload tools commit before the reload ` +
|
|
532
|
-
`begins). Do NOT treat this as corruption and do NOT retry ` +
|
|
533
|
-
`blindly (that would re-run the mutation). Verify post-state: ` +
|
|
534
|
-
`call unity_open_mcp_editor_status / bridge_status to confirm ` +
|
|
535
|
-
`the reload settled, then unity_open_mcp_read_compile_errors ` +
|
|
536
|
-
`to confirm there are no compile errors.`,
|
|
537
|
-
agentNextSteps: [
|
|
538
|
-
"Poll unity_open_mcp_editor_status / bridge_status until the bridge reports running and not compiling.",
|
|
539
|
-
"Call unity_open_mcp_read_compile_errors to confirm the recompiled assembly has no CSxxxx errors (note: if the response carries staleLogSuspected, force a recompile via reimport_package / compile_check first).",
|
|
540
|
-
"Do NOT retry the original call — the mutation likely committed before the reload. Verify the effect via the appropriate read tool instead.",
|
|
541
|
-
],
|
|
542
|
-
_route: { route: "live", outcome: "triggered_reload" },
|
|
543
|
-
}),
|
|
544
|
-
},
|
|
545
|
-
],
|
|
546
|
-
isError: false,
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
return makeErrorResult({
|
|
550
|
-
code: "bridge_response_unparsable",
|
|
551
|
-
message: `Bridge returned HTTP 200 for '${toolName}' but the response body ` +
|
|
552
|
-
`was not valid JSON. This usually means a mutating tool's serialized ` +
|
|
553
|
-
`output was corrupted (e.g. an execute_csharp snippet whose result ` +
|
|
554
|
-
`threw during serialization). The result is unreliable — do not trust ` +
|
|
555
|
-
`any partial output. Check editor_status / bridge_status, then retry. ` +
|
|
556
|
-
`Endpoint: ${this.baseUrl}.`,
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
// Direct body: return it verbatim. isError when the tool reported a
|
|
560
|
-
// top-level error field (the bridge's direct-response convention).
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return this.shapeToolResult(toolName, res);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Step 2 of {@link postTool}: parse the bridge response body and shape it into
|
|
450
|
+
* a {@link CallToolResult}. Handles the four response outcomes:
|
|
451
|
+
* - inline-image unwrap (capture_inline → MCP image block + metadata),
|
|
452
|
+
* - mutation envelope (derive isError from mutation.success),
|
|
453
|
+
* - compile-reload empty body (triggered_reload — likely-success domain
|
|
454
|
+
* reload tore down the socket mid-response),
|
|
455
|
+
* - direct body (verbatim, isError when a top-level error field is present),
|
|
456
|
+
* - unparsable body (bridge_response_unparsable corruption error).
|
|
457
|
+
*/
|
|
458
|
+
async shapeToolResult(toolName, res) {
|
|
459
|
+
// The bridge returns TWO response shapes from /tools/*:
|
|
460
|
+
// 1. A MUTATION envelope: { mutation: { success, ... }, gate: {...} }
|
|
461
|
+
// for gate-tracked mutating tools (gameobject_*, assets_*, etc.).
|
|
462
|
+
// 2. A DIRECT body: the tool's own JSON (e.g. { status, count, tags })
|
|
463
|
+
// for read-only / state-only tools the bridge classifies as
|
|
464
|
+
// DirectResponseTools (editor_get_tags, asmdef_list, shader_list_all,
|
|
465
|
+
// scene_list_opened, material_get_properties, ~80 total).
|
|
466
|
+
//
|
|
467
|
+
// Previously postTool dispatched off a hardcoded DIRECT_RESPONSE_TOOLS set
|
|
468
|
+
// that was ~60 entries behind the bridge's set. Tools missing from it fell
|
|
469
|
+
// through to deriveIsError(body), which dereferences body.mutation.success
|
|
470
|
+
// — undefined for a direct body → TypeError. That thrown TypeError landed
|
|
471
|
+
// in the connection-recovery catch block, surfacing a misleading
|
|
472
|
+
// bridge_offline (and, with the entry-2026-07-02-b retry change, looping).
|
|
473
|
+
//
|
|
474
|
+
// The fix: detect the envelope shape from the BODY itself. A response with
|
|
475
|
+
// a top-level `mutation` object is a mutation envelope; anything else is a
|
|
476
|
+
// direct body. This is reliable because the bridge's DirectResponseTools
|
|
477
|
+
// path (HandleDirectResponseTool) never emits a `mutation` field and the
|
|
478
|
+
// gate path always does (BuildMutationEnvelope). The DIRECT_RESPONSE_TOOLS
|
|
479
|
+
// set is now only the inlineImage-unwrap allowlist (capture_inline), kept
|
|
480
|
+
// for that one special-case transform, not for shape routing.
|
|
481
|
+
//
|
|
482
|
+
// Capture the raw text first so the unparsable-body branch below can
|
|
483
|
+
// distinguish an EMPTY body (the signature of a compile-reload tool that
|
|
484
|
+
// triggered a domain reload mid-response — the bridge tore down its HTTP
|
|
485
|
+
// socket and the in-flight body was lost) from a SUBSTANTIAL-but-corrupt
|
|
486
|
+
// body (e.g. an execute_csharp snippet whose OutputSerializer output was
|
|
487
|
+
// truncated by a TypeLoadException). The reload case is a likely-success
|
|
488
|
+
// outcome; the corruption case is a real error. See specs/feedback.md
|
|
489
|
+
// 2026-07-05 (reload) + 2026-07-03-c (corruption).
|
|
490
|
+
const rawText = await res.text();
|
|
491
|
+
let parsed = null;
|
|
492
|
+
try {
|
|
493
|
+
parsed = JSON.parse(rawText);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
parsed = null;
|
|
497
|
+
}
|
|
498
|
+
// capture_inline returns an `inlineImage` base64 PNG field. Unwrap it into
|
|
499
|
+
// an MCP image content block alongside a text metadata block
|
|
500
|
+
// ([{type: image}, {type: text}]). The image carries the same viewable
|
|
501
|
+
// payload a file-path screenshot would; the metadata keeps the view /
|
|
502
|
+
// resolution / byteLength fields without the base64 blob.
|
|
503
|
+
const inlineImage = parsed?.inlineImage;
|
|
504
|
+
if (DIRECT_RESPONSE_TOOLS.has(toolName) &&
|
|
505
|
+
parsed != null &&
|
|
506
|
+
typeof inlineImage === "string" &&
|
|
507
|
+
inlineImage.length > 0 &&
|
|
508
|
+
parsed.error == null) {
|
|
509
|
+
const mimeType = typeof parsed.mimeType === "string" ? parsed.mimeType : "image/png";
|
|
510
|
+
const metadata = { ...parsed };
|
|
511
|
+
delete metadata.inlineImage;
|
|
561
512
|
return {
|
|
562
513
|
content: [
|
|
563
|
-
{
|
|
564
|
-
|
|
565
|
-
text: JSON.stringify(parsed),
|
|
566
|
-
},
|
|
514
|
+
{ type: "image", data: inlineImage, mimeType },
|
|
515
|
+
{ type: "text", text: JSON.stringify(metadata) },
|
|
567
516
|
],
|
|
568
|
-
isError:
|
|
517
|
+
isError: false,
|
|
569
518
|
};
|
|
570
519
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
520
|
+
// Mutation envelope vs direct body, decided from the body itself.
|
|
521
|
+
if (parsed != null && typeof parsed.mutation === "object" && parsed.mutation !== null) {
|
|
522
|
+
const body = parsed;
|
|
523
|
+
return {
|
|
524
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
525
|
+
isError: deriveIsError(body),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Malformed bridge body — JSON.parse rejected (parsed === null) on what
|
|
529
|
+
// should be a 200 OK application/json response. The bridge's two response
|
|
530
|
+
// shapes (BuildGateEnvelope, BuildDirectToolErrorJson) are always valid
|
|
531
|
+
// JSON, so a parse failure means the response was corrupted in transit or
|
|
532
|
+
// — the case that motivated this guard — a mutating tool's serialized
|
|
533
|
+
// output was interpolated raw into the gate envelope and produced
|
|
534
|
+
// unbalanced JSON (e.g. an execute_csharp snippet whose result threw a
|
|
535
|
+
// TypeLoadException during OutputSerializer's reflective walk).
|
|
536
|
+
//
|
|
537
|
+
// Previously this branch silently degraded to isError:false with an empty
|
|
538
|
+
// `{}` body, manufacturing a fake success. For compile-reload tools,
|
|
539
|
+
// annotateCompileVerify then stamped `_compileVerify` onto that `{}`,
|
|
540
|
+
// producing a malformed envelope `{"_compileVerify":{...}}` with NO
|
|
541
|
+
// `mutation` object — violating the documented contract and confusing
|
|
542
|
+
// agents that key off mutation.success (specs/feedback.md entry
|
|
543
|
+
// 2026-07-03-c). Surface a structured error instead so the failure is
|
|
544
|
+
// visible and annotateCompileVerify (gated on !result.isError) never
|
|
545
|
+
// annotates a body that lost its mutation block.
|
|
546
|
+
//
|
|
547
|
+
// Compile-reload special case (specs/feedback.md 2026-07-05 entry): for
|
|
548
|
+
// tools with the restart_then_settle lifecycle, an EMPTY (or near-empty)
|
|
549
|
+
// body on a 200 OK is the signature of a SUCCESSFUL mutation that
|
|
550
|
+
// triggered a domain reload mid-response — the bridge tore down its HTTP
|
|
551
|
+
// socket for the reload, the in-flight response was lost, and the agent
|
|
552
|
+
// observes a body that is empty or truncated to a few bytes. The
|
|
553
|
+
// mutation almost certainly committed (reimport_package,
|
|
554
|
+
// asmdef_create/modify, package_add/remove, … all commit before the
|
|
555
|
+
// reload). Returning bridge_response_unparsable here would frame a
|
|
556
|
+
// successful operation as corruption. Instead surface a typed
|
|
557
|
+
// triggered_reload outcome that tells the agent to verify post-state
|
|
558
|
+
// rather than suspect a corrupt response.
|
|
559
|
+
//
|
|
560
|
+
// The reload vs. corruption split keys off rawText length: a torn-down
|
|
561
|
+
// socket delivers zero or a handful of bytes (no Content-Length / chunk
|
|
562
|
+
// completing), while an OutputSerializer corruption leaves a substantial
|
|
563
|
+
// half-written envelope. A SUBSTANTIAL unparseable body on a
|
|
564
|
+
// compile-reload tool is STILL treated as corruption (the original
|
|
565
|
+
// 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
|
|
566
|
+
if (parsed == null) {
|
|
567
|
+
const isEmptyLikeReload = rawText.trim().length === 0;
|
|
568
|
+
if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
|
|
569
|
+
return {
|
|
570
|
+
content: [
|
|
571
|
+
{
|
|
572
|
+
type: "text",
|
|
573
|
+
text: JSON.stringify({
|
|
574
|
+
status: "triggered_reload",
|
|
575
|
+
// The bridge returned 200 (the mutation reached the
|
|
576
|
+
// dispatcher and the response was started) but the body was
|
|
577
|
+
// empty — the hallmark of a domain reload tearing down the
|
|
578
|
+
// socket mid-response. The mutation very likely committed;
|
|
579
|
+
// treat this as "verify post-state" rather than "response
|
|
580
|
+
// corrupt, retry".
|
|
581
|
+
triggeredReload: true,
|
|
582
|
+
message: `Bridge returned HTTP 200 for '${toolName}' with an empty ` +
|
|
583
|
+
`response body. '${toolName}' is a compile-reload tool, so ` +
|
|
584
|
+
`this is the expected signature of a mutation that triggered ` +
|
|
585
|
+
`a domain reload mid-response — the bridge tore down its HTTP ` +
|
|
586
|
+
`socket for the reload and the in-flight response was lost ` +
|
|
587
|
+
`before any bytes were written. The mutation very likely ` +
|
|
588
|
+
`committed (compile-reload tools commit before the reload ` +
|
|
589
|
+
`begins). Do NOT treat this as corruption and do NOT retry ` +
|
|
590
|
+
`blindly (that would re-run the mutation). Verify post-state: ` +
|
|
591
|
+
`call unity_open_mcp_editor_status / bridge_status to confirm ` +
|
|
592
|
+
`the reload settled, then unity_open_mcp_read_compile_errors ` +
|
|
593
|
+
`to confirm there are no compile errors.`,
|
|
594
|
+
agentNextSteps: [
|
|
595
|
+
"Poll unity_open_mcp_editor_status / bridge_status until the bridge reports running and not compiling.",
|
|
596
|
+
"Call unity_open_mcp_read_compile_errors to confirm the recompiled assembly has no CSxxxx errors (note: if the response carries staleLogSuspected, force a recompile via reimport_package / compile_check first).",
|
|
597
|
+
"Do NOT retry the original call — the mutation likely committed before the reload. Verify the effect via the appropriate read tool instead.",
|
|
598
|
+
],
|
|
599
|
+
_route: { route: "live", outcome: "triggered_reload" },
|
|
600
|
+
}),
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
isError: false,
|
|
604
|
+
};
|
|
599
605
|
}
|
|
600
606
|
return makeErrorResult({
|
|
601
|
-
code: "
|
|
602
|
-
message: `
|
|
603
|
-
`
|
|
604
|
-
`
|
|
605
|
-
`
|
|
606
|
-
`
|
|
607
|
-
`
|
|
608
|
-
`complete there; verify the effect before retrying. Endpoint: ${this.baseUrl}.`,
|
|
609
|
-
detail: {
|
|
610
|
-
error: {
|
|
611
|
-
code: "bridge_offline",
|
|
612
|
-
message: `Client timeout against unchanged endpoint ${this.baseUrl}; not retried to avoid duplicate side-effects.`,
|
|
613
|
-
},
|
|
614
|
-
},
|
|
607
|
+
code: "bridge_response_unparsable",
|
|
608
|
+
message: `Bridge returned HTTP 200 for '${toolName}' but the response body ` +
|
|
609
|
+
`was not valid JSON. This usually means a mutating tool's serialized ` +
|
|
610
|
+
`output was corrupted (e.g. an execute_csharp snippet whose result ` +
|
|
611
|
+
`threw during serialization). The result is unreliable — do not trust ` +
|
|
612
|
+
`any partial output. Check editor_status / bridge_status, then retry. ` +
|
|
613
|
+
`Endpoint: ${this.baseUrl}.`,
|
|
615
614
|
});
|
|
616
615
|
}
|
|
616
|
+
// Direct body: return it verbatim. isError when the tool reported a
|
|
617
|
+
// top-level error field (the bridge's direct-response convention).
|
|
618
|
+
return {
|
|
619
|
+
content: [
|
|
620
|
+
{
|
|
621
|
+
type: "text",
|
|
622
|
+
text: JSON.stringify(parsed),
|
|
623
|
+
},
|
|
624
|
+
],
|
|
625
|
+
isError: parsed.error != null,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Step 3 of {@link postTool}: classify + recover from a transient POST
|
|
630
|
+
* failure (ECONNREFUSED / timeout during a domain reload). Re-syncs the cached
|
|
631
|
+
* endpoint from the lock first (so a Unity restart self-heals), waits out a
|
|
632
|
+
* reload, and re-POSTs ONLY when safe (endpoint changed during recovery, or a
|
|
633
|
+
* genuine connection failure where no bytes reached the server).
|
|
634
|
+
*
|
|
635
|
+
* Re-POST discipline (specs/feedback.md entry 4A — duplicate side-effects):
|
|
636
|
+
* when the endpoint is unchanged the same live bridge is still processing —
|
|
637
|
+
* or has queued — the original POST's Work; re-POSTing would run the mutation
|
|
638
|
+
* a second time. In that case surface the timeout instead of retrying.
|
|
639
|
+
*/
|
|
640
|
+
async handlePostFailure(toolName, args, err, retryOn503) {
|
|
641
|
+
const endpointBefore = this.baseUrl;
|
|
642
|
+
const recovered = await this.handleTransientOffline("post");
|
|
643
|
+
if (recovered !== null)
|
|
644
|
+
return recovered;
|
|
645
|
+
const endpointChanged = this.baseUrl !== endpointBefore;
|
|
646
|
+
if (shouldRetryPostAfterFailure(err, endpointChanged)) {
|
|
647
|
+
return this.postTool(toolName, args, retryOn503);
|
|
648
|
+
}
|
|
649
|
+
return makeErrorResult({
|
|
650
|
+
code: "bridge_offline",
|
|
651
|
+
message: `Tool '${toolName}' did not return from the bridge within the client ` +
|
|
652
|
+
`timeout. The bridge endpoint did not change (no restart detected) and ` +
|
|
653
|
+
`the failure was a response timeout (not a connection failure), so ` +
|
|
654
|
+
`the request was NOT retried — re-POSTing would risk running the ` +
|
|
655
|
+
`mutation twice on the same bridge. If the editor was briefly slow ` +
|
|
656
|
+
`(unfocused, GC, a long main-thread op), the original call may still ` +
|
|
657
|
+
`complete there; verify the effect before retrying. Endpoint: ${this.baseUrl}.`,
|
|
658
|
+
detail: {
|
|
659
|
+
error: {
|
|
660
|
+
code: "bridge_offline",
|
|
661
|
+
message: `Client timeout against unchanged endpoint ${this.baseUrl}; not retried to avoid duplicate side-effects.`,
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
});
|
|
617
665
|
}
|
|
618
666
|
async ensureReady() {
|
|
619
667
|
try {
|
|
@@ -684,6 +732,15 @@ export class LiveClient {
|
|
|
684
732
|
catch {
|
|
685
733
|
// Re-probe also failed — fall through to the offline error below.
|
|
686
734
|
}
|
|
735
|
+
// M27 Plan 1 — before declaring a generic bridge_offline, check the
|
|
736
|
+
// cold Safe Mode signature: no lock (bridge never compiled) + a live
|
|
737
|
+
// Unity process for this project. A match surfaces the same
|
|
738
|
+
// bridge_compile_failed shape as the mid-session dead-bridge path so
|
|
739
|
+
// the agent gets the read_compile_errors recovery hint instead of a
|
|
740
|
+
// generic offline error it would have to guess past.
|
|
741
|
+
const coldSafeMode = this.coldSafeModeResult();
|
|
742
|
+
if (coldSafeMode)
|
|
743
|
+
return coldSafeMode;
|
|
687
744
|
return makeErrorResult({
|
|
688
745
|
code: "bridge_offline",
|
|
689
746
|
message: `Bridge is not reachable at ${this.baseUrl}. ${buildOfflineHint(this.projectPath)}`,
|
|
@@ -774,6 +831,15 @@ export class LiveClient {
|
|
|
774
831
|
}
|
|
775
832
|
}
|
|
776
833
|
void operation; // log-only parameter, reserved for future telemetry
|
|
834
|
+
// M27 Plan 1 — before declaring a generic bridge_offline, check the cold
|
|
835
|
+
// Safe Mode signature. handleTransientOffline is the shared recovery
|
|
836
|
+
// exhaustion point for both the ping and POST paths; surfacing
|
|
837
|
+
// bridge_compile_failed here covers tool POSTs that never reached the
|
|
838
|
+
// bridge (no listener, no lock, Unity in Safe Mode) without each caller
|
|
839
|
+
// having to repeat the scan.
|
|
840
|
+
const coldSafeMode = this.coldSafeModeResult();
|
|
841
|
+
if (coldSafeMode)
|
|
842
|
+
return coldSafeMode;
|
|
777
843
|
const retryMsg = `Bridge is not reachable at ${this.baseUrl} after ${maxAttempts} retries. ${buildOfflineHint(this.projectPath)}`;
|
|
778
844
|
return makeErrorResult({
|
|
779
845
|
code: "bridge_offline",
|
|
@@ -822,12 +888,12 @@ export class LiveClient {
|
|
|
822
888
|
return false;
|
|
823
889
|
const portChanged = typeof lock.port === "number" &&
|
|
824
890
|
lock.port > 0 &&
|
|
825
|
-
this.baseUrl !==
|
|
891
|
+
this.baseUrl !== bridgeBaseUrl(lock.port);
|
|
826
892
|
const tokenChanged = lock.authToken !== this.authToken;
|
|
827
893
|
if (!portChanged && !tokenChanged)
|
|
828
894
|
return false;
|
|
829
895
|
if (portChanged)
|
|
830
|
-
this.baseUrl =
|
|
896
|
+
this.baseUrl = bridgeBaseUrl(lock.port);
|
|
831
897
|
if (tokenChanged)
|
|
832
898
|
this.authToken = lock.authToken;
|
|
833
899
|
return true;
|
|
@@ -899,6 +965,72 @@ export class LiveClient {
|
|
|
899
965
|
isError: true,
|
|
900
966
|
};
|
|
901
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* M27 Plan 1 — cold Safe Mode detection. The mirror of
|
|
970
|
+
* {@link deadBridgeResult} for the no-lock case. When Unity launches
|
|
971
|
+
* straight into Safe Mode, the bridge's `[InitializeOnLoad]` never runs, so
|
|
972
|
+
* no instance lock is written and {@link classifyInstance} returns `gone` —
|
|
973
|
+
* the same classification as "Unity is not running." Without a positive
|
|
974
|
+
* signal the agent cannot tell that `read_compile_errors` is the right next
|
|
975
|
+
* step; it gets a generic `bridge_offline` and must guess.
|
|
976
|
+
*
|
|
977
|
+
* This method scans for a live Unity process whose `-projectPath` references
|
|
978
|
+
* this project. A match is a strong positive signal of cold Safe Mode (the
|
|
979
|
+
* only other case that produces no lock + a live Unity process is "bridge
|
|
980
|
+
* toolbar off", which shares the same recovery — check compile errors /
|
|
981
|
+
* enable the bridge). Returns the SAME `bridge_compile_failed` shape as
|
|
982
|
+
* {@link deadBridgeResult} so the agent-facing contract is identical to the
|
|
983
|
+
* mid-session dead-bridge path; returns null when no Unity process matches
|
|
984
|
+
* (genuine offline — the caller falls back to `bridge_offline`).
|
|
985
|
+
*
|
|
986
|
+
* Like {@link deadBridgeResult} this is cheap to call repeatedly: the scan
|
|
987
|
+
* is the only new cost, and it runs only when classification is `gone`
|
|
988
|
+
* (lock absent or PID dead) — never on the happy path or during a reload.
|
|
989
|
+
*/
|
|
990
|
+
coldSafeModeResult() {
|
|
991
|
+
if (!this.projectPath)
|
|
992
|
+
return null;
|
|
993
|
+
let classification;
|
|
994
|
+
try {
|
|
995
|
+
classification = classifyInstance(readInstanceLock(this.projectPath));
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
// Only the no-lock / dead-PID case is a candidate for cold Safe Mode. A
|
|
1001
|
+
// live lock with a stale heartbeat is the mid-session dead-bridge path —
|
|
1002
|
+
// handled by deadBridgeResult, not here.
|
|
1003
|
+
if (classification !== "gone")
|
|
1004
|
+
return null;
|
|
1005
|
+
const proc = findUnityForProject(this.projectPath);
|
|
1006
|
+
if (!proc)
|
|
1007
|
+
return null;
|
|
1008
|
+
return {
|
|
1009
|
+
content: [
|
|
1010
|
+
{
|
|
1011
|
+
type: "text",
|
|
1012
|
+
text: JSON.stringify({
|
|
1013
|
+
error: {
|
|
1014
|
+
code: "bridge_compile_failed",
|
|
1015
|
+
message: "Unity is running but the bridge is unreachable and no " +
|
|
1016
|
+
"instance lock was found — Unity likely launched straight " +
|
|
1017
|
+
"into Safe Mode (the bridge assembly failed to compile from " +
|
|
1018
|
+
"a cold start, so [InitializeOnLoad] never wrote a lock). " +
|
|
1019
|
+
"The HTTP listener will not return until the C# error is " +
|
|
1020
|
+
"fixed. Call unity_open_mcp_read_compile_errors to retrieve " +
|
|
1021
|
+
"the compiler errors from Unity's Editor.log, fix the cited " +
|
|
1022
|
+
"file/line, then trigger a recompile (e.g. a no-op edit + " +
|
|
1023
|
+
"focus Unity, or unity_open_mcp_compile_check once the " +
|
|
1024
|
+
"source compiles). If Unity is NOT in safe mode, the bridge " +
|
|
1025
|
+
"toolbar toggle may be off — open the bridge window (Unity " +
|
|
1026
|
+
"menu: Tools/Unity Open MCP Bridge) and confirm it is started.",
|
|
1027
|
+
},
|
|
1028
|
+
}),
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
isError: true,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
902
1034
|
async waitForCompile() {
|
|
903
1035
|
const compileWaitMs = this.retry.compileWaitMs;
|
|
904
1036
|
const deadline = Date.now() + compileWaitMs;
|