unity-open-mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +33 -2
  2. package/dist/agent-identity.d.ts +12 -0
  3. package/dist/agent-identity.js +2 -0
  4. package/dist/agent-identity.js.map +1 -1
  5. package/dist/batch-spawn.d.ts +39 -0
  6. package/dist/batch-spawn.js +287 -69
  7. package/dist/batch-spawn.js.map +1 -1
  8. package/dist/capabilities/build-capabilities.js +2 -0
  9. package/dist/capabilities/build-capabilities.js.map +1 -1
  10. package/dist/capabilities/discovery.d.ts +51 -0
  11. package/dist/capabilities/discovery.js +87 -0
  12. package/dist/capabilities/discovery.js.map +1 -0
  13. package/dist/capabilities/lifecycle.d.ts +6 -0
  14. package/dist/capabilities/lifecycle.js +17 -1
  15. package/dist/capabilities/lifecycle.js.map +1 -1
  16. package/dist/capabilities/tool-groups.js +1 -0
  17. package/dist/capabilities/tool-groups.js.map +1 -1
  18. package/dist/child-supervision.d.ts +6 -0
  19. package/dist/child-supervision.js +12 -0
  20. package/dist/child-supervision.js.map +1 -1
  21. package/dist/cli/args.d.ts +24 -2
  22. package/dist/cli/args.js +97 -2
  23. package/dist/cli/args.js.map +1 -1
  24. package/dist/cli/cli.js +42 -3
  25. package/dist/cli/cli.js.map +1 -1
  26. package/dist/cli/help-text.d.ts +1 -0
  27. package/dist/cli/help-text.js +71 -3
  28. package/dist/cli/help-text.js.map +1 -1
  29. package/dist/cli/json-guards.d.ts +2 -0
  30. package/dist/cli/json-guards.js +5 -0
  31. package/dist/cli/json-guards.js.map +1 -0
  32. package/dist/cli/setup-command.d.ts +88 -0
  33. package/dist/cli/setup-command.js +444 -0
  34. package/dist/cli/setup-command.js.map +1 -0
  35. package/dist/cli/update-command.d.ts +30 -0
  36. package/dist/cli/update-command.js +271 -0
  37. package/dist/cli/update-command.js.map +1 -0
  38. package/dist/compile-verify.d.ts +3 -0
  39. package/dist/compile-verify.js +4 -0
  40. package/dist/compile-verify.js.map +1 -1
  41. package/dist/constants.d.ts +10 -2
  42. package/dist/constants.js +10 -2
  43. package/dist/constants.js.map +1 -1
  44. package/dist/dialog-dismiss.d.ts +108 -12
  45. package/dist/dialog-dismiss.js +294 -109
  46. package/dist/dialog-dismiss.js.map +1 -1
  47. package/dist/dialog-policy.d.ts +55 -9
  48. package/dist/dialog-policy.js +85 -15
  49. package/dist/dialog-policy.js.map +1 -1
  50. package/dist/editor-search.d.ts +2 -0
  51. package/dist/editor-search.js +8 -0
  52. package/dist/editor-search.js.map +1 -0
  53. package/dist/gate-error.d.ts +3 -0
  54. package/dist/gate-error.js.map +1 -1
  55. package/dist/index.js +55 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/instance-discovery.d.ts +16 -0
  58. package/dist/instance-discovery.js +45 -0
  59. package/dist/instance-discovery.js.map +1 -1
  60. package/dist/jobs/adapters.d.ts +15 -0
  61. package/dist/jobs/adapters.js +147 -0
  62. package/dist/jobs/adapters.js.map +1 -0
  63. package/dist/jobs/handler.d.ts +3 -0
  64. package/dist/jobs/handler.js +29 -0
  65. package/dist/jobs/handler.js.map +1 -0
  66. package/dist/jobs/job-manager.d.ts +123 -0
  67. package/dist/jobs/job-manager.js +290 -0
  68. package/dist/jobs/job-manager.js.map +1 -0
  69. package/dist/live-client.d.ts +50 -2
  70. package/dist/live-client.js +221 -26
  71. package/dist/live-client.js.map +1 -1
  72. package/dist/local-tools.d.ts +17 -0
  73. package/dist/local-tools.js +47 -0
  74. package/dist/local-tools.js.map +1 -0
  75. package/dist/process-diagnostics.d.ts +47 -20
  76. package/dist/process-diagnostics.js +30 -15
  77. package/dist/process-diagnostics.js.map +1 -1
  78. package/dist/project-path.d.ts +86 -0
  79. package/dist/project-path.js +159 -0
  80. package/dist/project-path.js.map +1 -0
  81. package/dist/release-lookup.d.ts +32 -0
  82. package/dist/release-lookup.js +147 -0
  83. package/dist/release-lookup.js.map +1 -0
  84. package/dist/routers.d.ts +17 -3
  85. package/dist/routers.js +28 -8
  86. package/dist/routers.js.map +1 -1
  87. package/dist/running-unity.d.ts +21 -0
  88. package/dist/running-unity.js +50 -0
  89. package/dist/running-unity.js.map +1 -1
  90. package/dist/schema-defaults.d.ts +4 -3
  91. package/dist/schema-defaults.js +20 -6
  92. package/dist/schema-defaults.js.map +1 -1
  93. package/dist/server-instructions.js +7 -1
  94. package/dist/server-instructions.js.map +1 -1
  95. package/dist/server.d.ts +10 -3
  96. package/dist/server.js +24 -25
  97. package/dist/server.js.map +1 -1
  98. package/dist/setup/portable-config.d.ts +60 -0
  99. package/dist/setup/portable-config.js +249 -0
  100. package/dist/setup/portable-config.js.map +1 -0
  101. package/dist/skill/SKILL.md +68 -0
  102. package/dist/skill/copy-references.d.ts +2 -0
  103. package/dist/skill/copy-references.js +16 -0
  104. package/dist/skill/copy-references.js.map +1 -0
  105. package/dist/skill/generate-skill.js +5 -3
  106. package/dist/skill/generate-skill.js.map +1 -1
  107. package/dist/skill/references/batch-and-gates.md +142 -0
  108. package/dist/skill/references/compile-and-safe-mode.md +163 -0
  109. package/dist/skill/references/discovery-and-groups.md +30 -0
  110. package/dist/skill/references/routing-and-lifecycle.md +62 -0
  111. package/dist/skill/references/senses-and-tests.md +41 -0
  112. package/dist/skill/references/yaml-and-offline-work.md +33 -0
  113. package/dist/templates/mcp-wrapper.sh +35 -0
  114. package/dist/tool-contract.d.ts +8 -0
  115. package/dist/tool-contract.js +253 -0
  116. package/dist/tool-contract.js.map +1 -0
  117. package/dist/tool-router.d.ts +62 -7
  118. package/dist/tool-router.js +535 -61
  119. package/dist/tool-router.js.map +1 -1
  120. package/dist/tool-session-state.d.ts +6 -0
  121. package/dist/tool-session-state.js +14 -0
  122. package/dist/tool-session-state.js.map +1 -1
  123. package/dist/tools/agent-capabilities.js +9 -0
  124. package/dist/tools/agent-capabilities.js.map +1 -1
  125. package/dist/tools/batch-execute.js +17 -14
  126. package/dist/tools/batch-execute.js.map +1 -1
  127. package/dist/tools/bridge-status.js +4 -5
  128. package/dist/tools/bridge-status.js.map +1 -1
  129. package/dist/tools/compile-check.js +6 -4
  130. package/dist/tools/compile-check.js.map +1 -1
  131. package/dist/tools/component-get.js +2 -2
  132. package/dist/tools/component-get.js.map +1 -1
  133. package/dist/tools/component-modify.js +4 -4
  134. package/dist/tools/component-modify.js.map +1 -1
  135. package/dist/tools/execute-csharp.js +8 -4
  136. package/dist/tools/execute-csharp.js.map +1 -1
  137. package/dist/tools/gameobject-modify.js +2 -1
  138. package/dist/tools/gameobject-modify.js.map +1 -1
  139. package/dist/tools/index.d.ts +2 -0
  140. package/dist/tools/index.js +10 -2
  141. package/dist/tools/index.js.map +1 -1
  142. package/dist/tools/inputsim-key.js +12 -14
  143. package/dist/tools/inputsim-key.js.map +1 -1
  144. package/dist/tools/inputsim-pointer.js +3 -3
  145. package/dist/tools/inputsim-pointer.js.map +1 -1
  146. package/dist/tools/inputsim-step.js +7 -8
  147. package/dist/tools/inputsim-step.js.map +1 -1
  148. package/dist/tools/inputsim-touch.js +9 -10
  149. package/dist/tools/inputsim-touch.js.map +1 -1
  150. package/dist/tools/jobs.d.ts +40 -0
  151. package/dist/tools/jobs.js +18 -0
  152. package/dist/tools/jobs.js.map +1 -0
  153. package/dist/tools/manage-tools.js +10 -5
  154. package/dist/tools/manage-tools.js.map +1 -1
  155. package/dist/tools/project-commands.d.ts +40 -0
  156. package/dist/tools/project-commands.js +23 -0
  157. package/dist/tools/project-commands.js.map +1 -0
  158. package/dist/tools/read-compile-errors.js +14 -93
  159. package/dist/tools/read-compile-errors.js.map +1 -1
  160. package/dist/tools/resource-pressure.js +27 -17
  161. package/dist/tools/resource-pressure.js.map +1 -1
  162. package/dist/tools/scan-paths.js +4 -4
  163. package/dist/tools/scan-paths.js.map +1 -1
  164. package/dist/tools/schema-fragments.d.ts +15 -3
  165. package/dist/tools/schema-fragments.js +23 -13
  166. package/dist/tools/schema-fragments.js.map +1 -1
  167. package/dist/tools/upgrade.d.ts +40 -0
  168. package/dist/tools/upgrade.js +55 -0
  169. package/dist/tools/upgrade.js.map +1 -0
  170. package/dist/unity-install-discovery.d.ts +24 -0
  171. package/dist/unity-install-discovery.js +113 -4
  172. package/dist/unity-install-discovery.js.map +1 -1
  173. package/package.json +4 -3
  174. package/templates/mcp-wrapper.sh +35 -0
@@ -1,3 +1,17 @@
1
+ import { testRunOperation, projectCommandOperation } from "./jobs/adapters.js";
2
+ import { JobManager, JobManagerError } from "./jobs/job-manager.js";
3
+ import { handleJobs } from "./jobs/handler.js";
4
+ import { PROCESS_AGENT_ID, PROCESS_IDENTITY } from "./agent-identity.js";
5
+ import { buildEditorSearch } from "./editor-search.js";
6
+ import { withSchemaDefaults } from "./schema-defaults.js";
7
+ import { validateSchema, wireArguments } from "./tool-contract.js";
8
+ import { discoverTools, compactDiscoverySchema } from "./capabilities/discovery.js";
9
+ import { PingCache } from "./ping-cache.js";
10
+ import { resolveProjectPath, validateUnityProjectRoot } from "./project-path.js";
11
+ import { resolvePort, resolveAuthToken } from "./instance-discovery.js";
12
+ import { skipsReloadProbe } from "./local-tools.js";
13
+ import { confirmBridgeReleaseTag, resolveLatestVersion } from "./release-lookup.js";
14
+ import { LiveClient } from "./live-client.js";
1
15
  import { AssetModelCache, isCompressible, routeCompressible } from "./compressible-router.js";
2
16
  import { listAssetsOffline, findReferencesOffline, dependenciesOffline } from "./offline.js";
3
17
  import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly, detectErrorsPredatingEdits, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
@@ -53,7 +67,8 @@ const defaultHubBackend = {
53
67
  getInstallPath: () => getInstallPath(),
54
68
  setInstallPath: (path) => setInstallPath(path),
55
69
  };
56
- import { BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS } from "./batch-spawn.js";
70
+ import { BatchSpawn, BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS, activeHeadlessRun } from "./batch-spawn.js";
71
+ import { filterVisibleTools } from "./tool-session-state.js";
57
72
  import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, isOutputProfile, } from "./output-profile.js";
58
73
  /**
59
74
  * Single source of truth for the `_source` tag. Every route-tagging site goes
@@ -186,6 +201,17 @@ function transformLiveResult(result, source, meta, transform) {
186
201
  function localError(code, message) {
187
202
  return sourceResult({ error: { code, message } }, "local", true);
188
203
  }
204
+ const defaultReleaseLookup = {
205
+ latest: () => resolveLatestVersion(globalThis.fetch.bind(globalThis)),
206
+ confirmBridgeTag: (version) => confirmBridgeReleaseTag(version, globalThis.fetch.bind(globalThis)),
207
+ };
208
+ let releaseLookup = defaultReleaseLookup;
209
+ /** Test seam for the upgrade route's network lookups. Returns a restore function. */
210
+ export function setReleaseLookupForTest(lookup) {
211
+ const previous = releaseLookup;
212
+ releaseLookup = lookup ?? defaultReleaseLookup;
213
+ return () => { releaseLookup = previous; };
214
+ }
189
215
  // M26 Plan 2 — gate-consistent envelope shape for the mutating Hub control
190
216
  // tools (install_editor / install_modules / set_install_path). These are
191
217
  // system-level ops, not project-asset mutations, so paths_hint is N/A and the
@@ -341,7 +367,7 @@ function groupLiveRefsByKind(paths) {
341
367
  return counts;
342
368
  }
343
369
  // Operator-facing "what to do next" hint for each coarse status. Kept short
344
- // and action-oriented so the Validation Suite can render it inline.
370
+ // and action-oriented so an operator UI can render it inline.
345
371
  function bridgeStatusNextStep(status, wedge) {
346
372
  if (status === "wedged") {
347
373
  return wedge?.reason === "main_thread_wedged"
@@ -392,6 +418,57 @@ function bridgeStatusNextStep(status, wedge) {
392
418
  "Bridge) and confirm it is started.";
393
419
  }
394
420
  }
421
+ // This server's own headless batch run holds the project. The Unity process
422
+ // scan deliberately skips that child (it is not an Editor the user owns), so
423
+ // Editor-ownership surfaces would otherwise report "no Unity running" and send
424
+ // the agent to relaunch via the Hub, which would collide with the run.
425
+ function describeHeadlessRun(run, now = Date.now()) {
426
+ return {
427
+ tool: run.tool,
428
+ operation: run.operation,
429
+ pid: run.pid,
430
+ startedAt: new Date(run.startedAt).toISOString(),
431
+ elapsedMs: Math.max(0, now - run.startedAt),
432
+ timeoutMs: run.timeoutMs,
433
+ };
434
+ }
435
+ function headlessRunSentence(run, now = Date.now()) {
436
+ const elapsedS = Math.round(Math.max(0, now - run.startedAt) / 1000);
437
+ const timeoutS = Math.round(run.timeoutMs / 1000);
438
+ return (`The only Unity process for this project is this MCP server's own headless ` +
439
+ `run for ${run.tool}` +
440
+ (run.pid !== null ? ` (pid ${run.pid})` : "") +
441
+ `, started ${elapsedS}s ago with a ${timeoutS}s timeout. It is not an ` +
442
+ `interactive Editor, and it exits on its own when the operation finishes ` +
443
+ `or the timeout elapses.`);
444
+ }
445
+ const HEADLESS_RUN_NEXT_STEPS = [
446
+ "Wait for the pending headless call to return; it releases the project " +
447
+ "when it finishes or when its timeout elapses.",
448
+ "Do not relaunch Unity for this project via the Hub meanwhile: Unity " +
449
+ "allows one Editor per project, so a second instance collides with the " +
450
+ "headless run's project lock.",
451
+ ];
452
+ function headlessRunErrorResult(action, run, projectPath) {
453
+ const now = Date.now();
454
+ const tail = action === "restart_editor"
455
+ ? " restart_editor never targets the server's own headless run, so there is no Editor to restart."
456
+ : " resource_pressure watches the interactive Editor's descriptor leak, which a short-lived headless run does not accumulate.";
457
+ return sourceResult({
458
+ error: {
459
+ code: "headless_run_in_progress",
460
+ message: headlessRunSentence(run, now) + tail,
461
+ projectPath,
462
+ ...describeHeadlessRun(run, now),
463
+ },
464
+ agentNextSteps: [
465
+ ...HEADLESS_RUN_NEXT_STEPS,
466
+ action === "restart_editor"
467
+ ? "Call restart_editor again only if an interactive Editor for this project hangs after the run completes."
468
+ : "Once the run completes and an interactive Editor is open, call resource_pressure again; pass an explicit pid to sample the headless process anyway.",
469
+ ],
470
+ }, "local", true);
471
+ }
395
472
  /**
396
473
  * specs/feedback.md 2026-08-24 — wire-contract staleness block.
397
474
  *
@@ -471,6 +548,60 @@ export class ToolRouter {
471
548
  onToolListChanged;
472
549
  hubBackend;
473
550
  globalLogPathOverride;
551
+ jobManager;
552
+ get jobs() { return this.jobManager ??= new JobManager(); }
553
+ closeJobs() { this.jobManager?.close(); }
554
+ async routeJobs(args, identity = PROCESS_IDENTITY) {
555
+ const definition = ALL_TOOLS.find(t => t.name === "unity_open_mcp_jobs");
556
+ const errors = validateSchema(args, definition.inputSchema);
557
+ if (errors.length)
558
+ return localError("invalid_arguments", errors.join("; "));
559
+ const owner = { project: this.projectPath || "default", agent: identity.agent,
560
+ ...(identity.port === undefined ? {} : { port: identity.port }) };
561
+ try {
562
+ // A port override names a bridge, not a project: bind the job client to
563
+ // the Editor whose lock claims that port (its bearer token and its lock
564
+ // diagnostics), exactly like a direct override call. resolveAuthToken
565
+ // yields no token for an explicit port, and the default project's lock
566
+ // would describe the wrong Editor.
567
+ const clientFor = (jobOwner) => jobOwner.port === undefined
568
+ ? jobOwner.agent === PROCESS_AGENT_ID ? this.live : this.live.forAgent(jobOwner.agent)
569
+ : LiveClient.forPortOverride(jobOwner.port, this.projectPath, jobOwner.agent);
570
+ if (args.action === "start" && typeof args.tool_or_command === "string") {
571
+ const name = args.tool_or_command, key = args.idempotency_key;
572
+ if (name === "unity_senses_run_tests") {
573
+ if (!this.jobs.hasOperation(name))
574
+ this.jobs.register(name, testRunOperation(clientFor));
575
+ }
576
+ else if (name.startsWith("project.") && !(typeof key === "string" && this.jobs.hasKey(owner, key))) {
577
+ // A retry whose key already names a job gets that job (or
578
+ // idempotency_conflict) from start() without the catalog, which may
579
+ // be unreachable mid-reload or declare a contract changed since.
580
+ // Re-read the catalog on every new start: a domain reload can change the
581
+ // command's async/cancellable declaration, and an adapter frozen at
582
+ // first registration would refuse the job for the rest of the session.
583
+ const catalog = await clientFor(owner).projectCommands({ action: "describe", id: name });
584
+ if (catalog.error) {
585
+ // A transport/catalog failure (bridge down, reloading, too old) is
586
+ // not "this command is unsupported": surface the catalog's own
587
+ // code so the agent retries instead of abandoning the plan.
588
+ const failure = catalog.error;
589
+ throw new JobManagerError(failure.code ?? "catalog_unavailable", failure.message ?? "Project command catalog is unavailable.");
590
+ }
591
+ const command = catalog.command;
592
+ if (!command?.available || !command.async)
593
+ throw new JobManagerError("job_operation_unsupported", "Target must be an available async project command.");
594
+ const cancellable = command.cancellable === true;
595
+ if (this.jobs.operation(name)?.cancellable !== cancellable)
596
+ this.jobs.register(name, projectCommandOperation(name, cancellable, clientFor), true);
597
+ }
598
+ }
599
+ return sourceResult(await handleJobs(this.jobs, owner, args), "local");
600
+ }
601
+ catch (error) {
602
+ return localError(error instanceof JobManagerError ? error.code : "invalid_arguments", error instanceof Error ? error.message : String(error));
603
+ }
604
+ }
474
605
  modelCache = new AssetModelCache();
475
606
  /**
476
607
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -487,15 +618,24 @@ export class ToolRouter {
487
618
  if (this.routeHandlers)
488
619
  return this.routeHandlers;
489
620
  const handlers = new Map([
490
- // Offline-routed tools (resolved from disk; never hit the bridge).
621
+ // Disk reads and live compiler evidence with an offline log fallback.
491
622
  ["unity_open_mcp_list_assets", (_l, a) => this.routeListAssets(a)],
492
- ["unity_open_mcp_read_compile_errors", (_l, a) => this.routeReadCompileErrors(a)],
623
+ ["unity_open_mcp_read_compile_errors", (l, a) => this.routeReadCompileErrors(a, l)],
493
624
  // Local-routed tools (server-only; no live/batch hop).
494
625
  ["unity_senses_pull_events", (l, a) => this.routePullEvents(a, l)],
495
626
  ["unity_open_mcp_capabilities", (l, a) => this.routeCapabilities(a, l)],
496
627
  ["unity_open_mcp_list_rules", (_l, a) => this.routeListRules(a)],
497
628
  ["unity_open_mcp_generate_skill", (_l, a) => this.routeGenerateSkill(a)],
498
- ["unity_open_mcp_manage_tools", (l, a) => this.routeManageTools(a, l)],
629
+ ["unity_open_mcp_manage_tools", (l, a, id) => this.routeManageTools(a, l, id)],
630
+ ["unity_open_mcp_project_commands", (l, a) => this.routeProjectCommands(a, l)],
631
+ // Live tool whose release lookups (npm latest, GitHub tag) run HERE so
632
+ // the Editor main thread never blocks on the network.
633
+ ["unity_open_mcp_upgrade", (l, a) => this.routeUpgrade(a, l)],
634
+ // The owner is the request identity (agent id + per-request port
635
+ // override), never the live client's: the default client always sends
636
+ // the process agent id, whatever _meta.agentId the call carried. Direct,
637
+ // manage_tools(invoke) and port-override calls therefore share one owner.
638
+ ["unity_open_mcp_jobs", (_l, a, id) => this.routeJobs(a, id)],
499
639
  ["unity_open_mcp_bridge_status", (l, a) => this.routeBridgeStatus(a, l)],
500
640
  // M31 Plan 3 — Editor fd-exhaustion operator surfaces. restart_editor
501
641
  // is the reactive kill half (acts AFTER the Editor is hung);
@@ -545,28 +685,132 @@ export class ToolRouter {
545
685
  this.hubBackend = hubBackend;
546
686
  this.globalLogPathOverride = globalLogPathOverride;
547
687
  }
548
- async route(toolName, args) {
549
- return this.routeCore(this.live, toolName, args);
688
+ async route(toolName, args, identity = PROCESS_IDENTITY) {
689
+ return this.routeCore(this.live, toolName, args, identity);
690
+ }
691
+ /**
692
+ * `capabilities` / `bridge_status` with an explicit project_path, from any
693
+ * entry point (direct, manage_tools invoke, port override). The probe router
694
+ * gets the arguments without project_path, so it dispatches normally. A
695
+ * per-request port override is refused rather than dropped or mixed in:
696
+ * project_path brings its own port/auth discovery, and pinging the override
697
+ * port would present another bridge's answer as the probed project's.
698
+ */
699
+ async routeProjectProbe(toolName, args, identity) {
700
+ if (typeof args.project_path !== "string" || !args.project_path.trim())
701
+ return localError("invalid_project_path", "project_path must name a Unity project root.");
702
+ if (identity.port !== undefined)
703
+ return localError("invalid_arguments", "project_path discovers that project's own bridge port; drop the _meta.port (or port) override for this call.");
704
+ const selected = resolveProjectPath({ flagPath: args.project_path }).absolute;
705
+ if (!validateUnityProjectRoot(selected).valid)
706
+ return localError("invalid_project_path", "project_path must contain Assets, Packages, and ProjectSettings.");
707
+ const { project_path: _selected, ...rest } = args;
708
+ return this.projectProbeRouter(selected).route(toolName, rest, identity);
709
+ }
710
+ /**
711
+ * Router for a read-only probe of another project: its own port/auth
712
+ * discovery and a throwaway copy of this session. Discovery still reconciles
713
+ * auto-activation against the probed project's inventory, so the probe
714
+ * reports that project's view, but only the copy changes — this session's
715
+ * active groups and tools/list stay put, and no list_changed is wired. The
716
+ * probe tools are local-only and never spawn Unity; the probe's own batch
717
+ * router only answers headless availability for that project's editor.
718
+ */
719
+ projectProbeRouter(projectPath) {
720
+ const live = new LiveClient(resolvePort(projectPath), new PingCache(), resolveAuthToken(projectPath), projectPath);
721
+ return new ToolRouter(live, this.projectProbeBatch(projectPath), projectPath, this.eventStream, this.sessionState.clone());
722
+ }
723
+ // Batch routers of probed projects, keyed by project path and bounded to
724
+ // the most recently probed few. Constructing a BatchSpawn walks every Hub
725
+ // install root synchronously; a polled probe must not pay that on every
726
+ // call, and the router keeps its own editor-resolution cache (revalidated
727
+ // when the project version or the env inputs change).
728
+ static PROBE_BATCH_CACHE_SIZE = 8;
729
+ probeBatchByProject = new Map();
730
+ projectProbeBatch(projectPath) {
731
+ const cached = this.probeBatchByProject.get(projectPath);
732
+ if (cached) {
733
+ // Re-insert so the map keeps insertion order as recency order.
734
+ this.probeBatchByProject.delete(projectPath);
735
+ this.probeBatchByProject.set(projectPath, cached);
736
+ return cached;
737
+ }
738
+ const batch = new BatchSpawn({ projectPath });
739
+ this.probeBatchByProject.set(projectPath, batch);
740
+ while (this.probeBatchByProject.size > ToolRouter.PROBE_BATCH_CACHE_SIZE) {
741
+ const oldest = this.probeBatchByProject.keys().next().value;
742
+ if (oldest === undefined)
743
+ break;
744
+ this.probeBatchByProject.delete(oldest);
745
+ }
746
+ return batch;
550
747
  }
551
748
  /**
552
749
  * M23 Plan 3 — route with a per-call live-client override. Used when a
553
750
  * per-request `_meta.port` override targets a different bridge instance than
554
751
  * the default. The override client bypasses shared session state (it is a
555
752
  * fresh LiveClient aimed at the override port); all other routing logic is
556
- * identical to {@link route}. When `overrideLive` is null the default client
557
- * is used (identical to {@link route}).
753
+ * identical to {@link route}. `identity` carries the override port and the
754
+ * request's agent id.
558
755
  */
559
- async routeOverride(toolName, args, overrideLive) {
560
- return this.routeCore(overrideLive, toolName, args);
756
+ async routeOverride(toolName, args, overrideLive, identity) {
757
+ return this.routeCore(overrideLive, toolName, args, identity);
561
758
  }
562
- async routeCore(live, toolName, args) {
759
+ async routeCore(live, toolName, args, identity) {
760
+ // Every entry point funnels through here, so an explicit project_path is
761
+ // retargeted before validation and wiring wherever the call came from.
762
+ if ((toolName === "unity_open_mcp_capabilities" || toolName === "unity_open_mcp_bridge_status") && args.project_path !== undefined)
763
+ return this.routeProjectProbe(toolName, args, identity);
764
+ const definition = ALL_TOOLS.find(t => t.name === toolName);
765
+ if (definition) {
766
+ const errors = validateSchema(args, definition.inputSchema);
767
+ if (errors.length)
768
+ return sourceResult({ error: { code: "invalid_arguments", message: errors.join("; ") }, errors, _route: { route: "local" } }, "local", true);
769
+ const notes = [];
770
+ const wire = wireArguments(args, definition.inputSchema, notes);
771
+ if (toolName === "unity_open_mcp_batch_execute" && Array.isArray(wire.commands)) {
772
+ wire.commands = wire.commands.map(command => {
773
+ const nested = ALL_TOOLS.find(t => t.name === command.tool);
774
+ return nested
775
+ ? { ...command, params: wireArguments(command.params ?? {}, nested.inputSchema, notes) }
776
+ : command;
777
+ });
778
+ }
779
+ // Recursive dispatch only after validation; alias notes are attached to the normal result.
780
+ return this.routeValidated(live, toolName, wire, notes, identity);
781
+ }
782
+ return this.routeValidated(live, toolName, args, [], identity);
783
+ }
784
+ async routeValidated(live, toolName, args, notes, identity) {
785
+ const result = await this.routeUnchecked(live, toolName, args, identity);
786
+ if (notes.length)
787
+ result.content.push({ type: "text", text: JSON.stringify({ deprecations: [...new Set(notes)] }) });
788
+ return result;
789
+ }
790
+ async routeUnchecked(live, toolName, args, identity) {
791
+ const localProbe = skipsReloadProbe(toolName);
792
+ const lock = !localProbe && this.projectPath ? readInstanceLock(this.projectPath) : null;
793
+ // The lock describes the default project's Editor. A per-request port
794
+ // override aimed elsewhere has no lock of its own (override clients never
795
+ // read locks) and its LiveClient handles that bridge's readiness, so the
796
+ // default Editor's reload must not refuse it. An override naming the
797
+ // default Editor's own port still targets the reloading Editor.
798
+ const targetsLockedEditor = identity.port === undefined || identity.port === lock?.port;
799
+ if (targetsLockedEditor && classifyInstance(lock) === "reloading") {
800
+ // One bounded re-probe. No headless fallback can acquire this Editor's project.
801
+ await new Promise(resolve => setTimeout(resolve, 250));
802
+ const available = await live.isLiveAvailable(750);
803
+ if (!available || classifyInstance(readInstanceLock(this.projectPath)) === "reloading") {
804
+ return sourceResult({ error: { code: "editor_reloading", message: "The live Editor is compiling or reloading. Retry after it settles." }, retryAfterMs: 1000 }, "local", true);
805
+ }
806
+ }
563
807
  // Named-tool dispatch (T3.2): the handler map covers every exact-name
564
808
  // route. The per-tool policy rationale (offline / local / hub / offline-
565
809
  // first / verify-fold / scene-paging) lives in each routeXxx method's
566
810
  // header comment.
567
811
  const handler = this.getRouteHandlers().get(toolName);
568
812
  if (handler) {
569
- return handler(live, args);
813
+ return handler(live, args, identity);
570
814
  }
571
815
  // Compact drill-down reads: offline-first for text-serialized assets, fall
572
816
  // back to live bridge for binary formats. The compressible-router handles
@@ -713,7 +957,7 @@ export class ToolRouter {
713
957
  return meta;
714
958
  }
715
959
  }
716
- async routeReadCompileErrors(args) {
960
+ async routeReadCompileErrors(args, live) {
717
961
  const tailBytes = typeof args.tail_bytes === "number" && args.tail_bytes >= 4096
718
962
  ? Math.min(Math.floor(args.tail_bytes), 1048576)
719
963
  : DEFAULT_LOG_TAIL_BYTES;
@@ -731,6 +975,40 @@ export class ToolRouter {
731
975
  const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid, this.globalLogPathOverride);
732
976
  const logPath = resolvedLog.path;
733
977
  const tail = readLogTail(logPath, tailBytes);
978
+ const compile = await live.readCompileState?.();
979
+ if (compile && compile.status !== "indeterminate") {
980
+ const historical = summarizeProjectHealth(tail.content);
981
+ const failed = compile.status === "compile_failed";
982
+ const pipelineErrors = failed && Array.isArray(compile.errors) ? compile.errors : [];
983
+ // Unity can report a failed compile the bridge never observed (it ran
984
+ // before the bridge domain loaded, e.g. the Editor-startup compile), so
985
+ // the pipeline holds no diagnostics for it. Editor.log does: promote its
986
+ // errors instead of answering "failed" with nothing to fix.
987
+ const fromLog = failed && pipelineErrors.length === 0;
988
+ const currentErrors = fromLog ? historical.compilerErrors : pipelineErrors;
989
+ return sourceResult({
990
+ ...compile, errorCount: currentErrors.length, errors: currentErrors,
991
+ errorSource: fromLog ? "Editor.log" : "CompilationPipeline",
992
+ unhealthy: failed,
993
+ staleAssembly: compile.status === "assembly_stale",
994
+ headline: compile.status === "no_errors_found" ? "Current compile is clean; source content matches the completed generation."
995
+ : compile.status === "currently_compiling" ? "Compilation is active; wait for completion."
996
+ : compile.status === "assembly_stale" ? "Source content changed after the last compile; recompile before trusting assemblies."
997
+ : !fromLog ? "Current compilation failed. Fix the compiler diagnostics and recompile."
998
+ : currentErrors.length > 0
999
+ ? "Current compilation failed in a compile the bridge did not observe (e.g. at Editor " +
1000
+ `startup); errors are the ${currentErrors.length} compiler diagnostic(s) from Editor.log. ` +
1001
+ "Fix them and recompile."
1002
+ : "Current compilation failed in a compile the bridge did not observe (e.g. at Editor " +
1003
+ "startup), and the Editor.log tail holds no compiler diagnostics. Raise tail_bytes or " +
1004
+ `recompile (${toolHintReference("unity_open_mcp_recompile_scripts")}) to capture them.`,
1005
+ compileSource: "CompilationPipeline", logPath, logSource: resolvedLog.reason,
1006
+ logAuthorship: parseLogAuthorship(tail.content),
1007
+ historicalLogErrors: fromLog ? [] : historical.compilerErrors,
1008
+ historicalLogIssues: historical.issues,
1009
+ logReadError: tail.error ?? null,
1010
+ }, "live");
1011
+ }
734
1012
  if (tail.error) {
735
1013
  return {
736
1014
  content: [
@@ -1122,6 +1400,13 @@ export class ToolRouter {
1122
1400
  };
1123
1401
  }
1124
1402
  async routeCapabilities(args, live) {
1403
+ if (["tool_name", "query", "group", "tag", "available", "active", "route", "mutating"].some(k => args[k] !== undefined)) {
1404
+ if (args.kind !== undefined && args.kind !== "tools")
1405
+ return localError("invalid_arguments", "Tool discovery filters require kind: tools (or omit kind).");
1406
+ const inventory = await this.fetchInventoryAndReconcile(live);
1407
+ const found = discoverTools(ALL_TOOLS, BATCH_TOOL_NAMES, this.sessionState, inventory?.tools, args, this.batch.headlessAvailable());
1408
+ return sourceResult(found, "local", !!found.error);
1409
+ }
1125
1410
  const kind = args.kind === "tools" || args.kind === "rules" || args.kind === "fixes"
1126
1411
  ? args.kind
1127
1412
  : undefined;
@@ -1229,9 +1514,49 @@ export class ToolRouter {
1229
1514
  // when reachable; when the bridge is offline it reports `available: null`
1230
1515
  // (unknown) and the agent should fall back to `unity_open_mcp_capabilities`
1231
1516
  // for the authoritative compiled-state report.
1232
- async routeManageTools(args, live) {
1517
+ async routeManageTools(args, live, identity) {
1233
1518
  const action = typeof args.action === "string" ? args.action : "";
1234
1519
  const group = typeof args.group === "string" ? args.group.trim() : "";
1520
+ const actionKeys = {
1521
+ list_groups: [], reset: [], activate: ["group"], deactivate: ["group"],
1522
+ suggest: ["intent", "tags"], activate_for: ["intent", "tags"],
1523
+ invoke: ["tool_name", "arguments"], editor_search: ["search_text", "asset_type", "open_ui"],
1524
+ };
1525
+ const ignored = Object.keys(args).filter(k => k !== "action" && actionKeys[action] && !actionKeys[action].includes(k));
1526
+ if (ignored.length)
1527
+ return this.manageToolsError("invalid_arguments", `Keys not used by ${action}: ${ignored.join(", ")}`);
1528
+ if (action === "editor_search") {
1529
+ const query = buildEditorSearch(String(args.search_text ?? ""), args.asset_type);
1530
+ if (args.open_ui !== true)
1531
+ return sourceResult({ query, opened: false, purpose: "Human collaboration; use search_assets/find_references for structured results." }, "local");
1532
+ const result = await this.routeCore(live, "unity_open_mcp_execute_csharp", {
1533
+ code: `UnityEditor.Search.SearchService.ShowWindow(UnityEditor.Search.SearchService.CreateContext(${JSON.stringify(query)})); return ${JSON.stringify(query)};`,
1534
+ read_only: true,
1535
+ }, identity);
1536
+ // The Search window opens through a read_only snippet, which a running
1537
+ // project job refuses (the bridge cannot verify a snippet writes
1538
+ // nothing). The handoff is optional UI, so report the query instead of
1539
+ // surfacing the refusal as a failed search.
1540
+ const body = result.isError ? parseResultBody(result) : null;
1541
+ const mutation = body?.mutation;
1542
+ const refusal = mutation?.error ?? body?.error;
1543
+ if (refusal?.code === "job_busy") {
1544
+ return sourceResult({
1545
+ query, opened: false, reason: "job_busy",
1546
+ ...(refusal.job ? { job: refusal.job } : {}),
1547
+ message: "Unity Search cannot be opened while an asynchronous project command job runs. " +
1548
+ "Paste the query into Unity Search manually, or retry open_ui once the job reaches a terminal state.",
1549
+ }, "local");
1550
+ }
1551
+ result.content.push({ type: "text", text: JSON.stringify({ query, opened: !result.isError }) });
1552
+ return result;
1553
+ }
1554
+ if (action === "invoke") {
1555
+ const name = typeof args.tool_name === "string" ? args.tool_name : "";
1556
+ if (name === "unity_open_mcp_manage_tools" || !filterVisibleTools(ALL_TOOLS, this.sessionState).some(t => t.name === name))
1557
+ return this.manageToolsError("tool_not_active", "Activate the target group first; recursive manage_tools invocation is forbidden.");
1558
+ return this.routeCore(live, name, withSchemaDefaults(ALL_TOOLS.find(t => t.name === name), (args.arguments ?? {})), identity);
1559
+ }
1235
1560
  if (action === "list_groups") {
1236
1561
  return this.manageToolsListGroups(live);
1237
1562
  }
@@ -1299,7 +1624,94 @@ export class ToolRouter {
1299
1624
  });
1300
1625
  }
1301
1626
  return this.manageToolsError("unknown_action", `Unknown action '${action}'. Valid actions: list_groups, activate, ` +
1302
- `deactivate, reset, suggest, activate_for.`);
1627
+ `deactivate, reset, suggest, activate_for, invoke, editor_search.`);
1628
+ }
1629
+ /**
1630
+ * `unity_open_mcp_upgrade` is a live tool, but its network lookups run HERE
1631
+ * rather than inside the bridge: the bridge tool executes on the Editor main
1632
+ * thread, where a 10 s registry timeout would freeze the Editor and every
1633
+ * queued bridge request. The bridge only ever receives a resolved
1634
+ * `target_version`, and an apply that will re-pin through UPM is forwarded
1635
+ * only after the `bridge-v<version>` release tag was confirmed to exist —
1636
+ * the same preview → confirm → apply order the bridge window follows.
1637
+ */
1638
+ async routeUpgrade(args, live) {
1639
+ const forwarded = { ...args };
1640
+ if (forwarded.target_version === undefined) {
1641
+ try {
1642
+ forwarded.target_version = (await releaseLookup.latest()).version;
1643
+ }
1644
+ catch (error) {
1645
+ const detail = error instanceof Error ? error.message : String(error);
1646
+ return injectRouteMeta(localError("latest_version_failed", `Could not resolve the latest published version: ${detail}. Pass target_version explicitly.`), { route: "local" });
1647
+ }
1648
+ }
1649
+ if (forwarded.dry_run === false && forwarded.update_upm !== false) {
1650
+ // Preview first: only a plan that really re-pins through UPM needs the
1651
+ // GitHub tag check (an embedded or file: bridge install skips UPM, so it
1652
+ // must also work offline).
1653
+ const preview = await live.route("unity_open_mcp_upgrade", { ...forwarded, dry_run: true });
1654
+ const previewBody = parseResultBody(preview);
1655
+ const mutation = previewBody?.mutation;
1656
+ const output = (mutation && typeof mutation === "object" ? mutation.output : previewBody);
1657
+ if (preview.isError || (output && typeof output === "object" && output.error !== undefined)) {
1658
+ return injectRouteMeta(preview, { route: "live" });
1659
+ }
1660
+ if (output && typeof output === "object" && output.upmEnabled === true) {
1661
+ const version = String(forwarded.target_version);
1662
+ const tag = await releaseLookup.confirmBridgeTag(version);
1663
+ if (!tag.confirmed) {
1664
+ return injectRouteMeta(localError("release_tag_unavailable", tag.error ?? `Release tag bridge-v${version} was not found.`), { route: "local" });
1665
+ }
1666
+ }
1667
+ }
1668
+ return injectRouteMeta(await live.route("unity_open_mcp_upgrade", forwarded), { route: "live" });
1669
+ }
1670
+ async routeProjectCommands(args, live) {
1671
+ if (args.action === "invoke") {
1672
+ const allowed = ["action", "command_id", "args", "schema_version", "paths_hint", "gate", "ignore_scene_dirty", "confirm_bypass", "timeout_ms"];
1673
+ let identity = { id: args.command_id };
1674
+ const fail = (code, message) => injectRouteMeta(sourceResult({ projectCommand: identity, error: { code, message } }, "local", true), { route: "local" });
1675
+ if (!args.command_id || Object.keys(args).some(k => !allowed.includes(k)))
1676
+ return fail("invalid_arguments", "invoke requires command_id and accepts command args plus transport safety options only.");
1677
+ const catalog = await live.projectCommands({ action: "describe", id: args.command_id });
1678
+ if (catalog.error)
1679
+ return injectRouteMeta(sourceResult(catalog, "live", true), { route: "live" });
1680
+ const command = catalog.command;
1681
+ if (!command?.available || !command.invocationSupported || !command.inputSchema || !command.schemaVersion)
1682
+ return fail("command_unavailable", "Command is unavailable or this bridge does not support invocation.");
1683
+ identity = { id: command.id, schemaVersion: command.schemaVersion, declaringAssembly: command.declaringAssembly,
1684
+ declaringType: command.declaringType, lifecycle: command.lifecycle };
1685
+ if (args.schema_version !== undefined && args.schema_version !== command.schemaVersion)
1686
+ return fail("command_schema_changed", "Describe the command again before invoking its changed contract.");
1687
+ const errors = validateSchema(args.args ?? {}, command.inputSchema);
1688
+ if (errors.length)
1689
+ return fail("invalid_arguments", errors.join("; "));
1690
+ // Deprecated parameter aliases are published like built-in aliases.
1691
+ const notes = [];
1692
+ const wire = wireArguments(args.args ?? {}, command.inputSchema, notes);
1693
+ const result = await live.route("unity_open_mcp_project_commands", { ...args, args: wire, schema_version: command.schemaVersion }, command.lifecycle);
1694
+ // Reload/transport failures have no bridge envelope; retain the exact catalog identity.
1695
+ result.content = result.content.map(item => {
1696
+ if (item.type !== "text")
1697
+ return item;
1698
+ try {
1699
+ const body = JSON.parse(item.text);
1700
+ if (body && typeof body === "object" && !Array.isArray(body) && !body.projectCommand)
1701
+ return { ...item, text: JSON.stringify({ ...body, projectCommand: identity }) };
1702
+ }
1703
+ catch { /* Preserve non-JSON transport diagnostics. */ }
1704
+ return item;
1705
+ });
1706
+ if (notes.length)
1707
+ result.content.push({ type: "text", text: JSON.stringify({ deprecations: [...new Set(notes)] }) });
1708
+ return injectRouteMeta(result, { route: "live" });
1709
+ }
1710
+ const allowed = args.action === "describe" ? ["action", "id"] : ["action", "query", "tags", "group", "package", "offset", "limit"];
1711
+ if (Object.keys(args).some(k => !allowed.includes(k)) || (args.action === "describe" && !args.id))
1712
+ return sourceResult({ error: { code: "invalid_arguments", message: "describe requires an exact id only; list accepts filters and paging only." } }, "local", true);
1713
+ const body = await live.projectCommands(args);
1714
+ return injectRouteMeta(sourceResult(body, "live", !!body.error), { route: "live" });
1303
1715
  }
1304
1716
  async manageToolsListGroups(live) {
1305
1717
  // M20 Plan 7 / T20.7.0 — fetch the compiled-state inventory once and
@@ -1388,6 +1800,7 @@ export class ToolRouter {
1388
1800
  // `availableReason`. Emits `notifications/tools/list_changed` exactly once
1389
1801
  // when the visible set changed (idempotent when already active).
1390
1802
  async manageToolsActivateFor(rec, live) {
1803
+ const groupsBeforeInventory = new Set(this.sessionState.activeGroups());
1391
1804
  const inventory = await this.fetchInventoryAndReconcile(live);
1392
1805
  const compiledAvailability = this.computeCompiledAvailability(inventory, groupToTools());
1393
1806
  const before = this.sessionState.activeGroups();
@@ -1422,6 +1835,7 @@ export class ToolRouter {
1422
1835
  intent: rec,
1423
1836
  groups,
1424
1837
  activated,
1838
+ tools: compactDiscoverySchema(discoverTools(ALL_TOOLS, BATCH_TOOL_NAMES, this.sessionState, inventory?.tools, { groups: this.sessionState.activeGroups().filter(g => !groupsBeforeInventory.has(g)), profile: "full", page_size: 1000 }, this.batch.headlessAvailable()).tools),
1425
1839
  skipped,
1426
1840
  unavailable,
1427
1841
  activeGroups: this.sessionState.activeGroups(),
@@ -1735,6 +2149,11 @@ export class ToolRouter {
1735
2149
  else {
1736
2150
  status = "stopped";
1737
2151
  }
2152
+ // A "stopped" bridge while this server's own headless run holds the
2153
+ // project: the process scan skips that child, so without this hint the
2154
+ // generic nextStep would send the agent to launch Unity into the run's
2155
+ // project lock.
2156
+ const headlessRun = status === "stopped" ? activeHeadlessRun(this.projectPath) : null;
1738
2157
  const body = {
1739
2158
  status,
1740
2159
  // Coarse ready flag for clients that want a single boolean: true only
@@ -1784,7 +2203,12 @@ export class ToolRouter {
1784
2203
  // `stale: true` block is the signal to reinstall the Unity package
1785
2204
  // rather than file a regression.
1786
2205
  wireContract: wireContractBlock(pingReachable, pingBody?.wireContract),
1787
- nextStep: bridgeStatusNextStep(status, wedge),
2206
+ ...(headlessRun !== null ? { headlessRun: describeHeadlessRun(headlessRun) } : {}),
2207
+ nextStep: headlessRun !== null
2208
+ ? headlessRunSentence(headlessRun) +
2209
+ " The bridge stays offline until then. Wait for the pending headless call to return; " +
2210
+ "do not launch Unity for this project meanwhile."
2211
+ : bridgeStatusNextStep(status, wedge),
1788
2212
  };
1789
2213
  // bridge_status never reports an error — even a stopped bridge is a
1790
2214
  // successful status read. _source=local because the synthesis happens in
@@ -1916,6 +2340,16 @@ export class ToolRouter {
1916
2340
  pid = null;
1917
2341
  }
1918
2342
  }
2343
+ // No interactive Editor, but this server's own headless run holds the
2344
+ // project: say so (both dry-run and confirmed) instead of the misleading
2345
+ // "relaunch via the Hub" of unity_process_not_found. The scan never
2346
+ // returns that child, so it is never a kill target.
2347
+ if (pid === null) {
2348
+ const headlessRun = activeHeadlessRun(this.projectPath);
2349
+ if (headlessRun !== null) {
2350
+ return headlessRunErrorResult("restart_editor", headlessRun, this.projectPath);
2351
+ }
2352
+ }
1919
2353
  // 3. Dry-run branch: confirm was false/absent. Return the diagnosis +
1920
2354
  // the PID the tool WOULD kill, with no side effect. Lets an agent
1921
2355
  // preview before committing. feedback #2: include the PID source and
@@ -2117,6 +2551,10 @@ export class ToolRouter {
2117
2551
  }
2118
2552
  }
2119
2553
  if (pid === null) {
2554
+ const headlessRun = activeHeadlessRun(this.projectPath);
2555
+ if (headlessRun !== null) {
2556
+ return headlessRunErrorResult("resource_pressure", headlessRun, this.projectPath);
2557
+ }
2120
2558
  return sourceResult({
2121
2559
  error: {
2122
2560
  code: "unity_process_not_found",
@@ -2140,74 +2578,110 @@ export class ToolRouter {
2140
2578
  const probe = countFileDescriptors(pid);
2141
2579
  const count = probe.count;
2142
2580
  const approximate = "approximate" in probe ? probe.approximate : false;
2143
- const headroom = computeFdHeadroom(count, approximate, ceiling);
2581
+ const partial = "partial" in probe ? probe.partial === true : false;
2582
+ // The threshold softening is about WHAT the metric counts, not about how
2583
+ // precise the number is. Only Windows HandleCount counts more than fds; a
2584
+ // timed-out lsof is imprecise but still counts real fds (a lower bound),
2585
+ // so it takes the normal warn/critical thresholds — softening it made a
2586
+ // partial 900/1024 report `ok` on the very platform this tool targets.
2587
+ const broaderThanFds = probe.method === "handle_count";
2588
+ const headroom = computeFdHeadroom(count, broaderThanFds, ceiling);
2144
2589
  const ts = Date.now();
2145
2590
  this.sessionState.recordFdSample({ ts, pid, count });
2146
2591
  const samples = this.sessionState.fdSamplesSnapshot();
2147
2592
  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:
2593
+ // Samples are per-PID (as the tool description states): after an Editor
2594
+ // restart the ring still holds the previous process's entries — report
2595
+ // only this PID's series. The trend already analyzes the latest PID only.
2596
+ const pidSamples = samples.filter((s) => s.pid === pid);
2597
+ // 4. Decide whether to raise an operator warning. Alarm on:
2598
+ // - a real fd count at/past the ceiling (`over_ceiling` from lsof//proc):
2599
+ // POSIX allocates the lowest free descriptor number, so every NEW
2600
+ // descriptor now lands above the ceiling and the next IOSelector
2601
+ // registration (any async pipe/socket — e.g. the Bee build driver)
2602
+ // can hang the Editor. A fresh healthy Unity 6 editor sits at ~160
2603
+ // fds; real counts past 1024 are never legitimate now that
2604
+ // countLsofFdRows excludes the cwd/txt/mmap rows that once inflated
2605
+ // the count.
2153
2606
  // - a monotonic leak (the actionable trend signal), or
2154
2607
  // - 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).
2608
+ // The one downgraded case is a Windows HandleCount probe over the
2609
+ // ceiling: handle counts cover kernel/GDI/user objects and routinely
2610
+ // exceed 1024 on a healthy process, so it gets a `pressureNote` and
2611
+ // alarms only on the trend. `rising` is any net-positive delta that is
2612
+ // NOT a qualifying leak — it fires on ordinary drift, so it never
2613
+ // alarms on its own.
2160
2614
  const fdMethod = probe.method;
2161
2615
  const probeReason = "reason" in probe ? probe.reason : null;
2162
2616
  const probeMessage = "message" in probe ? probe.message : null;
2163
- const shouldWarn = trend.state === "leaking" ||
2617
+ // A partial (timed-out) lsof listing is a LOWER BOUND on real fds, so an
2618
+ // over-ceiling verdict from it still stands; only the Windows HandleCount
2619
+ // metric measures something broader than fds. Same predicate that picked
2620
+ // the headroom thresholds above, so the two can never disagree.
2621
+ const overCeilingRealFds = headroom.state === "over_ceiling" && !broaderThanFds;
2622
+ const shouldWarn = overCeilingRealFds ||
2623
+ trend.state === "leaking" ||
2164
2624
  headroom.state === "warn" ||
2165
2625
  headroom.state === "critical";
2166
2626
  const warningState = shouldWarn
2167
2627
  ? {
2168
- level: headroom.state === "critical"
2628
+ level: headroom.state === "critical" || overCeilingRealFds
2169
2629
  ? "critical"
2170
2630
  : trend.state === "leaking"
2171
2631
  ? "leaking"
2172
2632
  : "warn",
2173
- message: headroom.state === "critical"
2174
- ? `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
2175
- `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
2176
- `likely to trip the Bee build-driver hang. Save scene work and restart ` +
2177
- `Unity via the Hub now, before the Editor hangs.`
2178
- : trend.state === "leaking"
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.`
2189
- : `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
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.`,
2633
+ message: overCeilingRealFds
2634
+ ? `Editor fd count (${count}) is at or above the ${ceiling}-descriptor ` +
2635
+ `Mono IOSelector ceiling. Every newly allocated descriptor number now ` +
2636
+ `lands above the ceiling, so the next async pipe/socket registration ` +
2637
+ `(e.g. the Bee build driver's IPC) can hang the Editor mid-build` +
2638
+ (trend.state === "leaking"
2639
+ ? ` — and the count is still climbing (trend delta ${trend.delta} ` +
2640
+ `over ${trend.sampleCount} sample(s))`
2641
+ : "") +
2642
+ `. Save scene work and restart Unity via the Hub now.`
2643
+ : headroom.state === "critical"
2644
+ ? broaderThanFds
2645
+ ? `Windows handle count is at ${Math.floor(headroom.pressureRatio * 100)}% ` +
2646
+ `of the ${ceiling}-descriptor ceiling proxy. HandleCount covers ` +
2647
+ `kernel/GDI/user objects — far broader than fds — so this is a soft ` +
2648
+ `signal, not fd risk: watch the trend, and treat a monotonic climb ` +
2649
+ `(trend.state "leaking") as the real alarm.`
2650
+ : `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
2651
+ `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
2652
+ `likely to trip the Bee build-driver hang. Save scene work and restart ` +
2653
+ `Unity via the Hub now, before the Editor hangs.`
2654
+ : trend.state === "leaking"
2655
+ ? headroom.state === "over_ceiling"
2656
+ ? `Editor handle count (${count}) is ABOVE the ${ceiling} ceiling ` +
2657
+ `proxy and climbing monotonically (leak in progress): trend ` +
2658
+ `delta ${trend.delta} over ${trend.sampleCount} sample(s). ` +
2659
+ `Save scene work and restart Unity via the Hub now.`
2660
+ : `Editor fd usage is climbing monotonically across samples (leak in ` +
2661
+ `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
2662
+ `sample(s). Save scene work and plan a restart before the count ` +
2663
+ `crosses the ${ceiling}-descriptor ceiling.`
2664
+ : `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
2665
+ `the ${ceiling}-descriptor ceiling. Monitor the trend; if it keeps ` +
2666
+ `climbing across domain reloads, save scene work and restart Unity ` +
2667
+ `via the Hub before the Editor hangs.`,
2193
2668
  }
2194
2669
  : null;
2195
- // Informational (non-alarming) note for the over-ceiling-but-stable case.
2670
+ // Informational note for the remaining soft case: a Windows HandleCount
2671
+ // over the ceiling proxy with no qualifying leak trend.
2196
2672
  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.`
2673
+ ? `Windows handle count (${count}) is above the ${ceiling} fd-ceiling ` +
2674
+ `proxy, but HandleCount covers kernel/GDI/user objects — far broader ` +
2675
+ `than Unix fds — so this is expected on a healthy process. Treat it as ` +
2676
+ `a concern ONLY if the trend becomes a monotonic leak (leaking) — ` +
2677
+ `re-sample after the next domain reload to establish one.`
2205
2678
  : null;
2206
2679
  return sourceResult({
2207
2680
  pid,
2208
2681
  fdCount: count,
2209
2682
  fdMethod,
2210
2683
  approximate,
2684
+ ...(partial ? { partial } : {}),
2211
2685
  ceiling,
2212
2686
  ceilingSource,
2213
2687
  headroom: headroom.headroom,
@@ -2215,12 +2689,12 @@ export class ToolRouter {
2215
2689
  state: headroom.state,
2216
2690
  reliable: headroom.reliable,
2217
2691
  trend,
2218
- samples: samples.map((s) => ({
2692
+ samples: pidSamples.map((s) => ({
2219
2693
  ts: s.ts,
2220
2694
  pid: s.pid,
2221
2695
  count: s.count,
2222
2696
  })),
2223
- sampleCount: samples.length,
2697
+ sampleCount: pidSamples.length,
2224
2698
  launchContextCaveat: LAUNCH_CONTEXT_CAVEAT,
2225
2699
  ...(probeReason !== null ? { probeReason } : {}),
2226
2700
  ...(probeMessage !== null ? { probeMessage } : {}),