unity-open-mcp 0.8.4 → 1.0.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 (111) 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 +1 -0
  5. package/dist/batch-spawn.js +118 -2
  6. package/dist/batch-spawn.js.map +1 -1
  7. package/dist/capabilities/intent-groups.js +42 -0
  8. package/dist/capabilities/intent-groups.js.map +1 -1
  9. package/dist/capabilities/lifecycle.js +12 -0
  10. package/dist/capabilities/lifecycle.js.map +1 -1
  11. package/dist/capabilities/rule-catalog.js +7 -1
  12. package/dist/capabilities/rule-catalog.js.map +1 -1
  13. package/dist/capabilities/tool-groups.js +39 -2
  14. package/dist/capabilities/tool-groups.js.map +1 -1
  15. package/dist/constants.d.ts +21 -1
  16. package/dist/constants.js +21 -1
  17. package/dist/constants.js.map +1 -1
  18. package/dist/event-stream.d.ts +13 -3
  19. package/dist/event-stream.js +77 -12
  20. package/dist/event-stream.js.map +1 -1
  21. package/dist/live-client.d.ts +17 -0
  22. package/dist/live-client.js +130 -9
  23. package/dist/live-client.js.map +1 -1
  24. package/dist/offline/api.js +82 -30
  25. package/dist/offline/api.js.map +1 -1
  26. package/dist/offline/index-builders.d.ts +9 -0
  27. package/dist/offline/index-builders.js +58 -8
  28. package/dist/offline/index-builders.js.map +1 -1
  29. package/dist/offline/parse.js +5 -1
  30. package/dist/offline/parse.js.map +1 -1
  31. package/dist/process-diagnostics.d.ts +100 -19
  32. package/dist/process-diagnostics.js +167 -59
  33. package/dist/process-diagnostics.js.map +1 -1
  34. package/dist/project-settings.d.ts +36 -0
  35. package/dist/project-settings.js +96 -0
  36. package/dist/project-settings.js.map +1 -0
  37. package/dist/running-unity.d.ts +23 -0
  38. package/dist/running-unity.js +60 -0
  39. package/dist/running-unity.js.map +1 -1
  40. package/dist/stale-assembly-cache.d.ts +40 -0
  41. package/dist/stale-assembly-cache.js +76 -0
  42. package/dist/stale-assembly-cache.js.map +1 -0
  43. package/dist/tool-router.d.ts +40 -1
  44. package/dist/tool-router.js +479 -68
  45. package/dist/tool-router.js.map +1 -1
  46. package/dist/tool-session-state.d.ts +12 -6
  47. package/dist/tool-session-state.js +16 -9
  48. package/dist/tool-session-state.js.map +1 -1
  49. package/dist/tools/capture-inline.js +4 -3
  50. package/dist/tools/capture-inline.js.map +1 -1
  51. package/dist/tools/component-get.js +9 -0
  52. package/dist/tools/component-get.js.map +1 -1
  53. package/dist/tools/editor-status.js +12 -3
  54. package/dist/tools/editor-status.js.map +1 -1
  55. package/dist/tools/execute-csharp.js +41 -4
  56. package/dist/tools/execute-csharp.js.map +1 -1
  57. package/dist/tools/gameobject-create.js +19 -6
  58. package/dist/tools/gameobject-create.js.map +1 -1
  59. package/dist/tools/gameobject-duplicate.js +7 -2
  60. package/dist/tools/gameobject-duplicate.js.map +1 -1
  61. package/dist/tools/index.d.ts +1 -0
  62. package/dist/tools/index.js +37 -0
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inputsim-key.d.ts +40 -0
  65. package/dist/tools/inputsim-key.js +72 -0
  66. package/dist/tools/inputsim-key.js.map +1 -0
  67. package/dist/tools/inputsim-pointer.d.ts +40 -0
  68. package/dist/tools/inputsim-pointer.js +121 -0
  69. package/dist/tools/inputsim-pointer.js.map +1 -0
  70. package/dist/tools/inputsim-pointer3d.d.ts +40 -0
  71. package/dist/tools/inputsim-pointer3d.js +55 -0
  72. package/dist/tools/inputsim-pointer3d.js.map +1 -0
  73. package/dist/tools/inputsim-probe.d.ts +40 -0
  74. package/dist/tools/inputsim-probe.js +43 -0
  75. package/dist/tools/inputsim-probe.js.map +1 -0
  76. package/dist/tools/inputsim-step.d.ts +40 -0
  77. package/dist/tools/inputsim-step.js +50 -0
  78. package/dist/tools/inputsim-step.js.map +1 -0
  79. package/dist/tools/inputsim-touch.d.ts +40 -0
  80. package/dist/tools/inputsim-touch.js +83 -0
  81. package/dist/tools/inputsim-touch.js.map +1 -0
  82. package/dist/tools/manage-tools.js +8 -1
  83. package/dist/tools/manage-tools.js.map +1 -1
  84. package/dist/tools/memory-snapshot-capture.js +11 -5
  85. package/dist/tools/memory-snapshot-capture.js.map +1 -1
  86. package/dist/tools/prefab-instantiate.js +11 -0
  87. package/dist/tools/prefab-instantiate.js.map +1 -1
  88. package/dist/tools/pull-events.js +18 -10
  89. package/dist/tools/pull-events.js.map +1 -1
  90. package/dist/tools/read-compile-errors.js +15 -1
  91. package/dist/tools/read-compile-errors.js.map +1 -1
  92. package/dist/tools/recompile-scripts.d.ts +40 -0
  93. package/dist/tools/recompile-scripts.js +35 -0
  94. package/dist/tools/recompile-scripts.js.map +1 -0
  95. package/dist/tools/resource-pressure.js +21 -10
  96. package/dist/tools/resource-pressure.js.map +1 -1
  97. package/dist/tools/run-tests.js +21 -4
  98. package/dist/tools/run-tests.js.map +1 -1
  99. package/dist/tools/screenshot-window.js +18 -12
  100. package/dist/tools/screenshot-window.js.map +1 -1
  101. package/dist/tools/screenshot.js +13 -6
  102. package/dist/tools/screenshot.js.map +1 -1
  103. package/dist/tools/ui-canvas-add.js +19 -6
  104. package/dist/tools/ui-canvas-add.js.map +1 -1
  105. package/dist/tools/visual-compare.d.ts +40 -0
  106. package/dist/tools/visual-compare.js +98 -0
  107. package/dist/tools/visual-compare.js.map +1 -0
  108. package/dist/unity-log.d.ts +111 -1
  109. package/dist/unity-log.js +334 -11
  110. package/dist/unity-log.js.map +1 -1
  111. package/package.json +1 -1
@@ -1,7 +1,8 @@
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 } from "./unity-log.js";
3
+ import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
4
4
  import { summarizeProjectHealth, extractProjectHealthIssues } from "./project-health.js";
5
+ import { countProjectAsmdefs, countAssembliesWithErrors } from "./asmdef-discovery.js";
5
6
  import { buildCapabilities } from "./capabilities/build-capabilities.js";
6
7
  import { RULE_CATALOG, FIX_CATALOG } from "./capabilities/rule-catalog.js";
7
8
  import { GROUP_IDS, TOOL_GROUPS, AUTO_ACTIVATE_GROUPS, groupToTools, getGroup, } from "./capabilities/tool-groups.js";
@@ -11,8 +12,8 @@ import { generateSkill } from "./skill/generate-skill.js";
11
12
  import { getKnownClientKeys } from "./skill/client-paths.js";
12
13
  import { ALL_TOOLS } from "./tools/index.js";
13
14
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
14
- import { PORT_ENV_VAR } from "./constants.js";
15
- import { findUnityForProject } from "./running-unity.js";
15
+ import { PORT_ENV_VAR, TYPED_EDITOR_ACTIVATE_INSTRUCTION } from "./constants.js";
16
+ import { findUnityForProject, readProcessCommandLine } from "./running-unity.js";
16
17
  // M31-optimizations Plan 1 / L14 — opt-in route-logging gate. Resolved once at
17
18
  // module load into a boolean; the per-dispatch log sites read the boolean
18
19
  // (not process.env on every call) so the hot path stays a single branch.
@@ -38,7 +39,8 @@ export function logRoute(message) {
38
39
  // (resource_pressure). Both act on the OS process, not the bridge, so they
39
40
  // share the local route classification with bridge_status.
40
41
  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";
42
+ import { countFileDescriptors, computeFdHeadroom, analyzeFdTrend, LAUNCH_CONTEXT_CAVEAT, } from "./process-diagnostics.js";
43
+ import { readFdCeiling } from "./project-settings.js";
42
44
  import { listInstalledEditors, fetchAvailableReleases, openInstallDeepLink, getInstallPath, setInstallPath, } from "./hub-control.js";
43
45
  /** Default Hub-control backend — calls the real hub-control functions. */
44
46
  const defaultHubBackend = {
@@ -276,33 +278,59 @@ function pageFindReferencesResult(result, pageSize, cursor) {
276
278
  function foldFindReferencesLiveBody(body, profile, wantPaging, args) {
277
279
  if (body.error)
278
280
  return null;
279
- // Bridge shape: { referencedBy: string[], totalCount, ... } (flat paths).
281
+ // Bridge shape: { referencedBy: Array<{assetPath, guid}>, totalCount, ... } —
282
+ // each entry is an object (FindReferencesTool emits {assetPath, guid}). Older
283
+ // comments assumed a flat string[]; the live bridge has never emitted that.
284
+ // `extractRefPath` normalizes both the object form and a legacy/defensive
285
+ // bare-string form so a future shape change does not crash the compact
286
+ // grouping (feedback-01-08-glm §3: a non-string element reached
287
+ // `.lastIndexOf` and hard-crashed the tool).
280
288
  const rawList = Array.isArray(body.referencedBy) ? body.referencedBy : [];
289
+ const pathList = rawList.map((entry) => extractRefPath(entry));
281
290
  const pageSize = typeof args.page_size === "number" && args.page_size > 0 ? args.page_size : 0;
282
291
  const cursor = typeof args.cursor === "string" ? args.cursor : undefined;
283
292
  if (profile === "compact") {
284
293
  // compact: counts + byKind grouping only — drop the per-asset list.
285
294
  const { referencedBy: _dropped, ...rest } = body;
286
295
  void _dropped;
287
- const byKind = groupLiveRefsByKind(rawList);
296
+ const byKind = groupLiveRefsByKind(pathList);
288
297
  return {
289
298
  ...rest,
290
- totalCount: rawList.length,
299
+ totalCount: pathList.length,
291
300
  byKind,
292
301
  referencedBy: [],
293
302
  };
294
303
  }
295
304
  // balanced / full: keep the path list (paged when requested).
296
305
  if (pageSize > 0) {
297
- const { page, block } = applyPaging(rawList, "find_references", { page_size: pageSize, cursor });
298
- return attachPagination({ ...body, referencedBy: page, totalCount: rawList.length }, block);
306
+ const { page, block } = applyPaging(pathList, "find_references", { page_size: pageSize, cursor });
307
+ return attachPagination({ ...body, referencedBy: page, totalCount: pathList.length }, block);
299
308
  }
300
- return { ...body, totalCount: rawList.length };
309
+ return { ...body, referencedBy: pathList, totalCount: pathList.length };
310
+ }
311
+ /** Coerce a `referencedBy` entry to its asset path. The live bridge emits
312
+ * `{assetPath, guid}` objects; accept a bare string defensively so a shape
313
+ * change never reaches the string-only `.lastIndexOf` in the grouping. */
314
+ function extractRefPath(entry) {
315
+ if (typeof entry === "string")
316
+ return entry;
317
+ if (entry && typeof entry === "object") {
318
+ const obj = entry;
319
+ if (typeof obj.assetPath === "string")
320
+ return obj.assetPath;
321
+ if (typeof obj.path === "string")
322
+ return obj.path;
323
+ }
324
+ return "";
301
325
  }
302
326
  /** Derive a byKind grouping from flat referenced paths for the live compact view. */
303
327
  function groupLiveRefsByKind(paths) {
304
328
  const counts = {};
305
329
  for (const path of paths) {
330
+ if (!path) {
331
+ counts.other = (counts.other ?? 0) + 1;
332
+ continue;
333
+ }
306
334
  const dot = path.lastIndexOf(".");
307
335
  const kind = dot > 0 ? path.slice(dot + 1).toLowerCase() : "other";
308
336
  counts[kind] = (counts[kind] ?? 0) + 1;
@@ -362,6 +390,7 @@ export class ToolRouter {
362
390
  sessionState;
363
391
  onToolListChanged;
364
392
  hubBackend;
393
+ globalLogPathOverride;
365
394
  modelCache = new AssetModelCache();
366
395
  /**
367
396
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -418,7 +447,15 @@ export class ToolRouter {
418
447
  // M26 Plan 2 — injectable Hub-control backend. Omit in production (the
419
448
  // default calls the real hub-control functions); tests pass a fake to
420
449
  // avoid real subprocess + network side effects.
421
- hubBackend = defaultHubBackend) {
450
+ hubBackend = defaultHubBackend,
451
+ // feedback-fable-04-08 §2b — injectable global Editor.log path for tests.
452
+ // Production leaves this undefined so resolveEditorLogPath computes the
453
+ // real platform global path; tests pass a path under their tmp sandbox so
454
+ // the freshness comparison does not consult the host machine's real
455
+ // ~/Library/Logs/Unity/Editor.log (which is newer than the artificial
456
+ // 1970-era mtimes the tests plant and would win the comparison, reading
457
+ // the wrong file).
458
+ globalLogPathOverride) {
422
459
  this.live = live;
423
460
  this.batch = batch;
424
461
  this.projectPath = projectPath;
@@ -426,6 +463,7 @@ export class ToolRouter {
426
463
  this.sessionState = sessionState;
427
464
  this.onToolListChanged = onToolListChanged;
428
465
  this.hubBackend = hubBackend;
466
+ this.globalLogPathOverride = globalLogPathOverride;
429
467
  }
430
468
  async route(toolName, args) {
431
469
  return this.routeCore(this.live, toolName, args);
@@ -502,10 +540,13 @@ export class ToolRouter {
502
540
  return this.batchPingResult();
503
541
  }
504
542
  const result = await this.batch.route(toolName, args);
505
- return injectRouteMeta(result, {
543
+ // feedback-fable-04-08 §7 — when the batch fallback hit a live editor's
544
+ // project lock, the actionable signal is editor_busy, not live_unavailable.
545
+ const meta = this.rewriteEditorBusy(result, {
506
546
  route: "batch",
507
547
  fallbackReason: "live_unavailable",
508
548
  });
549
+ return injectRouteMeta(result, meta);
509
550
  }
510
551
  batchPingResult() {
511
552
  return {
@@ -530,6 +571,52 @@ export class ToolRouter {
530
571
  isError: false,
531
572
  };
532
573
  }
574
+ /**
575
+ * feedback-fable-04-08 §7 — when a `live_unavailable` batch fallback lands
576
+ * on `editor_instance_locked`, the message describes the BATCH SPAWNER's
577
+ * problem ("a live editor holds the project lock, so no headless spawn"),
578
+ * but the agent's actionable truth is "the editor is busy (TestRunner run /
579
+ * long import); retry later", not a lock conflict. Detect this case by
580
+ * inspecting the batch result for `error.code === "editor_instance_locked"`
581
+ * AND confirming a live PID still holds the project (a stale lock with no
582
+ * live PID is a genuine lock conflict — leave it as `live_unavailable`).
583
+ *
584
+ * Returns the rewritten meta (and the result unchanged — the lock error body
585
+ * is real and must stay intact) so the caller stamps it via injectRouteMeta.
586
+ * The live PID is surfaced as `editorPid` for agent correlation. Falls
587
+ * through unchanged when the code differs, no live PID exists, or the body
588
+ * is unparseable.
589
+ */
590
+ rewriteEditorBusy(result, meta) {
591
+ if (meta.fallbackReason !== "live_unavailable")
592
+ return meta;
593
+ const body = parseResultBody(result);
594
+ if (body === null)
595
+ return meta;
596
+ const err = body.error;
597
+ const code = typeof err === "object" && err !== null
598
+ ? err.code
599
+ : undefined;
600
+ if (code !== "editor_instance_locked")
601
+ return meta;
602
+ // Confirm a live editor actually holds the project. The batch pre-spawn
603
+ // lock check already read the lock, but a race (the editor quit between
604
+ // the batch check and here) or a corrupt lock must NOT be misreported as
605
+ // "busy" — fall through to the generic live_unavailable in that case.
606
+ try {
607
+ const lock = readInstanceLock(this.projectPath);
608
+ if (!lock?.pid || !isPidAlive(lock.pid))
609
+ return meta;
610
+ return {
611
+ route: "batch",
612
+ fallbackReason: "editor_busy",
613
+ editorPid: lock.pid,
614
+ };
615
+ }
616
+ catch {
617
+ return meta;
618
+ }
619
+ }
533
620
  async routeReadCompileErrors(args) {
534
621
  const tailBytes = typeof args.tail_bytes === "number" && args.tail_bytes >= 4096
535
622
  ? Math.min(Math.floor(args.tail_bytes), 1048576)
@@ -538,7 +625,15 @@ export class ToolRouter {
538
625
  // project-relative log (<project>/Logs/Editor.log); the global per-user
539
626
  // log is stale there. resolveEditorLogPath prefers the project-relative
540
627
  // log when it exists and falls back to the global log for older Unity.
541
- const logPath = resolveEditorLogPath(this.projectPath);
628
+ //
629
+ // feedback-fable-31-07 §5 — when a live editor holds the project AND the
630
+ // resolved Editor.log looks frozen (rotated by a batch spawn that failed
631
+ // on the project lock), fall back to Editor-prev.log, where the live
632
+ // editor keeps writing. Pass the live PID from the instance lock so the
633
+ // resolver can make the strong-signal decision.
634
+ const livePid = this.resolveLiveEditorPid();
635
+ const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid, this.globalLogPathOverride);
636
+ const logPath = resolvedLog.path;
542
637
  const tail = readLogTail(logPath, tailBytes);
543
638
  if (tail.error) {
544
639
  return {
@@ -583,11 +678,6 @@ export class ToolRouter {
583
678
  // backward compatibility) so existing callers do not break.
584
679
  const health = summarizeProjectHealth(tail.content);
585
680
  const errors = health.compilerErrors;
586
- const status = health.unhealthy
587
- ? errors.length > 0
588
- ? "compile_failed"
589
- : "project_unhealthy"
590
- : "no_errors_found";
591
681
  // specs/feedback.md 2026-07-05 — stale-log detection. When an assembly is
592
682
  // stuck in a failed-compile state, AssetDatabase.Refresh no-ops and the
593
683
  // log's most-recent CSxxxx block can reference on-disk source that has
@@ -602,17 +692,108 @@ export class ToolRouter {
602
692
  if (stale.staleLogSuspected)
603
693
  staleLog = stale;
604
694
  }
695
+ // feedback-01-08-glm §5b — stale-ASSEMBLY detection. Runs UNCONDITIONALLY
696
+ // (even on no_errors_found): after a C# edit, AssetDatabase.Refresh often
697
+ // no-ops, the DLL is never rebuilt, and the log carries no fresh CSxxxx —
698
+ // so a healthy signal reported against a stale assembly misleads the agent
699
+ // into trusting code that has not actually been rebuilt. Cross-check the
700
+ // newest Library/ScriptAssemblies/*.dll mtime against Assets/**/*.cs source
701
+ // mtimes and surface staleAssembly when any source is newer than the DLL.
702
+ const staleAsm = detectStaleAssembly(this.projectPath);
703
+ // feedback-04-08-opus §2 — log-authorship cross-check. A batch run of a
704
+ // NEWER Unity writes a log whose errors (e.g. an API deprecation that is
705
+ // only an error on that version) CANNOT occur in the live editor. Parse the
706
+ // log header's version + batch-mode + date, compare against the live bridge
707
+ // version, and surface a mismatch so the agent does not "fix" 31 unrelated
708
+ // files or refuse to continue on errors that cannot occur here. The fields
709
+ // are always emitted when the header was parseable (so an agent can see the
710
+ // log was a batch run even when versions agree); the warning hint is added
711
+ // only on a real mismatch or a batch-mode log.
712
+ const authorship = parseLogAuthorship(tail.content);
713
+ const liveUnityVersion = this.resolveLiveUnityVersion();
714
+ const authorshipCheck = compareLogAuthorship(authorship, liveUnityVersion);
715
+ // feedback-fable-04-08 §4 — downgrade the top-level status when the errors
716
+ // may not apply to the running editor. An agent that trusts a
717
+ // `compile_failed` + `unhealthy: true` headline will start "fixing" 31
718
+ // phantom errors from a stale / batch-authored log. When EITHER the log is
719
+ // stale (a cited source is newer than the log) OR a version-mismatch is
720
+ // detected (a different Unity wrote the log), the verdict becomes
721
+ // `stale_log` — the errors[] are still attached as evidence, but the
722
+ // headline + status make clear they MUST NOT be acted on until a genuine
723
+ // recompile confirms them. The raw verdict (what the log itself says) is
724
+ // preserved in `logVerdict` for agents that want the literal signal.
725
+ const logVerdict = health.unhealthy
726
+ ? errors.length > 0
727
+ ? "compile_failed"
728
+ : "project_unhealthy"
729
+ : "no_errors_found";
730
+ const errorsMayNotApply = health.unhealthy &&
731
+ (!!staleLog || authorshipCheck.versionMismatch);
732
+ const status = errorsMayNotApply ? "stale_log" : logVerdict;
733
+ // specs/feedback.md 2026-08-12 — partial-compile signal. Unity compiles
734
+ // assemblies in dependency order; when an early assembly fails the pipeline
735
+ // may not reach dependent ones, so the reported errors can be a SUBSET of
736
+ // the true error set. When there are errors AND the project has multiple
737
+ // compile assemblies, surface asmdefCount + assembliesWithErrors +
738
+ // partialCompileLikely so an agent does not treat `errorCount` as final
739
+ // (this is exactly the trap that masked 6 errors behind 1 during the
740
+ // input-simulation review).
741
+ // Only walk for asmdefs when there are errors — the signal is only
742
+ // meaningful with errors, and countProjectAsmdefs stats the whole Assets/
743
+ // tree synchronously (memoized per project, but still skip it when unused).
744
+ const hasErrors = errors.length > 0;
745
+ const asmdefs = hasErrors
746
+ ? countProjectAsmdefs(this.projectPath)
747
+ : { count: 0, sample: [] };
748
+ const partialCompileLikely = hasErrors && asmdefs.count > 1;
749
+ const assembliesWithErrors = partialCompileLikely
750
+ ? countAssembliesWithErrors(errors.map((e) => e.file), this.projectPath)
751
+ : 0;
605
752
  return sourceResult({
606
753
  status,
607
- unhealthy: health.unhealthy,
608
- headline: health.headline,
754
+ // When the errors may not apply (stale log / version mismatch), the
755
+ // top-level verdict is downgraded to "stale_log" and unhealthy is
756
+ // suppressed so an agent branching on `unhealthy` does not treat
757
+ // phantom errors as a hard failure (feedback-fable-04-08 §4). The raw
758
+ // log verdict is preserved in `logVerdict` for callers that want the
759
+ // literal signal regardless of freshness.
760
+ unhealthy: errorsMayNotApply ? false : health.unhealthy,
761
+ headline: errorsMayNotApply
762
+ ? `${errors.length} error(s) in Editor.log, but the log appears ` +
763
+ "STALE or authored by a different Unity — these errors may NOT " +
764
+ "apply to the running editor. Do NOT act on them until a genuine " +
765
+ "recompile (unity_open_mcp_recompile_scripts, in the typed-editor " +
766
+ `group — ${TYPED_EDITOR_ACTIVATE_INSTRUCTION} first) confirms them.`
767
+ : health.headline,
768
+ ...(errorsMayNotApply ? { logVerdict } : {}),
609
769
  errorCount: errors.length,
610
770
  errors,
771
+ // specs/feedback.md 2026-08-12 — present only when there are errors AND
772
+ // the project has multiple compile assemblies. Unity's dependency-ordered
773
+ // pipeline means these errors may be a subset; fixing them lets dependent
774
+ // assemblies compile and may surface more. Re-check until errorCount is 0.
775
+ ...(partialCompileLikely
776
+ ? {
777
+ partialCompileLikely: true,
778
+ asmdefCount: asmdefs.count,
779
+ // Distinct assemblies the reported errors span (null when none of
780
+ // the cited files mapped to an asmdef — e.g. global/package code).
781
+ assembliesWithErrors: assembliesWithErrors > 0 ? assembliesWithErrors : null,
782
+ partialCompileHint: "Unity compiles assemblies in dependency order; the reported errors " +
783
+ "may be a SUBSET — once these are fixed, dependent assemblies that " +
784
+ "were not reached compile next and may surface additional errors. " +
785
+ "Re-check read_compile_errors after each fix until errorCount is 0.",
786
+ }
787
+ : {}),
611
788
  // Package / assembly issues from the same log tail. Empty when the
612
789
  // only red flags are compiler errors (the common case).
613
790
  issues: health.issues,
614
791
  issueCount: health.issues.length,
615
792
  logPath,
793
+ // Why this log file was chosen (feedback-fable-31-07 §5). Non-default
794
+ // values signal a log-rotation fallback: the live editor is writing to
795
+ // Editor-prev.log after a batch spawn rotated Editor.log.
796
+ logSource: resolvedLog.reason,
616
797
  tailBytes: tail.bytes,
617
798
  // Present ONLY when at least one cited source file is newer than
618
799
  // Editor.log — the log's most-recent error block may be stale and
@@ -624,8 +805,78 @@ export class ToolRouter {
624
805
  staleLogNewerFiles: staleLog.newerFiles,
625
806
  }
626
807
  : {}),
808
+ // Present ONLY when at least one Assets/**/*.cs source is newer than
809
+ // the newest built DLL — the running assembly predates the latest
810
+ // source, so a no_errors_found signal cannot be trusted until the
811
+ // assembly is rebuilt (feedback-01-08-glm §5b).
812
+ ...(staleAsm.staleAssembly
813
+ ? {
814
+ staleAssembly: true,
815
+ staleAssemblyHint: staleAsm.hint,
816
+ staleAssemblyNewerSources: staleAsm.newerSources,
817
+ }
818
+ : {}),
819
+ // feedback-04-08-opus §2 — log authorship. Emitted whenever the header
820
+ // was parseable (the live editor's long tail usually has no header, so
821
+ // these stay absent in the common case). On a version mismatch the hint
822
+ // says the errors CANNOT all occur in the live editor; on a batch-mode
823
+ // log it reminds the agent the errors are from a headless run.
824
+ ...(authorship.logUnityVersion !== null || authorship.logBatchMode !== null
825
+ ? {
826
+ logUnityVersion: authorship.logUnityVersion,
827
+ liveUnityVersion: authorshipCheck.liveUnityVersion,
828
+ logBatchMode: authorship.logBatchMode,
829
+ logDate: authorship.logDate,
830
+ ...(authorshipCheck.hint
831
+ ? {
832
+ logAuthorshipMismatch: authorshipCheck.versionMismatch,
833
+ logAuthorshipHint: authorshipCheck.hint,
834
+ }
835
+ : {}),
836
+ }
837
+ : {}),
627
838
  }, "offline");
628
839
  }
840
+ // Resolve the live Unity Editor PID for this project from the instance lock,
841
+ // or undefined when no live editor is known. Used by routeReadCompileErrors
842
+ // to feed the log-rotation fallback in resolveEditorLogPath
843
+ // (feedback-fable-31-07 §5): a frozen Editor.log + a live PID is the
844
+ // signature of a batch spawn that rotated the log while the live editor
845
+ // keeps writing to Editor-prev.log.
846
+ resolveLiveEditorPid() {
847
+ if (!this.projectPath)
848
+ return undefined;
849
+ try {
850
+ const lock = readInstanceLock(this.projectPath);
851
+ if (lock && lock.pid && isPidAlive(lock.pid))
852
+ return lock.pid;
853
+ }
854
+ catch {
855
+ // Unreadable/missing lock → no live PID known.
856
+ }
857
+ return undefined;
858
+ }
859
+ // feedback-04-08-opus §2 — resolve the live bridge's Unity version from the
860
+ // instance lock, so read_compile_errors can cross-check the LOG's version
861
+ // header against the version of the editor that is actually running. A batch
862
+ // run of a NEWER Unity leaves a log whose errors cannot occur in the live
863
+ // editor; without this check the tool would surface them as current. Returns
864
+ // null when no live editor / no version recorded (the benign case — no
865
+ // version to compare against, so no false mismatch fires).
866
+ resolveLiveUnityVersion() {
867
+ if (!this.projectPath)
868
+ return null;
869
+ try {
870
+ const lock = readInstanceLock(this.projectPath);
871
+ if (lock && lock.pid && isPidAlive(lock.pid) && lock.unityVersion) {
872
+ return lock.unityVersion;
873
+ }
874
+ }
875
+ catch {
876
+ // Unreadable/missing lock → no live version known.
877
+ }
878
+ return null;
879
+ }
629
880
  async routeListAssets(args) {
630
881
  try {
631
882
  const result = await listAssetsOffline({
@@ -677,7 +928,13 @@ export class ToolRouter {
677
928
  const maxEvents = typeof args.max_events === "number" && args.max_events > 0
678
929
  ? Math.min(args.max_events, 1000)
679
930
  : 50;
680
- const result = this.eventStream.pull(maxEvents);
931
+ // feedback #8 — forward the caller-supplied subscriber id so each agent
932
+ // session gets its own read cursor (a fresh subscriber starts at the
933
+ // current high-water mark and does not replay the shared queue's backlog).
934
+ const subscriber = typeof args.subscriber === "string" && args.subscriber.length > 0
935
+ ? args.subscriber
936
+ : undefined;
937
+ const result = this.eventStream.pull(maxEvents, subscriber);
681
938
  return {
682
939
  content: [{ type: "text", text: JSON.stringify(result) }],
683
940
  isError: false,
@@ -1329,12 +1586,14 @@ export class ToolRouter {
1329
1586
  // fixable compile failure. We reuse the same offline log read +
1330
1587
  // health-issue extractor as read_compile_errors so the diagnosis is
1331
1588
  // identical to what the agent already saw.
1332
- const logPath = resolveEditorLogPath(this.projectPath);
1589
+ const logPath = resolveEditorLogPath(this.projectPath, undefined, this.resolveLiveEditorPid(), this.globalLogPathOverride).path;
1333
1590
  const tail = readLogTail(logPath, DEFAULT_LOG_TAIL_BYTES);
1334
1591
  let signaturePresent = false;
1592
+ let signatureSource = null;
1335
1593
  let logWarning = null;
1336
1594
  if (tail.error || !tail.exists) {
1337
- // Without the log we cannot confirm the signature — refuse for safety.
1595
+ // Without the log we cannot confirm the signature from the file. The
1596
+ // live-console fallback below may still recover it.
1338
1597
  logWarning = tail.error
1339
1598
  ? `Editor.log unreadable at ${logPath}: ${tail.error}`
1340
1599
  : `No Editor.log found at ${logPath}.`;
@@ -1342,7 +1601,10 @@ export class ToolRouter {
1342
1601
  else {
1343
1602
  const issues = extractProjectHealthIssues(tail.content);
1344
1603
  signaturePresent = issues.some((i) => i.kind === "editor_fd_exhaustion");
1345
- if (!signaturePresent) {
1604
+ if (signaturePresent) {
1605
+ signatureSource = "editor_log";
1606
+ }
1607
+ else {
1346
1608
  logWarning =
1347
1609
  "The editor_fd_exhaustion signature is NOT present in the recent " +
1348
1610
  "Editor.log tail. The Editor may be slow/stuck for a different " +
@@ -1351,34 +1613,87 @@ export class ToolRouter {
1351
1613
  "the diagnosis before killing the Editor.";
1352
1614
  }
1353
1615
  }
1354
- // 2. Resolve the live Unity PID via the process scan (same primitive
1355
- // bridge_status uses for cold-Safe-Mode detection). The bridge may
1356
- // already be down (it dies on fd-exhaustion), so we cannot rely on
1357
- // the instance lock's pid alone.
1616
+ // feedback-fable-04-08 §2b — live-console signature fallback. When the log
1617
+ // file did not carry the signature (it may be the wrong/stale log for the
1618
+ // running session, or the bridge is still up and the Bee exception is only
1619
+ // in the live console via LogEntries), probe the bridge's console read as a
1620
+ // secondary signature source. The fd-exhaustion exception is raised into
1621
+ // the live Unity console independently of whichever Editor.log file the
1622
+ // resolver picked, so this catches the case the file read missed. The
1623
+ // bridge is usually down by full fd-exhaustion, so this is opportunistic —
1624
+ // a failure here leaves the file-based verdict standing.
1625
+ if (!signaturePresent) {
1626
+ try {
1627
+ const liveAvailable = await live.isLiveAvailable();
1628
+ if (liveAvailable) {
1629
+ const consoleResult = await live.route("unity_senses_read_console", { max_entries: 200, type: "error", detail: "verbose" });
1630
+ const consoleBody = parseResultBody(consoleResult);
1631
+ const text = this.serializeConsoleEntries(consoleBody);
1632
+ if (text) {
1633
+ const liveIssues = extractProjectHealthIssues(text);
1634
+ if (liveIssues.some((i) => i.kind === "editor_fd_exhaustion")) {
1635
+ signaturePresent = true;
1636
+ signatureSource = "live_console";
1637
+ logWarning = null;
1638
+ }
1639
+ }
1640
+ }
1641
+ }
1642
+ catch {
1643
+ // Bridge unreachable (expected on fd-exhaustion). The file-based
1644
+ // verdict above is the authoritative fallback.
1645
+ }
1646
+ }
1647
+ // 2. Resolve the live Unity PID. feedback #2 (2026-08-06): the instance
1648
+ // lock's `pid` is authoritative — the bridge only writes it from the
1649
+ // main interactive editor (it early-returns in worker/batch processes),
1650
+ // while the process scan could return an AssetImportWorker child that
1651
+ // shares the project path. Prefer the lock PID when it is live; fall
1652
+ // back to the scan (which now also excludes AssetImportWorker children)
1653
+ // only when no live lock PID exists.
1358
1654
  let pid = null;
1359
- try {
1360
- const proc = findUnityForProject(this.projectPath);
1361
- pid = proc ? proc.pid : null;
1655
+ let pidSource = null;
1656
+ const lockPid = this.resolveLiveEditorPid();
1657
+ if (lockPid !== undefined) {
1658
+ pid = lockPid;
1659
+ pidSource = "instance_lock";
1362
1660
  }
1363
- catch {
1364
- pid = null;
1661
+ else {
1662
+ try {
1663
+ const proc = findUnityForProject(this.projectPath);
1664
+ if (proc) {
1665
+ pid = proc.pid;
1666
+ pidSource = "process_scan";
1667
+ }
1668
+ }
1669
+ catch {
1670
+ pid = null;
1671
+ }
1365
1672
  }
1366
1673
  // 3. Dry-run branch: confirm was false/absent. Return the diagnosis +
1367
1674
  // the PID the tool WOULD kill, with no side effect. Lets an agent
1368
- // preview before committing.
1675
+ // preview before committing. feedback #2: include the PID source and
1676
+ // the resolved process's command line so a misattribution (e.g. an
1677
+ // AssetImportWorker child) is visible before confirm.
1369
1678
  if (!confirm) {
1679
+ const processCommandLine = pid !== null ? readProcessCommandLine(pid) : null;
1370
1680
  return sourceResult({
1371
1681
  action: "restart_editor",
1372
1682
  confirm: false,
1373
1683
  dryRun: true,
1374
1684
  signaturePresent,
1685
+ ...(signatureSource !== null ? { signatureSource } : {}),
1375
1686
  ...(pid !== null ? { wouldKillPid: pid } : {}),
1687
+ ...(pidSource !== null ? { pidSource } : {}),
1688
+ ...(processCommandLine !== null ? { processCommandLine } : {}),
1376
1689
  ...(logWarning !== null ? { warning: logWarning } : {}),
1377
1690
  projectPath: this.projectPath,
1378
1691
  message: "Dry-run preview. Pass confirm: true to actually terminate the " +
1379
1692
  "hung Unity Editor. Only do so after read_compile_errors " +
1380
1693
  "confirms an editor_fd_exhaustion issue — killing the Editor " +
1381
- "can destroy unsaved scene work and in-flight asset imports.",
1694
+ "can destroy unsaved scene work and in-flight asset imports. " +
1695
+ "Verify processCommandLine targets the main editor, not an " +
1696
+ "AssetImportWorker child.",
1382
1697
  }, "local");
1383
1698
  }
1384
1699
  // 4. Confirmed-path refusals. Each guard surfaces a structured code so
@@ -1434,10 +1749,10 @@ export class ToolRouter {
1434
1749
  }
1435
1750
  // 6. Kill the Editor.
1436
1751
  const kill = await killEditorProcess(pid, graceMs);
1437
- return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning);
1752
+ return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource);
1438
1753
  }
1439
1754
  /** Build the confirmed-kill response from a {@link KillResult}. */
1440
- restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning) {
1755
+ restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource) {
1441
1756
  if (kill.terminated) {
1442
1757
  return sourceResult({
1443
1758
  action: "restart_editor",
@@ -1447,6 +1762,7 @@ export class ToolRouter {
1447
1762
  method: kill.method,
1448
1763
  elapsedMs: kill.elapsedMs,
1449
1764
  graceMs,
1765
+ ...(signatureSource !== null ? { signatureSource } : {}),
1450
1766
  ...(dirtyScenesWarning !== null
1451
1767
  ? {
1452
1768
  dirtyScenesWarning,
@@ -1491,6 +1807,33 @@ export class ToolRouter {
1491
1807
  ],
1492
1808
  }, "local", true);
1493
1809
  }
1810
+ /**
1811
+ * Flatten a unity_senses_read_console result body into a single string the
1812
+ * health-issue extractor can scan for the fd-exhaustion signature. The
1813
+ * console result is a JSON object with an `entries[]` array of
1814
+ * `{ message, stack, ... }`; we join message + stack so the Bee driver's
1815
+ * `Could not register to wait for file descriptor N` frame (which lives in
1816
+ * the stack trace) is reachable by {@link extractProjectHealthIssues}.
1817
+ * Returns "" when the body has no entries.
1818
+ */
1819
+ serializeConsoleEntries(body) {
1820
+ if (!body || typeof body !== "object")
1821
+ return "";
1822
+ const entries = body.entries;
1823
+ if (!Array.isArray(entries))
1824
+ return "";
1825
+ const parts = [];
1826
+ for (const ent of entries) {
1827
+ if (!ent || typeof ent !== "object")
1828
+ continue;
1829
+ const e = ent;
1830
+ if (typeof e.message === "string")
1831
+ parts.push(e.message);
1832
+ if (typeof e.stack === "string")
1833
+ parts.push(e.stack);
1834
+ }
1835
+ return parts.join("\n");
1836
+ }
1494
1837
  /**
1495
1838
  * T31.4 — sample the live Unity process's file-descriptor usage and report
1496
1839
  * headroom against Mono's ~1024 fd ceiling (the real trip point for the Bee
@@ -1504,20 +1847,27 @@ export class ToolRouter {
1504
1847
  * calls build a trend (monotonic climb = leak in progress). No disk cache.
1505
1848
  */
1506
1849
  async routeResourcePressure(args) {
1507
- // 1. Resolve the PID. An explicit pid arg wins; otherwise scan for the
1508
- // live Unity process for this project (same primitive bridge_status
1509
- // uses). The probe must NOT depend on the bridge.
1850
+ // 1. Resolve the PID. An explicit pid arg wins; otherwise prefer the
1851
+ // instance-lock PID (authoritative main editor — feedback #2) and fall
1852
+ // back to the process scan (which excludes AssetImportWorker children).
1853
+ // The probe must NOT depend on the bridge.
1510
1854
  let pid = null;
1511
1855
  if (typeof args.pid === "number" && Number.isInteger(args.pid) && args.pid > 0) {
1512
1856
  pid = args.pid;
1513
1857
  }
1514
1858
  else {
1515
- try {
1516
- const proc = findUnityForProject(this.projectPath);
1517
- pid = proc ? proc.pid : null;
1859
+ const lockPid = this.resolveLiveEditorPid();
1860
+ if (lockPid !== undefined) {
1861
+ pid = lockPid;
1518
1862
  }
1519
- catch {
1520
- pid = null;
1863
+ else {
1864
+ try {
1865
+ const proc = findUnityForProject(this.projectPath);
1866
+ pid = proc ? proc.pid : null;
1867
+ }
1868
+ catch {
1869
+ pid = null;
1870
+ }
1521
1871
  }
1522
1872
  }
1523
1873
  if (pid === null) {
@@ -1532,25 +1882,42 @@ export class ToolRouter {
1532
1882
  },
1533
1883
  }, "local", true);
1534
1884
  }
1535
- // 2. Probe + record the sample. A failed probe still records a sample
1885
+ // 2. Resolve the configured fd ceiling (default 1024, overridable per
1886
+ // project via `.unity-open-mcp/settings.json` for runtimes whose
1887
+ // internal limit differs from Mono's classic 1024). Read fresh —
1888
+ // resource_pressure is called rarely, and this lets an operator change
1889
+ // the setting without restarting the MCP server.
1890
+ const { ceiling, source: ceilingSource } = readFdCeiling(this.projectPath);
1891
+ // 3. Probe + record the sample. A failed probe still records a sample
1536
1892
  // (count: null) so the trend detector sees the gap and does not
1537
1893
  // interpolate across it.
1538
1894
  const probe = countFileDescriptors(pid);
1539
1895
  const count = probe.count;
1540
- const headroom = computeFdHeadroom(count, "approximate" in probe ? probe.approximate : false);
1896
+ const approximate = "approximate" in probe ? probe.approximate : false;
1897
+ const headroom = computeFdHeadroom(count, approximate, ceiling);
1541
1898
  const ts = Date.now();
1542
1899
  this.sessionState.recordFdSample({ ts, pid, count });
1543
1900
  const samples = this.sessionState.fdSamplesSnapshot();
1544
- const trend = analyzeFdTrend(samples);
1545
- // 3. Build the response. The launchContextCaveat is always present so an
1546
- // agent (and the operator) understands the Mono-ceiling-vs-OS-soft-
1547
- // limit nuance. warningState is non-null only when there is something
1548
- // to surface (warn/critical/leaking); ok+stable stays silent.
1901
+ const trend = analyzeFdTrend(samples, ceiling);
1902
+ // 4. Decide whether to raise an operator warning. For an fd-heavy project
1903
+ // the OS-level count legitimately sits OVER the Mono-ceiling proxy on a
1904
+ // fresh healthy process (lsof counts mmap'd assets / file watchers /
1905
+ // FileStream handles that Mono's IOSelector never registers), so an
1906
+ // absolute `over_ceiling` state is NOT an alarm by itself. Alarm only on:
1907
+ // - a monotonic leak (the actionable trend signal), or
1908
+ // - approaching the ceiling from below (warn/critical).
1909
+ // A merely `rising` or stable/no-history over-ceiling process gets a
1910
+ // `pressureNote` instead. `rising` is any net-positive delta that is NOT a
1911
+ // qualifying leak — it fires on ordinary drift, so it must NOT alarm on its
1912
+ // own (the prior over_ceiling+rising alarm re-cried wolf on every sample
1913
+ // after the first).
1549
1914
  const fdMethod = probe.method;
1550
- const approximate = "approximate" in probe ? probe.approximate : false;
1551
1915
  const probeReason = "reason" in probe ? probe.reason : null;
1552
1916
  const probeMessage = "message" in probe ? probe.message : null;
1553
- const warningState = headroom.state === "warn" || headroom.state === "critical" || trend.state === "leaking"
1917
+ const shouldWarn = trend.state === "leaking" ||
1918
+ headroom.state === "warn" ||
1919
+ headroom.state === "critical";
1920
+ const warningState = shouldWarn
1554
1921
  ? {
1555
1922
  level: headroom.state === "critical"
1556
1923
  ? "critical"
@@ -1559,26 +1926,44 @@ export class ToolRouter {
1559
1926
  : "warn",
1560
1927
  message: headroom.state === "critical"
1561
1928
  ? `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1562
- `Mono's ${FD_CEILING}-descriptor ceiling — the next domain reload is ` +
1929
+ `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
1563
1930
  `likely to trip the Bee build-driver hang. Save scene work and restart ` +
1564
1931
  `Unity via the Hub now, before the Editor hangs.`
1565
1932
  : trend.state === "leaking"
1566
- ? `Editor fd usage is climbing monotonically across samples (leak in ` +
1567
- `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
1568
- `sample(s). Save scene work and plan a restart before the count ` +
1569
- `crosses the ${FD_CEILING}-descriptor ceiling.`
1933
+ ? headroom.state === "over_ceiling"
1934
+ ? `Editor fd usage (${count}) is ABOVE the ${ceiling}-descriptor ` +
1935
+ `ceiling and climbing monotonically (leak in progress): trend ` +
1936
+ `delta ${trend.delta} over ${trend.sampleCount} sample(s). The ` +
1937
+ `count has already crossed the ceiling proxy — save scene work ` +
1938
+ `and restart Unity via the Hub now.`
1939
+ : `Editor fd usage is climbing monotonically across samples (leak in ` +
1940
+ `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
1941
+ `sample(s). Save scene work and plan a restart before the count ` +
1942
+ `crosses the ${ceiling}-descriptor ceiling.`
1570
1943
  : `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1571
- `Mono's ${FD_CEILING}-descriptor ceiling. Monitor the trend; if it ` +
1572
- `keeps climbing across domain reloads, save scene work and restart ` +
1573
- `Unity via the Hub before the Editor hangs.`,
1944
+ `the ${ceiling}-descriptor ceiling. Monitor the trend; if it keeps ` +
1945
+ `climbing across domain reloads, save scene work and restart Unity ` +
1946
+ `via the Hub before the Editor hangs.`,
1574
1947
  }
1575
1948
  : null;
1949
+ // Informational (non-alarming) note for the over-ceiling-but-stable case.
1950
+ const pressureNote = !shouldWarn && headroom.state === "over_ceiling"
1951
+ ? `OS-level fd count (${count}) is above the ${ceiling}-descriptor ceiling ` +
1952
+ `proxy, but lsof / HandleCount count ALL OS file descriptors (mmap'd ` +
1953
+ `assets, file watchers, regular FileStream handles) while only ` +
1954
+ `Mono-IOSelector-registered descriptors (sockets/pipes under async IO) ` +
1955
+ `count toward the real trip point. For an asset-heavy project this is ` +
1956
+ `expected on a healthy process. Treat this as a concern ONLY if the trend ` +
1957
+ `becomes a monotonic leak (leaking) — re-sample after the next domain ` +
1958
+ `reload to establish one.`
1959
+ : null;
1576
1960
  return sourceResult({
1577
1961
  pid,
1578
1962
  fdCount: count,
1579
1963
  fdMethod,
1580
1964
  approximate,
1581
- ceiling: FD_CEILING,
1965
+ ceiling,
1966
+ ceilingSource,
1582
1967
  headroom: headroom.headroom,
1583
1968
  pressureRatio: headroom.pressureRatio,
1584
1969
  state: headroom.state,
@@ -1609,6 +1994,7 @@ export class ToolRouter {
1609
1994
  ],
1610
1995
  }
1611
1996
  : {}),
1997
+ ...(pressureNote !== null ? { pressureNote } : {}),
1612
1998
  }, "local");
1613
1999
  }
1614
2000
  // ── M26 Plan 2 — Unity Hub control (local-routed) ────────────────
@@ -1899,6 +2285,9 @@ export class ToolRouter {
1899
2285
  routeMeta = { route: "batch", fallbackReason: "live_unavailable" };
1900
2286
  }
1901
2287
  }
2288
+ // feedback-fable-04-08 §7 — rewrite the generic live_unavailable to
2289
+ // editor_busy when the batch hit a live editor's project lock.
2290
+ routeMeta = this.rewriteEditorBusy(raw, routeMeta);
1902
2291
  const body = parseResultBody(raw);
1903
2292
  if (body === null) {
1904
2293
  // Body not parseable — fall back to the legacy injectRouteMeta path so
@@ -1954,7 +2343,14 @@ export class ToolRouter {
1954
2343
  }
1955
2344
  else {
1956
2345
  raw = await this.batch.route("unity_open_mcp_scene_get_data", bridgeArgs);
1957
- raw = injectRouteMeta(raw, { route: "batch", fallbackReason: "live_unavailable" });
2346
+ // feedback-fable-04-08 §7 — rewrite to editor_busy when the batch hit
2347
+ // a live editor's project lock. Compute the meta from the raw result
2348
+ // (which carries the editor_instance_locked error) before stamping.
2349
+ const meta = this.rewriteEditorBusy(raw, {
2350
+ route: "batch",
2351
+ fallbackReason: "live_unavailable",
2352
+ });
2353
+ raw = injectRouteMeta(raw, meta);
1958
2354
  }
1959
2355
  }
1960
2356
  if (!wantPaging)
@@ -2005,12 +2401,25 @@ function pageSceneNodes(body, pageSize, cursor) {
2005
2401
  void roots;
2006
2402
  void rootGameObjects;
2007
2403
  void nodes;
2008
- void truncated;
2404
+ // `truncated` is read at the source-truncated carry-through below, so it is
2405
+ // not voided with the other intentionally-discarded scene fields.
2009
2406
  void moreHidden;
2010
2407
  void _existing;
2011
2408
  // Pull the top-level meta (everything except `scene`, which we rebuild).
2012
- const { scene: _s, ...topMeta } = body;
2409
+ // B-N21 — strip the structural fields from the TOP level too. For a
2410
+ // pre-unwrapped body (roots/rootGameObjects at the top level, no `scene`
2411
+ // wrapper — the fallback flattenSceneBody handles), `scene` above resolves
2412
+ // to {} and strips nothing, so spreading the raw remainder of `body` used
2413
+ // to carry the ENTIRE unpaged roots array into the response alongside the
2414
+ // page — paging never bounded the payload for that shape. flattenSceneBody
2415
+ // already consumed those fields; drop them wherever they live.
2416
+ const { scene: _s, roots: topRoots, rootGameObjects: topRootGameObjects, nodes: topNodes, truncated: topTruncated, moreHidden: topMoreHidden, pagination: _topExisting, ...topMeta } = body;
2013
2417
  void _s;
2418
+ void topRoots;
2419
+ void topRootGameObjects;
2420
+ void topNodes;
2421
+ void topMoreHidden;
2422
+ void _topExisting;
2014
2423
  const result = {
2015
2424
  ...topMeta,
2016
2425
  status: body.status ?? "ok",
@@ -2023,8 +2432,10 @@ function pageSceneNodes(body, pageSize, cursor) {
2023
2432
  // Carry the bridge's source-side truncation count through to the page so an
2024
2433
  // agent can distinguish "more pages" (pagination.truncated) from "the bridge
2025
2434
  // capped the hierarchy" (scene.truncated). Both can be non-zero at once.
2026
- if (typeof truncated === "number" && truncated > 0) {
2027
- result.scene.truncated = truncated;
2435
+ // For a pre-unwrapped body the count lives at the top level (B-N21).
2436
+ const sourceTruncated = typeof truncated === "number" ? truncated : topTruncated;
2437
+ if (typeof sourceTruncated === "number" && sourceTruncated > 0) {
2438
+ result.scene.truncated = sourceTruncated;
2028
2439
  }
2029
2440
  return attachPagination(result, block);
2030
2441
  }