unity-open-mcp 0.9.0 → 1.1.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 (144) hide show
  1. package/dist/asmdef-discovery.d.ts +35 -0
  2. package/dist/asmdef-discovery.js +171 -0
  3. package/dist/asmdef-discovery.js.map +1 -0
  4. package/dist/batch-spawn.d.ts +27 -0
  5. package/dist/batch-spawn.js +218 -24
  6. package/dist/batch-spawn.js.map +1 -1
  7. package/dist/capabilities/build-capabilities.d.ts +10 -0
  8. package/dist/capabilities/build-capabilities.js +4 -0
  9. package/dist/capabilities/build-capabilities.js.map +1 -1
  10. package/dist/capabilities/capabilities-view.d.ts +48 -0
  11. package/dist/capabilities/capabilities-view.js +93 -0
  12. package/dist/capabilities/capabilities-view.js.map +1 -0
  13. package/dist/capabilities/intent-groups.js +42 -0
  14. package/dist/capabilities/intent-groups.js.map +1 -1
  15. package/dist/capabilities/rule-catalog.js +7 -1
  16. package/dist/capabilities/rule-catalog.js.map +1 -1
  17. package/dist/capabilities/tool-groups.js +37 -2
  18. package/dist/capabilities/tool-groups.js.map +1 -1
  19. package/dist/child-supervision.d.ts +34 -0
  20. package/dist/child-supervision.js +110 -0
  21. package/dist/child-supervision.js.map +1 -0
  22. package/dist/cli/cli.js +9 -0
  23. package/dist/cli/cli.js.map +1 -1
  24. package/dist/cli/commands.js +37 -4
  25. package/dist/cli/commands.js.map +1 -1
  26. package/dist/compressible-router.js +12 -1
  27. package/dist/compressible-router.js.map +1 -1
  28. package/dist/constants.d.ts +31 -1
  29. package/dist/constants.js +31 -1
  30. package/dist/constants.js.map +1 -1
  31. package/dist/dialog-dismiss.d.ts +11 -0
  32. package/dist/dialog-dismiss.js +59 -4
  33. package/dist/dialog-dismiss.js.map +1 -1
  34. package/dist/editor-wedge.d.ts +45 -0
  35. package/dist/editor-wedge.js +111 -0
  36. package/dist/editor-wedge.js.map +1 -0
  37. package/dist/event-stream.d.ts +19 -6
  38. package/dist/event-stream.js +139 -17
  39. package/dist/event-stream.js.map +1 -1
  40. package/dist/gate-error.d.ts +2 -0
  41. package/dist/gate-error.js +9 -0
  42. package/dist/gate-error.js.map +1 -1
  43. package/dist/instance-discovery.d.ts +23 -0
  44. package/dist/instance-discovery.js +47 -1
  45. package/dist/instance-discovery.js.map +1 -1
  46. package/dist/live-client.d.ts +40 -5
  47. package/dist/live-client.js +254 -36
  48. package/dist/live-client.js.map +1 -1
  49. package/dist/offline/api.js +82 -30
  50. package/dist/offline/api.js.map +1 -1
  51. package/dist/offline/index-builders.d.ts +9 -0
  52. package/dist/offline/index-builders.js +58 -8
  53. package/dist/offline/index-builders.js.map +1 -1
  54. package/dist/offline/parse.js +5 -1
  55. package/dist/offline/parse.js.map +1 -1
  56. package/dist/process-diagnostics.d.ts +101 -19
  57. package/dist/process-diagnostics.js +197 -59
  58. package/dist/process-diagnostics.js.map +1 -1
  59. package/dist/project-settings.d.ts +36 -0
  60. package/dist/project-settings.js +96 -0
  61. package/dist/project-settings.js.map +1 -0
  62. package/dist/results.d.ts +8 -0
  63. package/dist/results.js +4 -1
  64. package/dist/results.js.map +1 -1
  65. package/dist/routers.js +4 -1
  66. package/dist/routers.js.map +1 -1
  67. package/dist/running-unity.d.ts +23 -0
  68. package/dist/running-unity.js +60 -0
  69. package/dist/running-unity.js.map +1 -1
  70. package/dist/server.js +8 -2
  71. package/dist/server.js.map +1 -1
  72. package/dist/stale-assembly-cache.d.ts +40 -0
  73. package/dist/stale-assembly-cache.js +76 -0
  74. package/dist/stale-assembly-cache.js.map +1 -0
  75. package/dist/tool-hint.d.ts +36 -0
  76. package/dist/tool-hint.js +98 -0
  77. package/dist/tool-hint.js.map +1 -0
  78. package/dist/tool-router.d.ts +47 -2
  79. package/dist/tool-router.js +652 -71
  80. package/dist/tool-router.js.map +1 -1
  81. package/dist/tools/agent-capabilities.js +27 -1
  82. package/dist/tools/agent-capabilities.js.map +1 -1
  83. package/dist/tools/baseline-create.js +1 -1
  84. package/dist/tools/baseline-create.js.map +1 -1
  85. package/dist/tools/batch-execute.js +12 -1
  86. package/dist/tools/batch-execute.js.map +1 -1
  87. package/dist/tools/bridge-status.js +21 -1
  88. package/dist/tools/bridge-status.js.map +1 -1
  89. package/dist/tools/capture-inline.js +4 -3
  90. package/dist/tools/capture-inline.js.map +1 -1
  91. package/dist/tools/editor-status.js +7 -2
  92. package/dist/tools/editor-status.js.map +1 -1
  93. package/dist/tools/execute-csharp.js +36 -3
  94. package/dist/tools/execute-csharp.js.map +1 -1
  95. package/dist/tools/execute-menu.js +33 -2
  96. package/dist/tools/execute-menu.js.map +1 -1
  97. package/dist/tools/index.d.ts +1 -0
  98. package/dist/tools/index.js +44 -0
  99. package/dist/tools/index.js.map +1 -1
  100. package/dist/tools/inputsim-key.d.ts +40 -0
  101. package/dist/tools/inputsim-key.js +72 -0
  102. package/dist/tools/inputsim-key.js.map +1 -0
  103. package/dist/tools/inputsim-pointer.d.ts +40 -0
  104. package/dist/tools/inputsim-pointer.js +121 -0
  105. package/dist/tools/inputsim-pointer.js.map +1 -0
  106. package/dist/tools/inputsim-pointer3d.d.ts +40 -0
  107. package/dist/tools/inputsim-pointer3d.js +55 -0
  108. package/dist/tools/inputsim-pointer3d.js.map +1 -0
  109. package/dist/tools/inputsim-probe.d.ts +40 -0
  110. package/dist/tools/inputsim-probe.js +43 -0
  111. package/dist/tools/inputsim-probe.js.map +1 -0
  112. package/dist/tools/inputsim-step.d.ts +40 -0
  113. package/dist/tools/inputsim-step.js +50 -0
  114. package/dist/tools/inputsim-step.js.map +1 -0
  115. package/dist/tools/inputsim-touch.d.ts +40 -0
  116. package/dist/tools/inputsim-touch.js +83 -0
  117. package/dist/tools/inputsim-touch.js.map +1 -0
  118. package/dist/tools/invoke-method.js +15 -1
  119. package/dist/tools/invoke-method.js.map +1 -1
  120. package/dist/tools/memory-snapshot-capture.js +11 -5
  121. package/dist/tools/memory-snapshot-capture.js.map +1 -1
  122. package/dist/tools/pull-events.js +18 -10
  123. package/dist/tools/pull-events.js.map +1 -1
  124. package/dist/tools/read-compile-errors.js +36 -7
  125. package/dist/tools/read-compile-errors.js.map +1 -1
  126. package/dist/tools/regression-check.js +2 -1
  127. package/dist/tools/regression-check.js.map +1 -1
  128. package/dist/tools/resource-pressure.js +21 -10
  129. package/dist/tools/resource-pressure.js.map +1 -1
  130. package/dist/tools/run-tests.js +11 -2
  131. package/dist/tools/run-tests.js.map +1 -1
  132. package/dist/tools/scan-all.js +2 -2
  133. package/dist/tools/scan-all.js.map +1 -1
  134. package/dist/tools/screenshot-window.js +18 -12
  135. package/dist/tools/screenshot-window.js.map +1 -1
  136. package/dist/tools/screenshot.js +13 -6
  137. package/dist/tools/screenshot.js.map +1 -1
  138. package/dist/tools/visual-compare.d.ts +40 -0
  139. package/dist/tools/visual-compare.js +98 -0
  140. package/dist/tools/visual-compare.js.map +1 -0
  141. package/dist/unity-log.d.ts +67 -2
  142. package/dist/unity-log.js +206 -8
  143. package/dist/unity-log.js.map +1 -1
  144. package/package.json +1 -1
@@ -1,18 +1,22 @@
1
1
  import { AssetModelCache, isCompressible, routeCompressible } from "./compressible-router.js";
2
2
  import { listAssetsOffline, findReferencesOffline, dependenciesOffline } from "./offline.js";
3
- import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly } from "./unity-log.js";
3
+ import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly, detectErrorsPredatingEdits, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
4
+ import { toolHintReference } from "./tool-hint.js";
5
+ import { scanForFdExhaustion } from "./editor-wedge.js";
4
6
  import { summarizeProjectHealth, extractProjectHealthIssues } from "./project-health.js";
7
+ import { countProjectAsmdefs, countAssembliesWithErrors } from "./asmdef-discovery.js";
5
8
  import { buildCapabilities } from "./capabilities/build-capabilities.js";
6
9
  import { RULE_CATALOG, FIX_CATALOG } from "./capabilities/rule-catalog.js";
7
10
  import { GROUP_IDS, TOOL_GROUPS, AUTO_ACTIVATE_GROUPS, groupToTools, getGroup, } from "./capabilities/tool-groups.js";
8
11
  import { recommendGroups, } from "./capabilities/intent-groups.js";
9
12
  import { listRules } from "./capabilities/list-rules.js";
13
+ import { viewCapabilities } from "./capabilities/capabilities-view.js";
10
14
  import { generateSkill } from "./skill/generate-skill.js";
11
15
  import { getKnownClientKeys } from "./skill/client-paths.js";
12
16
  import { ALL_TOOLS } from "./tools/index.js";
13
17
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
14
- import { PORT_ENV_VAR } from "./constants.js";
15
- import { findUnityForProject } from "./running-unity.js";
18
+ import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT } from "./constants.js";
19
+ import { findUnityForProject, readProcessCommandLine } from "./running-unity.js";
16
20
  // M31-optimizations Plan 1 / L14 — opt-in route-logging gate. Resolved once at
17
21
  // module load into a boolean; the per-dispatch log sites read the boolean
18
22
  // (not process.env on every call) so the hot path stays a single branch.
@@ -38,7 +42,8 @@ export function logRoute(message) {
38
42
  // (resource_pressure). Both act on the OS process, not the bridge, so they
39
43
  // share the local route classification with bridge_status.
40
44
  import { killEditorProcess, DEFAULT_KILL_GRACE_MS, MAX_KILL_WAIT_MS, } from "./editor-process-control.js";
41
- import { countFileDescriptors, computeFdHeadroom, analyzeFdTrend, LAUNCH_CONTEXT_CAVEAT, FD_CEILING, } from "./process-diagnostics.js";
45
+ import { countFileDescriptors, computeFdHeadroom, analyzeFdTrend, LAUNCH_CONTEXT_CAVEAT, } from "./process-diagnostics.js";
46
+ import { readFdCeiling } from "./project-settings.js";
42
47
  import { listInstalledEditors, fetchAvailableReleases, openInstallDeepLink, getInstallPath, setInstallPath, } from "./hub-control.js";
43
48
  /** Default Hub-control backend — calls the real hub-control functions. */
44
49
  const defaultHubBackend = {
@@ -49,7 +54,7 @@ const defaultHubBackend = {
49
54
  setInstallPath: (path) => setInstallPath(path),
50
55
  };
51
56
  import { BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS } from "./batch-spawn.js";
52
- import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, } from "./output-profile.js";
57
+ import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, isOutputProfile, } from "./output-profile.js";
53
58
  /**
54
59
  * Single source of truth for the `_source` tag. Every route-tagging site goes
55
60
  * through one of the two helpers below so live / offline / local stay
@@ -337,7 +342,25 @@ function groupLiveRefsByKind(paths) {
337
342
  }
338
343
  // Operator-facing "what to do next" hint for each coarse status. Kept short
339
344
  // and action-oriented so the Validation Suite can render it inline.
340
- function bridgeStatusNextStep(status) {
345
+ function bridgeStatusNextStep(status, wedge) {
346
+ if (status === "wedged") {
347
+ return wedge?.reason === "main_thread_wedged"
348
+ ? "Unity's main thread is wedged — the HTTP listener still answers /ping, but the " +
349
+ "editor heartbeat has stopped, which is the signature of a MODAL DIALOG blocking " +
350
+ "the message pump (material/scene converters and several ExecuteMenuItem paths " +
351
+ "open one). This is NOT a compile failure and NOT a dead bridge assembly — a dead " +
352
+ "assembly has no listener at all. It cannot self-heal and no tool can dismiss the " +
353
+ "dialog: an operator must dismiss it in the Unity UI, after which queued calls " +
354
+ "resume. Prefer non-interactive APIs over unity_open_mcp_execute_menu for " +
355
+ "converter-style menus."
356
+ : "The Unity Editor hit the Bee build driver's file-descriptor exhaustion — the " +
357
+ "process is alive and the listener answers, but it can no longer BUILD: " +
358
+ "Library/ScriptAssemblies stops updating, so C# edits never take effect and " +
359
+ "unity_open_mcp_execute_csharp silently runs the previous assembly. Do not trust " +
360
+ "any result computed since. Call unity_open_mcp_read_compile_errors to confirm " +
361
+ "the editor_fd_exhaustion issue, save scene work, then restart the Editor " +
362
+ "(unity_open_mcp_restart_editor with confirm: true, or manually).";
363
+ }
341
364
  switch (status) {
342
365
  case "running":
343
366
  return "Bridge is ready. Proceed with live-only MCP tools.";
@@ -369,7 +392,66 @@ function bridgeStatusNextStep(status) {
369
392
  "Bridge) and confirm it is started.";
370
393
  }
371
394
  }
372
- function bridgeStatusRecoveryHint(status) {
395
+ /**
396
+ * specs/feedback.md 2026-08-24 — wire-contract staleness block.
397
+ *
398
+ * The field report: a blocker fixed and shipped on 2026-08-17 (registry tools
399
+ * rejecting the transport envelope keys, which made `editor_status` uncallable
400
+ * with default args) recurred against an installed bridge that still reported
401
+ * `bridgeVersion "1.0.0"` — the same string the FIXED source reports, because
402
+ * the package semver does not move for a wire-contract fix. "Your install is
403
+ * stale" and "this regressed" were therefore indistinguishable at the call
404
+ * site, and the agent had no way to pick between reinstalling and filing a bug.
405
+ *
406
+ * `BridgeSession.WireContract` is bumped for every observable change to the
407
+ * request/response contract, so comparing it against
408
+ * {@link EXPECTED_BRIDGE_WIRE_CONTRACT} answers that question directly. A
409
+ * bridge that predates the field reports no `wireContract` at all — `bridge`
410
+ * is then null and `stale` is true, which is the correct reading (it is older
411
+ * than revision 1 by definition).
412
+ *
413
+ * Returns null when /ping was unreachable: there is no bridge to compare, and a
414
+ * bogus "stale" on an offline bridge would be worse than silence.
415
+ */
416
+ function wireContractBlock(reachable, reported) {
417
+ if (!reachable)
418
+ return null;
419
+ const bridge = typeof reported === "number" ? reported : null;
420
+ const stale = bridge === null || bridge < EXPECTED_BRIDGE_WIRE_CONTRACT;
421
+ return {
422
+ bridge,
423
+ expected: EXPECTED_BRIDGE_WIRE_CONTRACT,
424
+ stale,
425
+ note: stale
426
+ ? "The installed Unity bridge package is OLDER than this MCP server's wire " +
427
+ "contract" +
428
+ (bridge === null
429
+ ? " (it does not report `wireContract` at all, so it predates revision 1)"
430
+ : ` (revision ${bridge} vs ${EXPECTED_BRIDGE_WIRE_CONTRACT})`) +
431
+ ". A tool failing in a way the docs say is fixed is almost certainly " +
432
+ "this staleness, not a regression: reinstall/update the bridge package " +
433
+ "in Unity (Package Manager > the unity-open-mcp-bridge git URL) and " +
434
+ "retry before filing anything. bridgeVersion alone cannot tell you " +
435
+ "this — it does not move for a wire-contract fix."
436
+ : null,
437
+ };
438
+ }
439
+ function bridgeStatusRecoveryHint(status, wedge) {
440
+ if (status === "wedged") {
441
+ return wedge?.reason === "main_thread_wedged"
442
+ ? {
443
+ tool: "unity_open_mcp_editor_status",
444
+ reason: "Main thread wedged (likely a modal dialog): /ping answers but the " +
445
+ "editor heartbeat stopped. No tool can dismiss a modal — an operator " +
446
+ "must close it in the Unity UI, then editor_status confirms recovery.",
447
+ }
448
+ : {
449
+ tool: "unity_open_mcp_read_compile_errors",
450
+ reason: "editor_fd_exhaustion in the freshest Editor.log — the Editor answers " +
451
+ "HTTP but can no longer build, so C# edits never take effect. Confirm " +
452
+ "with read_compile_errors, then restart the Editor.",
453
+ };
454
+ }
373
455
  if (status === "dead_bridge") {
374
456
  return {
375
457
  tool: "unity_open_mcp_read_compile_errors",
@@ -388,6 +470,7 @@ export class ToolRouter {
388
470
  sessionState;
389
471
  onToolListChanged;
390
472
  hubBackend;
473
+ globalLogPathOverride;
391
474
  modelCache = new AssetModelCache();
392
475
  /**
393
476
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -444,7 +527,15 @@ export class ToolRouter {
444
527
  // M26 Plan 2 — injectable Hub-control backend. Omit in production (the
445
528
  // default calls the real hub-control functions); tests pass a fake to
446
529
  // avoid real subprocess + network side effects.
447
- hubBackend = defaultHubBackend) {
530
+ hubBackend = defaultHubBackend,
531
+ // feedback-fable-04-08 §2b — injectable global Editor.log path for tests.
532
+ // Production leaves this undefined so resolveEditorLogPath computes the
533
+ // real platform global path; tests pass a path under their tmp sandbox so
534
+ // the freshness comparison does not consult the host machine's real
535
+ // ~/Library/Logs/Unity/Editor.log (which is newer than the artificial
536
+ // 1970-era mtimes the tests plant and would win the comparison, reading
537
+ // the wrong file).
538
+ globalLogPathOverride) {
448
539
  this.live = live;
449
540
  this.batch = batch;
450
541
  this.projectPath = projectPath;
@@ -452,6 +543,7 @@ export class ToolRouter {
452
543
  this.sessionState = sessionState;
453
544
  this.onToolListChanged = onToolListChanged;
454
545
  this.hubBackend = hubBackend;
546
+ this.globalLogPathOverride = globalLogPathOverride;
455
547
  }
456
548
  async route(toolName, args) {
457
549
  return this.routeCore(this.live, toolName, args);
@@ -478,10 +570,21 @@ export class ToolRouter {
478
570
  }
479
571
  // Compact drill-down reads: offline-first for text-serialized assets, fall
480
572
  // back to live bridge for binary formats. The compressible-router handles
481
- // the source selection internally.
573
+ // the source selection internally and tags the payload `_source`
574
+ // ("offline" when it was served from the disk parser without touching the
575
+ // bridge; error results tag their origin too — "local" for caller-side
576
+ // validation, "offline" for a failed offline-first attempt with the
577
+ // bridge down). Derive `_route` from that tag: stamping route:"live"
578
+ // unconditionally mislabeled offline-served reads as bridge responses —
579
+ // contradictory metadata that pointed an agent's diagnostics at the
580
+ // bridge for data it never produced.
482
581
  if (isCompressible(toolName)) {
483
582
  const result = await routeCompressible(toolName, args, live, this.modelCache, this.projectPath);
484
- return injectRouteMeta(result, { route: "live" });
583
+ const source = parseResultBody(result)?._source;
584
+ const meta = source === "offline" || source === "local"
585
+ ? { route: source }
586
+ : { route: "live" };
587
+ return injectRouteMeta(result, meta);
485
588
  }
486
589
  const canBatch = this.batch.isBatchTool(toolName);
487
590
  const isPing = toolName === "unity_open_mcp_ping";
@@ -528,10 +631,13 @@ export class ToolRouter {
528
631
  return this.batchPingResult();
529
632
  }
530
633
  const result = await this.batch.route(toolName, args);
531
- return injectRouteMeta(result, {
634
+ // feedback-fable-04-08 §7 — when the batch fallback hit a live editor's
635
+ // project lock, the actionable signal is editor_busy, not live_unavailable.
636
+ const meta = this.rewriteEditorBusy(result, {
532
637
  route: "batch",
533
638
  fallbackReason: "live_unavailable",
534
639
  });
640
+ return injectRouteMeta(result, meta);
535
641
  }
536
642
  batchPingResult() {
537
643
  return {
@@ -546,6 +652,11 @@ export class ToolRouter {
546
652
  mode: "batch",
547
653
  compiling: false,
548
654
  isPlaying: false,
655
+ // feedback 2026-08-17 — provenance marker distinguishing this
656
+ // synthesized fallback (no probe was possible; the live route was
657
+ // unavailable) from the live route's verified probe answer
658
+ // (source:"probe" + asOf).
659
+ source: "unprobed",
549
660
  _route: {
550
661
  route: "batch",
551
662
  fallbackReason: "live_unavailable",
@@ -556,6 +667,52 @@ export class ToolRouter {
556
667
  isError: false,
557
668
  };
558
669
  }
670
+ /**
671
+ * feedback-fable-04-08 §7 — when a `live_unavailable` batch fallback lands
672
+ * on `editor_instance_locked`, the message describes the BATCH SPAWNER's
673
+ * problem ("a live editor holds the project lock, so no headless spawn"),
674
+ * but the agent's actionable truth is "the editor is busy (TestRunner run /
675
+ * long import); retry later", not a lock conflict. Detect this case by
676
+ * inspecting the batch result for `error.code === "editor_instance_locked"`
677
+ * AND confirming a live PID still holds the project (a stale lock with no
678
+ * live PID is a genuine lock conflict — leave it as `live_unavailable`).
679
+ *
680
+ * Returns the rewritten meta (and the result unchanged — the lock error body
681
+ * is real and must stay intact) so the caller stamps it via injectRouteMeta.
682
+ * The live PID is surfaced as `editorPid` for agent correlation. Falls
683
+ * through unchanged when the code differs, no live PID exists, or the body
684
+ * is unparseable.
685
+ */
686
+ rewriteEditorBusy(result, meta) {
687
+ if (meta.fallbackReason !== "live_unavailable")
688
+ return meta;
689
+ const body = parseResultBody(result);
690
+ if (body === null)
691
+ return meta;
692
+ const err = body.error;
693
+ const code = typeof err === "object" && err !== null
694
+ ? err.code
695
+ : undefined;
696
+ if (code !== "editor_instance_locked")
697
+ return meta;
698
+ // Confirm a live editor actually holds the project. The batch pre-spawn
699
+ // lock check already read the lock, but a race (the editor quit between
700
+ // the batch check and here) or a corrupt lock must NOT be misreported as
701
+ // "busy" — fall through to the generic live_unavailable in that case.
702
+ try {
703
+ const lock = readInstanceLock(this.projectPath);
704
+ if (!lock?.pid || !isPidAlive(lock.pid))
705
+ return meta;
706
+ return {
707
+ route: "batch",
708
+ fallbackReason: "editor_busy",
709
+ editorPid: lock.pid,
710
+ };
711
+ }
712
+ catch {
713
+ return meta;
714
+ }
715
+ }
559
716
  async routeReadCompileErrors(args) {
560
717
  const tailBytes = typeof args.tail_bytes === "number" && args.tail_bytes >= 4096
561
718
  ? Math.min(Math.floor(args.tail_bytes), 1048576)
@@ -571,7 +728,7 @@ export class ToolRouter {
571
728
  // editor keeps writing. Pass the live PID from the instance lock so the
572
729
  // resolver can make the strong-signal decision.
573
730
  const livePid = this.resolveLiveEditorPid();
574
- const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid);
731
+ const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid, this.globalLogPathOverride);
575
732
  const logPath = resolvedLog.path;
576
733
  const tail = readLogTail(logPath, tailBytes);
577
734
  if (tail.error) {
@@ -617,11 +774,6 @@ export class ToolRouter {
617
774
  // backward compatibility) so existing callers do not break.
618
775
  const health = summarizeProjectHealth(tail.content);
619
776
  const errors = health.compilerErrors;
620
- const status = health.unhealthy
621
- ? errors.length > 0
622
- ? "compile_failed"
623
- : "project_unhealthy"
624
- : "no_errors_found";
625
777
  // specs/feedback.md 2026-07-05 — stale-log detection. When an assembly is
626
778
  // stuck in a failed-compile state, AssetDatabase.Refresh no-ops and the
627
779
  // log's most-recent CSxxxx block can reference on-disk source that has
@@ -644,12 +796,156 @@ export class ToolRouter {
644
796
  // newest Library/ScriptAssemblies/*.dll mtime against Assets/**/*.cs source
645
797
  // mtimes and surface staleAssembly when any source is newer than the DLL.
646
798
  const staleAsm = detectStaleAssembly(this.projectPath);
799
+ // specs/feedback.md 2026-08-14 — "these errors may predate your edits".
800
+ // Three consecutive reads returned the same error block for code that had
801
+ // already been fixed, with no staleness signal: Editor.log's FILE mtime
802
+ // was fresh (asset imports keep appending) while the error BLOCK inside it
803
+ // was old, so the staleLog heuristic could not fire. The built-assembly
804
+ // mtime is the anchor that works — a cited source newer than the newest
805
+ // built DLL proves no compile has COMPLETED since the edit.
806
+ const errorsPredateEdits = errors.length > 0
807
+ ? detectErrorsPredatingEdits(errors.map((e) => e.file), this.projectPath, staleAsm.dllMtimeMs)
808
+ : null;
809
+ // feedback-04-08-opus §2 — log-authorship cross-check. A batch run of a
810
+ // NEWER Unity writes a log whose errors (e.g. an API deprecation that is
811
+ // only an error on that version) CANNOT occur in the live editor. Parse the
812
+ // log header's version + batch-mode + date, compare against the live bridge
813
+ // version, and surface a mismatch so the agent does not "fix" 31 unrelated
814
+ // files or refuse to continue on errors that cannot occur here. The fields
815
+ // are always emitted when the header was parseable (so an agent can see the
816
+ // log was a batch run even when versions agree); the warning hint is added
817
+ // only on a real mismatch or a batch-mode log.
818
+ const authorship = parseLogAuthorship(tail.content);
819
+ const liveUnityVersion = this.resolveLiveUnityVersion();
820
+ const authorshipCheck = compareLogAuthorship(authorship, liveUnityVersion);
821
+ // feedback-fable-04-08 §4 — downgrade the top-level status when the errors
822
+ // may not apply to the running editor. An agent that trusts a
823
+ // `compile_failed` + `unhealthy: true` headline will start "fixing" 31
824
+ // phantom errors from a stale / batch-authored log. When EITHER the log is
825
+ // stale (a cited source is newer than the log) OR a version-mismatch is
826
+ // detected (a different Unity wrote the log), the verdict becomes
827
+ // `stale_log` — the errors[] are still attached as evidence, but the
828
+ // headline + status make clear they MUST NOT be acted on until a genuine
829
+ // recompile confirms them. The raw verdict (what the log itself says) is
830
+ // preserved in `logVerdict` for agents that want the literal signal.
831
+ const logVerdict = health.unhealthy
832
+ ? errors.length > 0
833
+ ? "compile_failed"
834
+ : "project_unhealthy"
835
+ : "no_errors_found";
836
+ const errorsMayNotApply = health.unhealthy &&
837
+ (!!staleLog || authorshipCheck.versionMismatch);
838
+ // feedback 2026-08-17 — a clean bill of health read from the ROTATED log
839
+ // is not a clean bill of health. When logSource is a prev_log_* value the
840
+ // resolver fell back to Editor-prev.log while a live Editor writes a
841
+ // different file; an agent reading only `status` got a false green. A
842
+ // no-errors read from a rotated log is explicitly `indeterminate`.
843
+ const prevLogRead = resolvedLog.reason.startsWith("prev_log_");
844
+ const status = errorsMayNotApply
845
+ ? "stale_log"
846
+ : logVerdict === "no_errors_found" && prevLogRead
847
+ ? "indeterminate"
848
+ : logVerdict;
849
+ // specs/feedback.md 2026-08-12 — partial-compile signal. Unity compiles
850
+ // assemblies in dependency order; when an early assembly fails the pipeline
851
+ // may not reach dependent ones, so the reported errors can be a SUBSET of
852
+ // the true error set. When there are errors AND the project has multiple
853
+ // compile assemblies, surface asmdefCount + assembliesWithErrors +
854
+ // partialCompileLikely so an agent does not treat `errorCount` as final
855
+ // (this is exactly the trap that masked 6 errors behind 1 during the
856
+ // input-simulation review).
857
+ // Only walk for asmdefs when there are errors — the signal is only
858
+ // meaningful with errors, and countProjectAsmdefs stats the whole Assets/
859
+ // tree synchronously (memoized per project, but still skip it when unused).
860
+ const hasErrors = errors.length > 0;
861
+ const asmdefs = hasErrors
862
+ ? countProjectAsmdefs(this.projectPath)
863
+ : { count: 0, sample: [] };
864
+ const partialCompileLikely = hasErrors && asmdefs.count > 1;
865
+ const assembliesWithErrors = partialCompileLikely
866
+ ? countAssembliesWithErrors(errors.map((e) => e.file), this.projectPath)
867
+ : 0;
868
+ // Headline assembly. Precedence (most severe first):
869
+ // 1. errorsMayNotApply — stale log / version mismatch: the errors may
870
+ // not apply at all (feedback-fable-04-08 §4).
871
+ // 2. errorsPredateEdits — errors + a stale assembly: the error block
872
+ // was written by a compile that predates the on-disk edits (no
873
+ // compile has COMPLETED since: every built DLL is older than a cited
874
+ // source). The log-mtime staleness heuristic cannot see this — asset
875
+ // imports keep appending to Editor.log, so the FILE is fresh while
876
+ // the error BLOCK inside it is old. Say so in the headline rather
877
+ // than letting the agent re-read code it has already fixed
878
+ // (specs/feedback.md 2026-08-14).
879
+ // 3. feedback 2026-08-17 — errors + staleAssembly WITHOUT the
880
+ // cited-files proof: the whole-Assets scan still shows a source
881
+ // newer than the newest built DLL, so the error block may come from
882
+ // an earlier compile. The 2026-08-14 fix covered only the
883
+ // errorsPredateEdits shape; this extends the headline caveat to the
884
+ // staleAssembly shape (the field report's 12 stale CS errors had
885
+ // staleAssembly:true but the headline stated them as fact).
886
+ // 4. indeterminate (feedback 2026-08-17) — no errors, but read from the
887
+ // ROTATED log: not a verified clean bill of health.
888
+ // 5. the plain health headline.
889
+ const headline = errorsMayNotApply
890
+ ? `${errors.length} error(s) in Editor.log, but the log appears ` +
891
+ "STALE or authored by a different Unity — these errors may NOT " +
892
+ "apply to the running editor. Do NOT act on them until a genuine " +
893
+ `recompile (${toolHintReference("unity_open_mcp_recompile_scripts")}) ` +
894
+ "confirms them."
895
+ : errorsPredateEdits
896
+ ? `${health.headline} NOTE: these errors may PREDATE your edits — ` +
897
+ `${errorsPredateEdits.files.length} cited source file(s) are newer ` +
898
+ "than the newest built assembly, so no compile has completed since " +
899
+ "you changed them. Force a recompile " +
900
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) and ` +
901
+ "re-read before acting on them."
902
+ : hasErrors && staleAsm.staleAssembly
903
+ ? `${health.headline} NOTE: the error block may PREDATE the current ` +
904
+ `sources — ${staleAsm.newerSources.length} Assets/**/*.cs source(s) ` +
905
+ "are newer than the newest built assembly, so these errors may come " +
906
+ "from an earlier compile. Force a recompile " +
907
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) and ` +
908
+ "re-read before acting on them."
909
+ : status === "indeterminate"
910
+ ? "No errors found, but this is NOT a verified clean bill of " +
911
+ `health: the read came from the ROTATED log (${resolvedLog.reason === "prev_log_live_editor" ? "Editor-prev.log chosen because a live editor holds the project" : "Editor-prev.log fallback"}) ` +
912
+ "while a live Editor may be writing a different log. Confirm via " +
913
+ "the live bridge (unity_open_mcp_editor_status / a live tool " +
914
+ "probe) or re-read after the Editor settles."
915
+ : health.headline;
647
916
  return sourceResult({
648
917
  status,
649
- unhealthy: health.unhealthy,
650
- headline: health.headline,
918
+ // When the errors may not apply (stale log / version mismatch), the
919
+ // top-level verdict is downgraded to "stale_log" and unhealthy is
920
+ // suppressed so an agent branching on `unhealthy` does not treat
921
+ // phantom errors as a hard failure (feedback-fable-04-08 §4). The raw
922
+ // log verdict is preserved in `logVerdict` for callers that want the
923
+ // literal signal regardless of freshness.
924
+ unhealthy: errorsMayNotApply ? false : health.unhealthy,
925
+ headline,
926
+ // Raw verdict preserved whenever the top-level status was rewritten
927
+ // (stale_log downgrade OR the indeterminate prev-log case), so callers
928
+ // can still read what the log literally said.
929
+ ...(errorsMayNotApply || status === "indeterminate" ? { logVerdict } : {}),
651
930
  errorCount: errors.length,
652
931
  errors,
932
+ // specs/feedback.md 2026-08-12 — present only when there are errors AND
933
+ // the project has multiple compile assemblies. Unity's dependency-ordered
934
+ // pipeline means these errors may be a subset; fixing them lets dependent
935
+ // assemblies compile and may surface more. Re-check until errorCount is 0.
936
+ ...(partialCompileLikely
937
+ ? {
938
+ partialCompileLikely: true,
939
+ asmdefCount: asmdefs.count,
940
+ // Distinct assemblies the reported errors span (null when none of
941
+ // the cited files mapped to an asmdef — e.g. global/package code).
942
+ assembliesWithErrors: assembliesWithErrors > 0 ? assembliesWithErrors : null,
943
+ partialCompileHint: "Unity compiles assemblies in dependency order; the reported errors " +
944
+ "may be a SUBSET — once these are fixed, dependent assemblies that " +
945
+ "were not reached compile next and may surface additional errors. " +
946
+ "Re-check read_compile_errors after each fix until errorCount is 0.",
947
+ }
948
+ : {}),
653
949
  // Package / assembly issues from the same log tail. Empty when the
654
950
  // only red flags are compiler errors (the common case).
655
951
  issues: health.issues,
@@ -681,6 +977,45 @@ export class ToolRouter {
681
977
  staleAssemblyNewerSources: staleAsm.newerSources,
682
978
  }
683
979
  : {}),
980
+ // specs/feedback.md 2026-08-14 — machine-readable sibling of the
981
+ // headline caveat above. Present ONLY when errors were returned AND at
982
+ // least one file they cite is newer than the newest built assembly, so
983
+ // the error block cannot reflect the current source. Distinct from
984
+ // `staleLogSuspected` (log-file mtime) and `staleAssembly` (whole
985
+ // Assets/ scan): this names the cited files the agent is about to
986
+ // re-read.
987
+ ...(errorsPredateEdits
988
+ ? {
989
+ errorsMayPredateEdits: true,
990
+ errorsMayPredateEditsFiles: errorsPredateEdits.files,
991
+ errorsMayPredateEditsHint: "These errors were parsed from a compile that ran BEFORE the " +
992
+ "listed files were last edited (each is newer than the newest " +
993
+ "Library/ScriptAssemblies/*.dll, so no compile has completed " +
994
+ "since). Do not re-read or re-fix that code on the strength of " +
995
+ "this block — force a recompile " +
996
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) ` +
997
+ "and read again.",
998
+ }
999
+ : {}),
1000
+ // feedback-04-08-opus §2 — log authorship. Emitted whenever the header
1001
+ // was parseable (the live editor's long tail usually has no header, so
1002
+ // these stay absent in the common case). On a version mismatch the hint
1003
+ // says the errors CANNOT all occur in the live editor; on a batch-mode
1004
+ // log it reminds the agent the errors are from a headless run.
1005
+ ...(authorship.logUnityVersion !== null || authorship.logBatchMode !== null
1006
+ ? {
1007
+ logUnityVersion: authorship.logUnityVersion,
1008
+ liveUnityVersion: authorshipCheck.liveUnityVersion,
1009
+ logBatchMode: authorship.logBatchMode,
1010
+ logDate: authorship.logDate,
1011
+ ...(authorshipCheck.hint
1012
+ ? {
1013
+ logAuthorshipMismatch: authorshipCheck.versionMismatch,
1014
+ logAuthorshipHint: authorshipCheck.hint,
1015
+ }
1016
+ : {}),
1017
+ }
1018
+ : {}),
684
1019
  }, "offline");
685
1020
  }
686
1021
  // Resolve the live Unity Editor PID for this project from the instance lock,
@@ -702,6 +1037,27 @@ export class ToolRouter {
702
1037
  }
703
1038
  return undefined;
704
1039
  }
1040
+ // feedback-04-08-opus §2 — resolve the live bridge's Unity version from the
1041
+ // instance lock, so read_compile_errors can cross-check the LOG's version
1042
+ // header against the version of the editor that is actually running. A batch
1043
+ // run of a NEWER Unity leaves a log whose errors cannot occur in the live
1044
+ // editor; without this check the tool would surface them as current. Returns
1045
+ // null when no live editor / no version recorded (the benign case — no
1046
+ // version to compare against, so no false mismatch fires).
1047
+ resolveLiveUnityVersion() {
1048
+ if (!this.projectPath)
1049
+ return null;
1050
+ try {
1051
+ const lock = readInstanceLock(this.projectPath);
1052
+ if (lock && lock.pid && isPidAlive(lock.pid) && lock.unityVersion) {
1053
+ return lock.unityVersion;
1054
+ }
1055
+ }
1056
+ catch {
1057
+ // Unreadable/missing lock → no live version known.
1058
+ }
1059
+ return null;
1060
+ }
705
1061
  async routeListAssets(args) {
706
1062
  try {
707
1063
  const result = await listAssetsOffline({
@@ -753,7 +1109,13 @@ export class ToolRouter {
753
1109
  const maxEvents = typeof args.max_events === "number" && args.max_events > 0
754
1110
  ? Math.min(args.max_events, 1000)
755
1111
  : 50;
756
- const result = this.eventStream.pull(maxEvents);
1112
+ // feedback #8 — forward the caller-supplied subscriber id so each agent
1113
+ // session gets its own read cursor (a fresh subscriber starts at the
1114
+ // current high-water mark and does not replay the shared queue's backlog).
1115
+ const subscriber = typeof args.subscriber === "string" && args.subscriber.length > 0
1116
+ ? args.subscriber
1117
+ : undefined;
1118
+ const result = this.eventStream.pull(maxEvents, subscriber);
757
1119
  return {
758
1120
  content: [{ type: "text", text: JSON.stringify(result) }],
759
1121
  isError: false,
@@ -764,6 +1126,12 @@ export class ToolRouter {
764
1126
  ? args.kind
765
1127
  : undefined;
766
1128
  const includePlanned = args.include_planned !== false;
1129
+ // specs/feedback.md 2026-08-24 — the tool surface defaults to `compact`
1130
+ // (the unfolded catalog is ~513 KB, which overflows a typical harness
1131
+ // per-result ceiling). `buildCapabilities` itself keeps returning the full
1132
+ // shape so its other caller (generate_skill) is unaffected; the fold is a
1133
+ // view applied here.
1134
+ const profile = isOutputProfile(args.profile) ? args.profile : "compact";
767
1135
  // M18 Plan 2 / T18.2.3 — when the bridge is live, probe its compiled-
768
1136
  // state tool inventory so per-group `available` reflects whether each
769
1137
  // domain dependency compiled in (e.g. UNITY_OPEN_MCP_EXT_NAVIGATION).
@@ -789,7 +1157,11 @@ export class ToolRouter {
789
1157
  fixes: FIX_CATALOG,
790
1158
  availableBridgeTools,
791
1159
  }, { kind, includePlanned });
792
- return sourceResult(result, "local");
1160
+ return sourceResult(viewCapabilities(result, {
1161
+ profile,
1162
+ page_size: typeof args.page_size === "number" ? args.page_size : undefined,
1163
+ cursor: typeof args.cursor === "string" ? args.cursor : undefined,
1164
+ }), "local");
793
1165
  }
794
1166
  async routeListRules(args) {
795
1167
  const assetKind = typeof args.asset_kind === "string" ? args.asset_kind : undefined;
@@ -1293,8 +1665,52 @@ export class ToolRouter {
1293
1665
  if (proc)
1294
1666
  unityProcessPid = proc.pid;
1295
1667
  }
1668
+ // specs/feedback.md 2026-08-14 — wedge detection, evaluated BEFORE the
1669
+ // existing token ladder because both wedges masquerade as one of its
1670
+ // healthy-looking outcomes.
1671
+ //
1672
+ // (a) main_thread_wedged. `dead_bridge` means "live PID + stale
1673
+ // heartbeat". A modal dialog produces exactly that (the heartbeat is
1674
+ // written from EditorApplication.update, which a blocked pump stops
1675
+ // running) — but a dead bridge ASSEMBLY cannot answer /ping at all,
1676
+ // because the listener lives in the assembly that failed to compile.
1677
+ // So a reachable /ping alongside a stale heartbeat rules out the
1678
+ // safe-mode diagnosis and identifies a stalled main thread instead.
1679
+ // (b) editor_fd_exhaustion. Process, heartbeat and /ping all survive a
1680
+ // dead build driver, so nothing in the existing ladder can see it.
1681
+ // One regex over the freshest log tail can (the same scan
1682
+ // read_compile_errors runs), and it only runs when the ladder would
1683
+ // otherwise report a healthy editor — the offline/stopped paths have
1684
+ // no editor to be wedged.
1685
+ let wedge = null;
1686
+ if (classification === "dead_bridge" && pingReachable) {
1687
+ wedge = {
1688
+ reason: "main_thread_wedged",
1689
+ detail: "The instance lock's heartbeat is stale (the editor main thread stopped " +
1690
+ "ticking) but the bridge listener still answers /ping. A failed bridge " +
1691
+ "assembly would take the listener down with it, so this is a blocked main " +
1692
+ "thread — almost always a modal dialog — not Safe Mode.",
1693
+ };
1694
+ }
1695
+ else if (pingReachable && connected) {
1696
+ const fd = scanForFdExhaustion(this.projectPath, this.resolveLiveEditorPid(), this.globalLogPathOverride);
1697
+ if (fd.present) {
1698
+ wedge = {
1699
+ reason: "editor_fd_exhaustion",
1700
+ detail: "The freshest Editor.log carries the Bee build-driver file-descriptor " +
1701
+ "exhaustion signature. The Editor answers HTTP but cannot build: " +
1702
+ "Library/ScriptAssemblies stops updating, so C# edits never take effect " +
1703
+ "and execute_csharp keeps running the previous assembly.",
1704
+ ...(fd.logPath ? { logPath: fd.logPath } : {}),
1705
+ ...(fd.raw ? { raw: fd.raw } : {}),
1706
+ };
1707
+ }
1708
+ }
1296
1709
  let status;
1297
- if (classification === "dead_bridge") {
1710
+ if (wedge !== null) {
1711
+ status = "wedged";
1712
+ }
1713
+ else if (classification === "dead_bridge") {
1298
1714
  status = "dead_bridge";
1299
1715
  }
1300
1716
  else if (compiling) {
@@ -1336,7 +1752,13 @@ export class ToolRouter {
1336
1752
  // Safe Mode / compile failure", not the generic "stopped" the bare
1337
1753
  // status used to suggest.
1338
1754
  classification,
1339
- recoveryHint: bridgeStatusRecoveryHint(status),
1755
+ recoveryHint: bridgeStatusRecoveryHint(status, wedge),
1756
+ // specs/feedback.md 2026-08-14 — present ONLY when the editor is wedged.
1757
+ // `classification` keeps mirroring the instance lock verbatim (healthy |
1758
+ // reloading | dead_bridge | gone) so that contract is unchanged; the
1759
+ // wedge is carried by the tool's own `status` token plus this block, and
1760
+ // `ready` is false either way.
1761
+ ...(wedge !== null ? { wedged: wedge } : {}),
1340
1762
  instance: {
1341
1763
  lockPath: lockOnDisk,
1342
1764
  classification,
@@ -1354,10 +1776,15 @@ export class ToolRouter {
1354
1776
  isPlaying: pingBody?.isPlaying ?? null,
1355
1777
  unityVersion: pingBody?.unityVersion ?? null,
1356
1778
  bridgeVersion: pingBody?.bridgeVersion ?? null,
1779
+ wireContract: pingBody?.wireContract ?? null,
1357
1780
  mode: pingBody?.mode ?? null,
1358
1781
  }
1359
1782
  : { reachable: false },
1360
- nextStep: bridgeStatusNextStep(status),
1783
+ // specs/feedback.md 2026-08-24 — null when /ping was unreachable; a
1784
+ // `stale: true` block is the signal to reinstall the Unity package
1785
+ // rather than file a regression.
1786
+ wireContract: wireContractBlock(pingReachable, pingBody?.wireContract),
1787
+ nextStep: bridgeStatusNextStep(status, wedge),
1361
1788
  };
1362
1789
  // bridge_status never reports an error — even a stopped bridge is a
1363
1790
  // successful status read. _source=local because the synthesis happens in
@@ -1405,12 +1832,14 @@ export class ToolRouter {
1405
1832
  // fixable compile failure. We reuse the same offline log read +
1406
1833
  // health-issue extractor as read_compile_errors so the diagnosis is
1407
1834
  // identical to what the agent already saw.
1408
- const logPath = resolveEditorLogPath(this.projectPath, undefined, this.resolveLiveEditorPid()).path;
1835
+ const logPath = resolveEditorLogPath(this.projectPath, undefined, this.resolveLiveEditorPid(), this.globalLogPathOverride).path;
1409
1836
  const tail = readLogTail(logPath, DEFAULT_LOG_TAIL_BYTES);
1410
1837
  let signaturePresent = false;
1838
+ let signatureSource = null;
1411
1839
  let logWarning = null;
1412
1840
  if (tail.error || !tail.exists) {
1413
- // Without the log we cannot confirm the signature — refuse for safety.
1841
+ // Without the log we cannot confirm the signature from the file. The
1842
+ // live-console fallback below may still recover it.
1414
1843
  logWarning = tail.error
1415
1844
  ? `Editor.log unreadable at ${logPath}: ${tail.error}`
1416
1845
  : `No Editor.log found at ${logPath}.`;
@@ -1418,7 +1847,10 @@ export class ToolRouter {
1418
1847
  else {
1419
1848
  const issues = extractProjectHealthIssues(tail.content);
1420
1849
  signaturePresent = issues.some((i) => i.kind === "editor_fd_exhaustion");
1421
- if (!signaturePresent) {
1850
+ if (signaturePresent) {
1851
+ signatureSource = "editor_log";
1852
+ }
1853
+ else {
1422
1854
  logWarning =
1423
1855
  "The editor_fd_exhaustion signature is NOT present in the recent " +
1424
1856
  "Editor.log tail. The Editor may be slow/stuck for a different " +
@@ -1427,34 +1859,87 @@ export class ToolRouter {
1427
1859
  "the diagnosis before killing the Editor.";
1428
1860
  }
1429
1861
  }
1430
- // 2. Resolve the live Unity PID via the process scan (same primitive
1431
- // bridge_status uses for cold-Safe-Mode detection). The bridge may
1432
- // already be down (it dies on fd-exhaustion), so we cannot rely on
1433
- // the instance lock's pid alone.
1862
+ // feedback-fable-04-08 §2b — live-console signature fallback. When the log
1863
+ // file did not carry the signature (it may be the wrong/stale log for the
1864
+ // running session, or the bridge is still up and the Bee exception is only
1865
+ // in the live console via LogEntries), probe the bridge's console read as a
1866
+ // secondary signature source. The fd-exhaustion exception is raised into
1867
+ // the live Unity console independently of whichever Editor.log file the
1868
+ // resolver picked, so this catches the case the file read missed. The
1869
+ // bridge is usually down by full fd-exhaustion, so this is opportunistic —
1870
+ // a failure here leaves the file-based verdict standing.
1871
+ if (!signaturePresent) {
1872
+ try {
1873
+ const liveAvailable = await live.isLiveAvailable();
1874
+ if (liveAvailable) {
1875
+ const consoleResult = await live.route("unity_senses_read_console", { max_entries: 200, type: "error", detail: "verbose" });
1876
+ const consoleBody = parseResultBody(consoleResult);
1877
+ const text = this.serializeConsoleEntries(consoleBody);
1878
+ if (text) {
1879
+ const liveIssues = extractProjectHealthIssues(text);
1880
+ if (liveIssues.some((i) => i.kind === "editor_fd_exhaustion")) {
1881
+ signaturePresent = true;
1882
+ signatureSource = "live_console";
1883
+ logWarning = null;
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1888
+ catch {
1889
+ // Bridge unreachable (expected on fd-exhaustion). The file-based
1890
+ // verdict above is the authoritative fallback.
1891
+ }
1892
+ }
1893
+ // 2. Resolve the live Unity PID. feedback #2 (2026-08-06): the instance
1894
+ // lock's `pid` is authoritative — the bridge only writes it from the
1895
+ // main interactive editor (it early-returns in worker/batch processes),
1896
+ // while the process scan could return an AssetImportWorker child that
1897
+ // shares the project path. Prefer the lock PID when it is live; fall
1898
+ // back to the scan (which now also excludes AssetImportWorker children)
1899
+ // only when no live lock PID exists.
1434
1900
  let pid = null;
1435
- try {
1436
- const proc = findUnityForProject(this.projectPath);
1437
- pid = proc ? proc.pid : null;
1901
+ let pidSource = null;
1902
+ const lockPid = this.resolveLiveEditorPid();
1903
+ if (lockPid !== undefined) {
1904
+ pid = lockPid;
1905
+ pidSource = "instance_lock";
1438
1906
  }
1439
- catch {
1440
- pid = null;
1907
+ else {
1908
+ try {
1909
+ const proc = findUnityForProject(this.projectPath);
1910
+ if (proc) {
1911
+ pid = proc.pid;
1912
+ pidSource = "process_scan";
1913
+ }
1914
+ }
1915
+ catch {
1916
+ pid = null;
1917
+ }
1441
1918
  }
1442
1919
  // 3. Dry-run branch: confirm was false/absent. Return the diagnosis +
1443
1920
  // the PID the tool WOULD kill, with no side effect. Lets an agent
1444
- // preview before committing.
1921
+ // preview before committing. feedback #2: include the PID source and
1922
+ // the resolved process's command line so a misattribution (e.g. an
1923
+ // AssetImportWorker child) is visible before confirm.
1445
1924
  if (!confirm) {
1925
+ const processCommandLine = pid !== null ? readProcessCommandLine(pid) : null;
1446
1926
  return sourceResult({
1447
1927
  action: "restart_editor",
1448
1928
  confirm: false,
1449
1929
  dryRun: true,
1450
1930
  signaturePresent,
1931
+ ...(signatureSource !== null ? { signatureSource } : {}),
1451
1932
  ...(pid !== null ? { wouldKillPid: pid } : {}),
1933
+ ...(pidSource !== null ? { pidSource } : {}),
1934
+ ...(processCommandLine !== null ? { processCommandLine } : {}),
1452
1935
  ...(logWarning !== null ? { warning: logWarning } : {}),
1453
1936
  projectPath: this.projectPath,
1454
1937
  message: "Dry-run preview. Pass confirm: true to actually terminate the " +
1455
1938
  "hung Unity Editor. Only do so after read_compile_errors " +
1456
1939
  "confirms an editor_fd_exhaustion issue — killing the Editor " +
1457
- "can destroy unsaved scene work and in-flight asset imports.",
1940
+ "can destroy unsaved scene work and in-flight asset imports. " +
1941
+ "Verify processCommandLine targets the main editor, not an " +
1942
+ "AssetImportWorker child.",
1458
1943
  }, "local");
1459
1944
  }
1460
1945
  // 4. Confirmed-path refusals. Each guard surfaces a structured code so
@@ -1510,10 +1995,10 @@ export class ToolRouter {
1510
1995
  }
1511
1996
  // 6. Kill the Editor.
1512
1997
  const kill = await killEditorProcess(pid, graceMs);
1513
- return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning);
1998
+ return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource);
1514
1999
  }
1515
2000
  /** Build the confirmed-kill response from a {@link KillResult}. */
1516
- restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning) {
2001
+ restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource) {
1517
2002
  if (kill.terminated) {
1518
2003
  return sourceResult({
1519
2004
  action: "restart_editor",
@@ -1523,6 +2008,7 @@ export class ToolRouter {
1523
2008
  method: kill.method,
1524
2009
  elapsedMs: kill.elapsedMs,
1525
2010
  graceMs,
2011
+ ...(signatureSource !== null ? { signatureSource } : {}),
1526
2012
  ...(dirtyScenesWarning !== null
1527
2013
  ? {
1528
2014
  dirtyScenesWarning,
@@ -1567,6 +2053,33 @@ export class ToolRouter {
1567
2053
  ],
1568
2054
  }, "local", true);
1569
2055
  }
2056
+ /**
2057
+ * Flatten a unity_senses_read_console result body into a single string the
2058
+ * health-issue extractor can scan for the fd-exhaustion signature. The
2059
+ * console result is a JSON object with an `entries[]` array of
2060
+ * `{ message, stack, ... }`; we join message + stack so the Bee driver's
2061
+ * `Could not register to wait for file descriptor N` frame (which lives in
2062
+ * the stack trace) is reachable by {@link extractProjectHealthIssues}.
2063
+ * Returns "" when the body has no entries.
2064
+ */
2065
+ serializeConsoleEntries(body) {
2066
+ if (!body || typeof body !== "object")
2067
+ return "";
2068
+ const entries = body.entries;
2069
+ if (!Array.isArray(entries))
2070
+ return "";
2071
+ const parts = [];
2072
+ for (const ent of entries) {
2073
+ if (!ent || typeof ent !== "object")
2074
+ continue;
2075
+ const e = ent;
2076
+ if (typeof e.message === "string")
2077
+ parts.push(e.message);
2078
+ if (typeof e.stack === "string")
2079
+ parts.push(e.stack);
2080
+ }
2081
+ return parts.join("\n");
2082
+ }
1570
2083
  /**
1571
2084
  * T31.4 — sample the live Unity process's file-descriptor usage and report
1572
2085
  * headroom against Mono's ~1024 fd ceiling (the real trip point for the Bee
@@ -1580,20 +2093,27 @@ export class ToolRouter {
1580
2093
  * calls build a trend (monotonic climb = leak in progress). No disk cache.
1581
2094
  */
1582
2095
  async routeResourcePressure(args) {
1583
- // 1. Resolve the PID. An explicit pid arg wins; otherwise scan for the
1584
- // live Unity process for this project (same primitive bridge_status
1585
- // uses). The probe must NOT depend on the bridge.
2096
+ // 1. Resolve the PID. An explicit pid arg wins; otherwise prefer the
2097
+ // instance-lock PID (authoritative main editor — feedback #2) and fall
2098
+ // back to the process scan (which excludes AssetImportWorker children).
2099
+ // The probe must NOT depend on the bridge.
1586
2100
  let pid = null;
1587
2101
  if (typeof args.pid === "number" && Number.isInteger(args.pid) && args.pid > 0) {
1588
2102
  pid = args.pid;
1589
2103
  }
1590
2104
  else {
1591
- try {
1592
- const proc = findUnityForProject(this.projectPath);
1593
- pid = proc ? proc.pid : null;
2105
+ const lockPid = this.resolveLiveEditorPid();
2106
+ if (lockPid !== undefined) {
2107
+ pid = lockPid;
1594
2108
  }
1595
- catch {
1596
- pid = null;
2109
+ else {
2110
+ try {
2111
+ const proc = findUnityForProject(this.projectPath);
2112
+ pid = proc ? proc.pid : null;
2113
+ }
2114
+ catch {
2115
+ pid = null;
2116
+ }
1597
2117
  }
1598
2118
  }
1599
2119
  if (pid === null) {
@@ -1608,25 +2128,42 @@ export class ToolRouter {
1608
2128
  },
1609
2129
  }, "local", true);
1610
2130
  }
1611
- // 2. Probe + record the sample. A failed probe still records a sample
2131
+ // 2. Resolve the configured fd ceiling (default 1024, overridable per
2132
+ // project via `.unity-open-mcp/settings.json` for runtimes whose
2133
+ // internal limit differs from Mono's classic 1024). Read fresh —
2134
+ // resource_pressure is called rarely, and this lets an operator change
2135
+ // the setting without restarting the MCP server.
2136
+ const { ceiling, source: ceilingSource } = readFdCeiling(this.projectPath);
2137
+ // 3. Probe + record the sample. A failed probe still records a sample
1612
2138
  // (count: null) so the trend detector sees the gap and does not
1613
2139
  // interpolate across it.
1614
2140
  const probe = countFileDescriptors(pid);
1615
2141
  const count = probe.count;
1616
- const headroom = computeFdHeadroom(count, "approximate" in probe ? probe.approximate : false);
2142
+ const approximate = "approximate" in probe ? probe.approximate : false;
2143
+ const headroom = computeFdHeadroom(count, approximate, ceiling);
1617
2144
  const ts = Date.now();
1618
2145
  this.sessionState.recordFdSample({ ts, pid, count });
1619
2146
  const samples = this.sessionState.fdSamplesSnapshot();
1620
- const trend = analyzeFdTrend(samples);
1621
- // 3. Build the response. The launchContextCaveat is always present so an
1622
- // agent (and the operator) understands the Mono-ceiling-vs-OS-soft-
1623
- // limit nuance. warningState is non-null only when there is something
1624
- // to surface (warn/critical/leaking); ok+stable stays silent.
2147
+ const trend = analyzeFdTrend(samples, ceiling);
2148
+ // 4. Decide whether to raise an operator warning. For an fd-heavy project
2149
+ // the OS-level count legitimately sits OVER the Mono-ceiling proxy on a
2150
+ // fresh healthy process (lsof counts mmap'd assets / file watchers /
2151
+ // FileStream handles that Mono's IOSelector never registers), so an
2152
+ // absolute `over_ceiling` state is NOT an alarm by itself. Alarm only on:
2153
+ // - a monotonic leak (the actionable trend signal), or
2154
+ // - approaching the ceiling from below (warn/critical).
2155
+ // A merely `rising` or stable/no-history over-ceiling process gets a
2156
+ // `pressureNote` instead. `rising` is any net-positive delta that is NOT a
2157
+ // qualifying leak — it fires on ordinary drift, so it must NOT alarm on its
2158
+ // own (the prior over_ceiling+rising alarm re-cried wolf on every sample
2159
+ // after the first).
1625
2160
  const fdMethod = probe.method;
1626
- const approximate = "approximate" in probe ? probe.approximate : false;
1627
2161
  const probeReason = "reason" in probe ? probe.reason : null;
1628
2162
  const probeMessage = "message" in probe ? probe.message : null;
1629
- const warningState = headroom.state === "warn" || headroom.state === "critical" || trend.state === "leaking"
2163
+ const shouldWarn = trend.state === "leaking" ||
2164
+ headroom.state === "warn" ||
2165
+ headroom.state === "critical";
2166
+ const warningState = shouldWarn
1630
2167
  ? {
1631
2168
  level: headroom.state === "critical"
1632
2169
  ? "critical"
@@ -1635,26 +2172,44 @@ export class ToolRouter {
1635
2172
  : "warn",
1636
2173
  message: headroom.state === "critical"
1637
2174
  ? `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1638
- `Mono's ${FD_CEILING}-descriptor ceiling — the next domain reload is ` +
2175
+ `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
1639
2176
  `likely to trip the Bee build-driver hang. Save scene work and restart ` +
1640
2177
  `Unity via the Hub now, before the Editor hangs.`
1641
2178
  : trend.state === "leaking"
1642
- ? `Editor fd usage is climbing monotonically across samples (leak in ` +
1643
- `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
1644
- `sample(s). Save scene work and plan a restart before the count ` +
1645
- `crosses the ${FD_CEILING}-descriptor ceiling.`
2179
+ ? headroom.state === "over_ceiling"
2180
+ ? `Editor fd usage (${count}) is ABOVE the ${ceiling}-descriptor ` +
2181
+ `ceiling and climbing monotonically (leak in progress): trend ` +
2182
+ `delta ${trend.delta} over ${trend.sampleCount} sample(s). The ` +
2183
+ `count has already crossed the ceiling proxy — save scene work ` +
2184
+ `and restart Unity via the Hub now.`
2185
+ : `Editor fd usage is climbing monotonically across samples (leak in ` +
2186
+ `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
2187
+ `sample(s). Save scene work and plan a restart before the count ` +
2188
+ `crosses the ${ceiling}-descriptor ceiling.`
1646
2189
  : `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1647
- `Mono's ${FD_CEILING}-descriptor ceiling. Monitor the trend; if it ` +
1648
- `keeps climbing across domain reloads, save scene work and restart ` +
1649
- `Unity via the Hub before the Editor hangs.`,
2190
+ `the ${ceiling}-descriptor ceiling. Monitor the trend; if it keeps ` +
2191
+ `climbing across domain reloads, save scene work and restart Unity ` +
2192
+ `via the Hub before the Editor hangs.`,
1650
2193
  }
1651
2194
  : null;
2195
+ // Informational (non-alarming) note for the over-ceiling-but-stable case.
2196
+ const pressureNote = !shouldWarn && headroom.state === "over_ceiling"
2197
+ ? `OS-level fd count (${count}) is above the ${ceiling}-descriptor ceiling ` +
2198
+ `proxy, but lsof / HandleCount count ALL OS file descriptors (mmap'd ` +
2199
+ `assets, file watchers, regular FileStream handles) while only ` +
2200
+ `Mono-IOSelector-registered descriptors (sockets/pipes under async IO) ` +
2201
+ `count toward the real trip point. For an asset-heavy project this is ` +
2202
+ `expected on a healthy process. Treat this as a concern ONLY if the trend ` +
2203
+ `becomes a monotonic leak (leaking) — re-sample after the next domain ` +
2204
+ `reload to establish one.`
2205
+ : null;
1652
2206
  return sourceResult({
1653
2207
  pid,
1654
2208
  fdCount: count,
1655
2209
  fdMethod,
1656
2210
  approximate,
1657
- ceiling: FD_CEILING,
2211
+ ceiling,
2212
+ ceilingSource,
1658
2213
  headroom: headroom.headroom,
1659
2214
  pressureRatio: headroom.pressureRatio,
1660
2215
  state: headroom.state,
@@ -1685,6 +2240,7 @@ export class ToolRouter {
1685
2240
  ],
1686
2241
  }
1687
2242
  : {}),
2243
+ ...(pressureNote !== null ? { pressureNote } : {}),
1688
2244
  }, "local");
1689
2245
  }
1690
2246
  // ── M26 Plan 2 — Unity Hub control (local-routed) ────────────────
@@ -1975,6 +2531,9 @@ export class ToolRouter {
1975
2531
  routeMeta = { route: "batch", fallbackReason: "live_unavailable" };
1976
2532
  }
1977
2533
  }
2534
+ // feedback-fable-04-08 §7 — rewrite the generic live_unavailable to
2535
+ // editor_busy when the batch hit a live editor's project lock.
2536
+ routeMeta = this.rewriteEditorBusy(raw, routeMeta);
1978
2537
  const body = parseResultBody(raw);
1979
2538
  if (body === null) {
1980
2539
  // Body not parseable — fall back to the legacy injectRouteMeta path so
@@ -2030,7 +2589,14 @@ export class ToolRouter {
2030
2589
  }
2031
2590
  else {
2032
2591
  raw = await this.batch.route("unity_open_mcp_scene_get_data", bridgeArgs);
2033
- raw = injectRouteMeta(raw, { route: "batch", fallbackReason: "live_unavailable" });
2592
+ // feedback-fable-04-08 §7 — rewrite to editor_busy when the batch hit
2593
+ // a live editor's project lock. Compute the meta from the raw result
2594
+ // (which carries the editor_instance_locked error) before stamping.
2595
+ const meta = this.rewriteEditorBusy(raw, {
2596
+ route: "batch",
2597
+ fallbackReason: "live_unavailable",
2598
+ });
2599
+ raw = injectRouteMeta(raw, meta);
2034
2600
  }
2035
2601
  }
2036
2602
  if (!wantPaging)
@@ -2081,12 +2647,25 @@ function pageSceneNodes(body, pageSize, cursor) {
2081
2647
  void roots;
2082
2648
  void rootGameObjects;
2083
2649
  void nodes;
2084
- void truncated;
2650
+ // `truncated` is read at the source-truncated carry-through below, so it is
2651
+ // not voided with the other intentionally-discarded scene fields.
2085
2652
  void moreHidden;
2086
2653
  void _existing;
2087
2654
  // Pull the top-level meta (everything except `scene`, which we rebuild).
2088
- const { scene: _s, ...topMeta } = body;
2655
+ // B-N21 — strip the structural fields from the TOP level too. For a
2656
+ // pre-unwrapped body (roots/rootGameObjects at the top level, no `scene`
2657
+ // wrapper — the fallback flattenSceneBody handles), `scene` above resolves
2658
+ // to {} and strips nothing, so spreading the raw remainder of `body` used
2659
+ // to carry the ENTIRE unpaged roots array into the response alongside the
2660
+ // page — paging never bounded the payload for that shape. flattenSceneBody
2661
+ // already consumed those fields; drop them wherever they live.
2662
+ const { scene: _s, roots: topRoots, rootGameObjects: topRootGameObjects, nodes: topNodes, truncated: topTruncated, moreHidden: topMoreHidden, pagination: _topExisting, ...topMeta } = body;
2089
2663
  void _s;
2664
+ void topRoots;
2665
+ void topRootGameObjects;
2666
+ void topNodes;
2667
+ void topMoreHidden;
2668
+ void _topExisting;
2090
2669
  const result = {
2091
2670
  ...topMeta,
2092
2671
  status: body.status ?? "ok",
@@ -2099,8 +2678,10 @@ function pageSceneNodes(body, pageSize, cursor) {
2099
2678
  // Carry the bridge's source-side truncation count through to the page so an
2100
2679
  // agent can distinguish "more pages" (pagination.truncated) from "the bridge
2101
2680
  // capped the hierarchy" (scene.truncated). Both can be non-zero at once.
2102
- if (typeof truncated === "number" && truncated > 0) {
2103
- result.scene.truncated = truncated;
2681
+ // For a pre-unwrapped body the count lives at the top level (B-N21).
2682
+ const sourceTruncated = typeof truncated === "number" ? truncated : topTruncated;
2683
+ if (typeof sourceTruncated === "number" && sourceTruncated > 0) {
2684
+ result.scene.truncated = sourceTruncated;
2104
2685
  }
2105
2686
  return attachPagination(result, block);
2106
2687
  }