unity-open-mcp 0.9.0 → 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 (89) 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 +78 -1
  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/rule-catalog.js +7 -1
  10. package/dist/capabilities/rule-catalog.js.map +1 -1
  11. package/dist/capabilities/tool-groups.js +37 -2
  12. package/dist/capabilities/tool-groups.js.map +1 -1
  13. package/dist/constants.d.ts +21 -1
  14. package/dist/constants.js +21 -1
  15. package/dist/constants.js.map +1 -1
  16. package/dist/event-stream.d.ts +13 -3
  17. package/dist/event-stream.js +77 -12
  18. package/dist/event-stream.js.map +1 -1
  19. package/dist/live-client.d.ts +17 -0
  20. package/dist/live-client.js +122 -5
  21. package/dist/live-client.js.map +1 -1
  22. package/dist/offline/api.js +82 -30
  23. package/dist/offline/api.js.map +1 -1
  24. package/dist/offline/index-builders.d.ts +9 -0
  25. package/dist/offline/index-builders.js +58 -8
  26. package/dist/offline/index-builders.js.map +1 -1
  27. package/dist/offline/parse.js +5 -1
  28. package/dist/offline/parse.js.map +1 -1
  29. package/dist/process-diagnostics.d.ts +100 -19
  30. package/dist/process-diagnostics.js +167 -59
  31. package/dist/process-diagnostics.js.map +1 -1
  32. package/dist/project-settings.d.ts +36 -0
  33. package/dist/project-settings.js +96 -0
  34. package/dist/project-settings.js.map +1 -0
  35. package/dist/running-unity.d.ts +23 -0
  36. package/dist/running-unity.js +60 -0
  37. package/dist/running-unity.js.map +1 -1
  38. package/dist/stale-assembly-cache.d.ts +40 -0
  39. package/dist/stale-assembly-cache.js +76 -0
  40. package/dist/stale-assembly-cache.js.map +1 -0
  41. package/dist/tool-router.d.ts +39 -1
  42. package/dist/tool-router.js +397 -62
  43. package/dist/tool-router.js.map +1 -1
  44. package/dist/tools/capture-inline.js +4 -3
  45. package/dist/tools/capture-inline.js.map +1 -1
  46. package/dist/tools/editor-status.js +7 -2
  47. package/dist/tools/editor-status.js.map +1 -1
  48. package/dist/tools/execute-csharp.js +25 -2
  49. package/dist/tools/execute-csharp.js.map +1 -1
  50. package/dist/tools/index.d.ts +1 -0
  51. package/dist/tools/index.js +34 -0
  52. package/dist/tools/index.js.map +1 -1
  53. package/dist/tools/inputsim-key.d.ts +40 -0
  54. package/dist/tools/inputsim-key.js +72 -0
  55. package/dist/tools/inputsim-key.js.map +1 -0
  56. package/dist/tools/inputsim-pointer.d.ts +40 -0
  57. package/dist/tools/inputsim-pointer.js +121 -0
  58. package/dist/tools/inputsim-pointer.js.map +1 -0
  59. package/dist/tools/inputsim-pointer3d.d.ts +40 -0
  60. package/dist/tools/inputsim-pointer3d.js +55 -0
  61. package/dist/tools/inputsim-pointer3d.js.map +1 -0
  62. package/dist/tools/inputsim-probe.d.ts +40 -0
  63. package/dist/tools/inputsim-probe.js +43 -0
  64. package/dist/tools/inputsim-probe.js.map +1 -0
  65. package/dist/tools/inputsim-step.d.ts +40 -0
  66. package/dist/tools/inputsim-step.js +50 -0
  67. package/dist/tools/inputsim-step.js.map +1 -0
  68. package/dist/tools/inputsim-touch.d.ts +40 -0
  69. package/dist/tools/inputsim-touch.js +83 -0
  70. package/dist/tools/inputsim-touch.js.map +1 -0
  71. package/dist/tools/memory-snapshot-capture.js +11 -5
  72. package/dist/tools/memory-snapshot-capture.js.map +1 -1
  73. package/dist/tools/pull-events.js +18 -10
  74. package/dist/tools/pull-events.js.map +1 -1
  75. package/dist/tools/read-compile-errors.js +10 -2
  76. package/dist/tools/read-compile-errors.js.map +1 -1
  77. package/dist/tools/resource-pressure.js +21 -10
  78. package/dist/tools/resource-pressure.js.map +1 -1
  79. package/dist/tools/screenshot-window.js +18 -12
  80. package/dist/tools/screenshot-window.js.map +1 -1
  81. package/dist/tools/screenshot.js +13 -6
  82. package/dist/tools/screenshot.js.map +1 -1
  83. package/dist/tools/visual-compare.d.ts +40 -0
  84. package/dist/tools/visual-compare.js +98 -0
  85. package/dist/tools/visual-compare.js.map +1 -0
  86. package/dist/unity-log.d.ts +51 -2
  87. package/dist/unity-log.js +152 -5
  88. package/dist/unity-log.js.map +1 -1
  89. 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, detectStaleAssembly } 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 = {
@@ -388,6 +390,7 @@ export class ToolRouter {
388
390
  sessionState;
389
391
  onToolListChanged;
390
392
  hubBackend;
393
+ globalLogPathOverride;
391
394
  modelCache = new AssetModelCache();
392
395
  /**
393
396
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -444,7 +447,15 @@ export class ToolRouter {
444
447
  // M26 Plan 2 — injectable Hub-control backend. Omit in production (the
445
448
  // default calls the real hub-control functions); tests pass a fake to
446
449
  // avoid real subprocess + network side effects.
447
- 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) {
448
459
  this.live = live;
449
460
  this.batch = batch;
450
461
  this.projectPath = projectPath;
@@ -452,6 +463,7 @@ export class ToolRouter {
452
463
  this.sessionState = sessionState;
453
464
  this.onToolListChanged = onToolListChanged;
454
465
  this.hubBackend = hubBackend;
466
+ this.globalLogPathOverride = globalLogPathOverride;
455
467
  }
456
468
  async route(toolName, args) {
457
469
  return this.routeCore(this.live, toolName, args);
@@ -528,10 +540,13 @@ export class ToolRouter {
528
540
  return this.batchPingResult();
529
541
  }
530
542
  const result = await this.batch.route(toolName, args);
531
- 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, {
532
546
  route: "batch",
533
547
  fallbackReason: "live_unavailable",
534
548
  });
549
+ return injectRouteMeta(result, meta);
535
550
  }
536
551
  batchPingResult() {
537
552
  return {
@@ -556,6 +571,52 @@ export class ToolRouter {
556
571
  isError: false,
557
572
  };
558
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
+ }
559
620
  async routeReadCompileErrors(args) {
560
621
  const tailBytes = typeof args.tail_bytes === "number" && args.tail_bytes >= 4096
561
622
  ? Math.min(Math.floor(args.tail_bytes), 1048576)
@@ -571,7 +632,7 @@ export class ToolRouter {
571
632
  // editor keeps writing. Pass the live PID from the instance lock so the
572
633
  // resolver can make the strong-signal decision.
573
634
  const livePid = this.resolveLiveEditorPid();
574
- const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid);
635
+ const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid, this.globalLogPathOverride);
575
636
  const logPath = resolvedLog.path;
576
637
  const tail = readLogTail(logPath, tailBytes);
577
638
  if (tail.error) {
@@ -617,11 +678,6 @@ export class ToolRouter {
617
678
  // backward compatibility) so existing callers do not break.
618
679
  const health = summarizeProjectHealth(tail.content);
619
680
  const errors = health.compilerErrors;
620
- const status = health.unhealthy
621
- ? errors.length > 0
622
- ? "compile_failed"
623
- : "project_unhealthy"
624
- : "no_errors_found";
625
681
  // specs/feedback.md 2026-07-05 — stale-log detection. When an assembly is
626
682
  // stuck in a failed-compile state, AssetDatabase.Refresh no-ops and the
627
683
  // log's most-recent CSxxxx block can reference on-disk source that has
@@ -644,12 +700,91 @@ export class ToolRouter {
644
700
  // newest Library/ScriptAssemblies/*.dll mtime against Assets/**/*.cs source
645
701
  // mtimes and surface staleAssembly when any source is newer than the DLL.
646
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;
647
752
  return sourceResult({
648
753
  status,
649
- unhealthy: health.unhealthy,
650
- 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 } : {}),
651
769
  errorCount: errors.length,
652
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
+ : {}),
653
788
  // Package / assembly issues from the same log tail. Empty when the
654
789
  // only red flags are compiler errors (the common case).
655
790
  issues: health.issues,
@@ -681,6 +816,25 @@ export class ToolRouter {
681
816
  staleAssemblyNewerSources: staleAsm.newerSources,
682
817
  }
683
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
+ : {}),
684
838
  }, "offline");
685
839
  }
686
840
  // Resolve the live Unity Editor PID for this project from the instance lock,
@@ -702,6 +856,27 @@ export class ToolRouter {
702
856
  }
703
857
  return undefined;
704
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
+ }
705
880
  async routeListAssets(args) {
706
881
  try {
707
882
  const result = await listAssetsOffline({
@@ -753,7 +928,13 @@ export class ToolRouter {
753
928
  const maxEvents = typeof args.max_events === "number" && args.max_events > 0
754
929
  ? Math.min(args.max_events, 1000)
755
930
  : 50;
756
- 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);
757
938
  return {
758
939
  content: [{ type: "text", text: JSON.stringify(result) }],
759
940
  isError: false,
@@ -1405,12 +1586,14 @@ export class ToolRouter {
1405
1586
  // fixable compile failure. We reuse the same offline log read +
1406
1587
  // health-issue extractor as read_compile_errors so the diagnosis is
1407
1588
  // identical to what the agent already saw.
1408
- const logPath = resolveEditorLogPath(this.projectPath, undefined, this.resolveLiveEditorPid()).path;
1589
+ const logPath = resolveEditorLogPath(this.projectPath, undefined, this.resolveLiveEditorPid(), this.globalLogPathOverride).path;
1409
1590
  const tail = readLogTail(logPath, DEFAULT_LOG_TAIL_BYTES);
1410
1591
  let signaturePresent = false;
1592
+ let signatureSource = null;
1411
1593
  let logWarning = null;
1412
1594
  if (tail.error || !tail.exists) {
1413
- // 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.
1414
1597
  logWarning = tail.error
1415
1598
  ? `Editor.log unreadable at ${logPath}: ${tail.error}`
1416
1599
  : `No Editor.log found at ${logPath}.`;
@@ -1418,7 +1601,10 @@ export class ToolRouter {
1418
1601
  else {
1419
1602
  const issues = extractProjectHealthIssues(tail.content);
1420
1603
  signaturePresent = issues.some((i) => i.kind === "editor_fd_exhaustion");
1421
- if (!signaturePresent) {
1604
+ if (signaturePresent) {
1605
+ signatureSource = "editor_log";
1606
+ }
1607
+ else {
1422
1608
  logWarning =
1423
1609
  "The editor_fd_exhaustion signature is NOT present in the recent " +
1424
1610
  "Editor.log tail. The Editor may be slow/stuck for a different " +
@@ -1427,34 +1613,87 @@ export class ToolRouter {
1427
1613
  "the diagnosis before killing the Editor.";
1428
1614
  }
1429
1615
  }
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.
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.
1434
1654
  let pid = null;
1435
- try {
1436
- const proc = findUnityForProject(this.projectPath);
1437
- 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";
1438
1660
  }
1439
- catch {
1440
- 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
+ }
1441
1672
  }
1442
1673
  // 3. Dry-run branch: confirm was false/absent. Return the diagnosis +
1443
1674
  // the PID the tool WOULD kill, with no side effect. Lets an agent
1444
- // 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.
1445
1678
  if (!confirm) {
1679
+ const processCommandLine = pid !== null ? readProcessCommandLine(pid) : null;
1446
1680
  return sourceResult({
1447
1681
  action: "restart_editor",
1448
1682
  confirm: false,
1449
1683
  dryRun: true,
1450
1684
  signaturePresent,
1685
+ ...(signatureSource !== null ? { signatureSource } : {}),
1451
1686
  ...(pid !== null ? { wouldKillPid: pid } : {}),
1687
+ ...(pidSource !== null ? { pidSource } : {}),
1688
+ ...(processCommandLine !== null ? { processCommandLine } : {}),
1452
1689
  ...(logWarning !== null ? { warning: logWarning } : {}),
1453
1690
  projectPath: this.projectPath,
1454
1691
  message: "Dry-run preview. Pass confirm: true to actually terminate the " +
1455
1692
  "hung Unity Editor. Only do so after read_compile_errors " +
1456
1693
  "confirms an editor_fd_exhaustion issue — killing the Editor " +
1457
- "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.",
1458
1697
  }, "local");
1459
1698
  }
1460
1699
  // 4. Confirmed-path refusals. Each guard surfaces a structured code so
@@ -1510,10 +1749,10 @@ export class ToolRouter {
1510
1749
  }
1511
1750
  // 6. Kill the Editor.
1512
1751
  const kill = await killEditorProcess(pid, graceMs);
1513
- return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning);
1752
+ return this.restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource);
1514
1753
  }
1515
1754
  /** Build the confirmed-kill response from a {@link KillResult}. */
1516
- restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning) {
1755
+ restartEditorKillResponse(pid, graceMs, kill, dirtyScenesWarning, signatureSource) {
1517
1756
  if (kill.terminated) {
1518
1757
  return sourceResult({
1519
1758
  action: "restart_editor",
@@ -1523,6 +1762,7 @@ export class ToolRouter {
1523
1762
  method: kill.method,
1524
1763
  elapsedMs: kill.elapsedMs,
1525
1764
  graceMs,
1765
+ ...(signatureSource !== null ? { signatureSource } : {}),
1526
1766
  ...(dirtyScenesWarning !== null
1527
1767
  ? {
1528
1768
  dirtyScenesWarning,
@@ -1567,6 +1807,33 @@ export class ToolRouter {
1567
1807
  ],
1568
1808
  }, "local", true);
1569
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
+ }
1570
1837
  /**
1571
1838
  * T31.4 — sample the live Unity process's file-descriptor usage and report
1572
1839
  * headroom against Mono's ~1024 fd ceiling (the real trip point for the Bee
@@ -1580,20 +1847,27 @@ export class ToolRouter {
1580
1847
  * calls build a trend (monotonic climb = leak in progress). No disk cache.
1581
1848
  */
1582
1849
  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.
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.
1586
1854
  let pid = null;
1587
1855
  if (typeof args.pid === "number" && Number.isInteger(args.pid) && args.pid > 0) {
1588
1856
  pid = args.pid;
1589
1857
  }
1590
1858
  else {
1591
- try {
1592
- const proc = findUnityForProject(this.projectPath);
1593
- pid = proc ? proc.pid : null;
1859
+ const lockPid = this.resolveLiveEditorPid();
1860
+ if (lockPid !== undefined) {
1861
+ pid = lockPid;
1594
1862
  }
1595
- catch {
1596
- 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
+ }
1597
1871
  }
1598
1872
  }
1599
1873
  if (pid === null) {
@@ -1608,25 +1882,42 @@ export class ToolRouter {
1608
1882
  },
1609
1883
  }, "local", true);
1610
1884
  }
1611
- // 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
1612
1892
  // (count: null) so the trend detector sees the gap and does not
1613
1893
  // interpolate across it.
1614
1894
  const probe = countFileDescriptors(pid);
1615
1895
  const count = probe.count;
1616
- 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);
1617
1898
  const ts = Date.now();
1618
1899
  this.sessionState.recordFdSample({ ts, pid, count });
1619
1900
  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.
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).
1625
1914
  const fdMethod = probe.method;
1626
- const approximate = "approximate" in probe ? probe.approximate : false;
1627
1915
  const probeReason = "reason" in probe ? probe.reason : null;
1628
1916
  const probeMessage = "message" in probe ? probe.message : null;
1629
- 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
1630
1921
  ? {
1631
1922
  level: headroom.state === "critical"
1632
1923
  ? "critical"
@@ -1635,26 +1926,44 @@ export class ToolRouter {
1635
1926
  : "warn",
1636
1927
  message: headroom.state === "critical"
1637
1928
  ? `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1638
- `Mono's ${FD_CEILING}-descriptor ceiling — the next domain reload is ` +
1929
+ `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
1639
1930
  `likely to trip the Bee build-driver hang. Save scene work and restart ` +
1640
1931
  `Unity via the Hub now, before the Editor hangs.`
1641
1932
  : 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.`
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.`
1646
1943
  : `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.`,
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.`,
1650
1947
  }
1651
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;
1652
1960
  return sourceResult({
1653
1961
  pid,
1654
1962
  fdCount: count,
1655
1963
  fdMethod,
1656
1964
  approximate,
1657
- ceiling: FD_CEILING,
1965
+ ceiling,
1966
+ ceilingSource,
1658
1967
  headroom: headroom.headroom,
1659
1968
  pressureRatio: headroom.pressureRatio,
1660
1969
  state: headroom.state,
@@ -1685,6 +1994,7 @@ export class ToolRouter {
1685
1994
  ],
1686
1995
  }
1687
1996
  : {}),
1997
+ ...(pressureNote !== null ? { pressureNote } : {}),
1688
1998
  }, "local");
1689
1999
  }
1690
2000
  // ── M26 Plan 2 — Unity Hub control (local-routed) ────────────────
@@ -1975,6 +2285,9 @@ export class ToolRouter {
1975
2285
  routeMeta = { route: "batch", fallbackReason: "live_unavailable" };
1976
2286
  }
1977
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);
1978
2291
  const body = parseResultBody(raw);
1979
2292
  if (body === null) {
1980
2293
  // Body not parseable — fall back to the legacy injectRouteMeta path so
@@ -2030,7 +2343,14 @@ export class ToolRouter {
2030
2343
  }
2031
2344
  else {
2032
2345
  raw = await this.batch.route("unity_open_mcp_scene_get_data", bridgeArgs);
2033
- 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);
2034
2354
  }
2035
2355
  }
2036
2356
  if (!wantPaging)
@@ -2081,12 +2401,25 @@ function pageSceneNodes(body, pageSize, cursor) {
2081
2401
  void roots;
2082
2402
  void rootGameObjects;
2083
2403
  void nodes;
2084
- 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.
2085
2406
  void moreHidden;
2086
2407
  void _existing;
2087
2408
  // Pull the top-level meta (everything except `scene`, which we rebuild).
2088
- 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;
2089
2417
  void _s;
2418
+ void topRoots;
2419
+ void topRootGameObjects;
2420
+ void topNodes;
2421
+ void topMoreHidden;
2422
+ void _topExisting;
2090
2423
  const result = {
2091
2424
  ...topMeta,
2092
2425
  status: body.status ?? "ok",
@@ -2099,8 +2432,10 @@ function pageSceneNodes(body, pageSize, cursor) {
2099
2432
  // Carry the bridge's source-side truncation count through to the page so an
2100
2433
  // agent can distinguish "more pages" (pagination.truncated) from "the bridge
2101
2434
  // capped the hierarchy" (scene.truncated). Both can be non-zero at once.
2102
- if (typeof truncated === "number" && truncated > 0) {
2103
- 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;
2104
2439
  }
2105
2440
  return attachPagination(result, block);
2106
2441
  }