unity-open-mcp 1.0.0 → 1.2.3

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 (85) hide show
  1. package/dist/batch-spawn.d.ts +26 -0
  2. package/dist/batch-spawn.js +152 -35
  3. package/dist/batch-spawn.js.map +1 -1
  4. package/dist/capabilities/build-capabilities.d.ts +10 -0
  5. package/dist/capabilities/build-capabilities.js +4 -0
  6. package/dist/capabilities/build-capabilities.js.map +1 -1
  7. package/dist/capabilities/capabilities-view.d.ts +48 -0
  8. package/dist/capabilities/capabilities-view.js +93 -0
  9. package/dist/capabilities/capabilities-view.js.map +1 -0
  10. package/dist/child-supervision.d.ts +34 -0
  11. package/dist/child-supervision.js +110 -0
  12. package/dist/child-supervision.js.map +1 -0
  13. package/dist/cli/cli.js +9 -0
  14. package/dist/cli/cli.js.map +1 -1
  15. package/dist/cli/commands.js +37 -4
  16. package/dist/cli/commands.js.map +1 -1
  17. package/dist/compressible-router.js +12 -1
  18. package/dist/compressible-router.js.map +1 -1
  19. package/dist/constants.d.ts +17 -7
  20. package/dist/constants.js +17 -7
  21. package/dist/constants.js.map +1 -1
  22. package/dist/dialog-dismiss.d.ts +11 -0
  23. package/dist/dialog-dismiss.js +59 -4
  24. package/dist/dialog-dismiss.js.map +1 -1
  25. package/dist/editor-wedge.d.ts +45 -0
  26. package/dist/editor-wedge.js +111 -0
  27. package/dist/editor-wedge.js.map +1 -0
  28. package/dist/event-stream.d.ts +6 -3
  29. package/dist/event-stream.js +62 -5
  30. package/dist/event-stream.js.map +1 -1
  31. package/dist/gate-error.d.ts +2 -0
  32. package/dist/gate-error.js +9 -0
  33. package/dist/gate-error.js.map +1 -1
  34. package/dist/instance-discovery.d.ts +23 -0
  35. package/dist/instance-discovery.js +47 -1
  36. package/dist/instance-discovery.js.map +1 -1
  37. package/dist/live-client.d.ts +23 -5
  38. package/dist/live-client.js +132 -31
  39. package/dist/live-client.js.map +1 -1
  40. package/dist/process-diagnostics.d.ts +51 -23
  41. package/dist/process-diagnostics.js +67 -22
  42. package/dist/process-diagnostics.js.map +1 -1
  43. package/dist/results.d.ts +8 -0
  44. package/dist/results.js +4 -1
  45. package/dist/results.js.map +1 -1
  46. package/dist/routers.js +4 -1
  47. package/dist/routers.js.map +1 -1
  48. package/dist/server.js +8 -2
  49. package/dist/server.js.map +1 -1
  50. package/dist/tool-hint.d.ts +36 -0
  51. package/dist/tool-hint.js +98 -0
  52. package/dist/tool-hint.js.map +1 -0
  53. package/dist/tool-router.d.ts +8 -1
  54. package/dist/tool-router.js +346 -64
  55. package/dist/tool-router.js.map +1 -1
  56. package/dist/tools/agent-capabilities.js +27 -1
  57. package/dist/tools/agent-capabilities.js.map +1 -1
  58. package/dist/tools/baseline-create.js +1 -1
  59. package/dist/tools/baseline-create.js.map +1 -1
  60. package/dist/tools/batch-execute.js +12 -1
  61. package/dist/tools/batch-execute.js.map +1 -1
  62. package/dist/tools/bridge-status.js +21 -1
  63. package/dist/tools/bridge-status.js.map +1 -1
  64. package/dist/tools/execute-csharp.js +11 -1
  65. package/dist/tools/execute-csharp.js.map +1 -1
  66. package/dist/tools/execute-menu.js +33 -2
  67. package/dist/tools/execute-menu.js.map +1 -1
  68. package/dist/tools/index.js +10 -0
  69. package/dist/tools/index.js.map +1 -1
  70. package/dist/tools/invoke-method.js +15 -1
  71. package/dist/tools/invoke-method.js.map +1 -1
  72. package/dist/tools/read-compile-errors.js +30 -9
  73. package/dist/tools/read-compile-errors.js.map +1 -1
  74. package/dist/tools/regression-check.js +2 -1
  75. package/dist/tools/regression-check.js.map +1 -1
  76. package/dist/tools/resource-pressure.js +27 -17
  77. package/dist/tools/resource-pressure.js.map +1 -1
  78. package/dist/tools/run-tests.js +11 -2
  79. package/dist/tools/run-tests.js.map +1 -1
  80. package/dist/tools/scan-all.js +2 -2
  81. package/dist/tools/scan-all.js.map +1 -1
  82. package/dist/unity-log.d.ts +16 -0
  83. package/dist/unity-log.js +60 -9
  84. package/dist/unity-log.js.map +1 -1
  85. package/package.json +1 -1
@@ -1,6 +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, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
3
+ import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly, detectErrorsPredatingEdits, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
4
+ import { toolHintReference } from "./tool-hint.js";
5
+ import { scanForFdExhaustion } from "./editor-wedge.js";
4
6
  import { summarizeProjectHealth, extractProjectHealthIssues } from "./project-health.js";
5
7
  import { countProjectAsmdefs, countAssembliesWithErrors } from "./asmdef-discovery.js";
6
8
  import { buildCapabilities } from "./capabilities/build-capabilities.js";
@@ -8,11 +10,12 @@ import { RULE_CATALOG, FIX_CATALOG } from "./capabilities/rule-catalog.js";
8
10
  import { GROUP_IDS, TOOL_GROUPS, AUTO_ACTIVATE_GROUPS, groupToTools, getGroup, } from "./capabilities/tool-groups.js";
9
11
  import { recommendGroups, } from "./capabilities/intent-groups.js";
10
12
  import { listRules } from "./capabilities/list-rules.js";
13
+ import { viewCapabilities } from "./capabilities/capabilities-view.js";
11
14
  import { generateSkill } from "./skill/generate-skill.js";
12
15
  import { getKnownClientKeys } from "./skill/client-paths.js";
13
16
  import { ALL_TOOLS } from "./tools/index.js";
14
17
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
15
- import { PORT_ENV_VAR, TYPED_EDITOR_ACTIVATE_INSTRUCTION } from "./constants.js";
18
+ import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT } from "./constants.js";
16
19
  import { findUnityForProject, readProcessCommandLine } from "./running-unity.js";
17
20
  // M31-optimizations Plan 1 / L14 — opt-in route-logging gate. Resolved once at
18
21
  // module load into a boolean; the per-dispatch log sites read the boolean
@@ -51,7 +54,7 @@ const defaultHubBackend = {
51
54
  setInstallPath: (path) => setInstallPath(path),
52
55
  };
53
56
  import { BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS } from "./batch-spawn.js";
54
- import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, } from "./output-profile.js";
57
+ import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, isOutputProfile, } from "./output-profile.js";
55
58
  /**
56
59
  * Single source of truth for the `_source` tag. Every route-tagging site goes
57
60
  * through one of the two helpers below so live / offline / local stay
@@ -339,7 +342,25 @@ function groupLiveRefsByKind(paths) {
339
342
  }
340
343
  // Operator-facing "what to do next" hint for each coarse status. Kept short
341
344
  // and action-oriented so the Validation Suite can render it inline.
342
- function bridgeStatusNextStep(status) {
345
+ function bridgeStatusNextStep(status, wedge) {
346
+ if (status === "wedged") {
347
+ return wedge?.reason === "main_thread_wedged"
348
+ ? "Unity's main thread is wedged — the HTTP listener still answers /ping, but the " +
349
+ "editor heartbeat has stopped, which is the signature of a MODAL DIALOG blocking " +
350
+ "the message pump (material/scene converters and several ExecuteMenuItem paths " +
351
+ "open one). This is NOT a compile failure and NOT a dead bridge assembly — a dead " +
352
+ "assembly has no listener at all. It cannot self-heal and no tool can dismiss the " +
353
+ "dialog: an operator must dismiss it in the Unity UI, after which queued calls " +
354
+ "resume. Prefer non-interactive APIs over unity_open_mcp_execute_menu for " +
355
+ "converter-style menus."
356
+ : "The Unity Editor hit the Bee build driver's file-descriptor exhaustion — the " +
357
+ "process is alive and the listener answers, but it can no longer BUILD: " +
358
+ "Library/ScriptAssemblies stops updating, so C# edits never take effect and " +
359
+ "unity_open_mcp_execute_csharp silently runs the previous assembly. Do not trust " +
360
+ "any result computed since. Call unity_open_mcp_read_compile_errors to confirm " +
361
+ "the editor_fd_exhaustion issue, save scene work, then restart the Editor " +
362
+ "(unity_open_mcp_restart_editor with confirm: true, or manually).";
363
+ }
343
364
  switch (status) {
344
365
  case "running":
345
366
  return "Bridge is ready. Proceed with live-only MCP tools.";
@@ -371,7 +392,66 @@ function bridgeStatusNextStep(status) {
371
392
  "Bridge) and confirm it is started.";
372
393
  }
373
394
  }
374
- function bridgeStatusRecoveryHint(status) {
395
+ /**
396
+ * specs/feedback.md 2026-08-24 — wire-contract staleness block.
397
+ *
398
+ * The field report: a blocker fixed and shipped on 2026-08-17 (registry tools
399
+ * rejecting the transport envelope keys, which made `editor_status` uncallable
400
+ * with default args) recurred against an installed bridge that still reported
401
+ * `bridgeVersion "1.0.0"` — the same string the FIXED source reports, because
402
+ * the package semver does not move for a wire-contract fix. "Your install is
403
+ * stale" and "this regressed" were therefore indistinguishable at the call
404
+ * site, and the agent had no way to pick between reinstalling and filing a bug.
405
+ *
406
+ * `BridgeSession.WireContract` is bumped for every observable change to the
407
+ * request/response contract, so comparing it against
408
+ * {@link EXPECTED_BRIDGE_WIRE_CONTRACT} answers that question directly. A
409
+ * bridge that predates the field reports no `wireContract` at all — `bridge`
410
+ * is then null and `stale` is true, which is the correct reading (it is older
411
+ * than revision 1 by definition).
412
+ *
413
+ * Returns null when /ping was unreachable: there is no bridge to compare, and a
414
+ * bogus "stale" on an offline bridge would be worse than silence.
415
+ */
416
+ function wireContractBlock(reachable, reported) {
417
+ if (!reachable)
418
+ return null;
419
+ const bridge = typeof reported === "number" ? reported : null;
420
+ const stale = bridge === null || bridge < EXPECTED_BRIDGE_WIRE_CONTRACT;
421
+ return {
422
+ bridge,
423
+ expected: EXPECTED_BRIDGE_WIRE_CONTRACT,
424
+ stale,
425
+ note: stale
426
+ ? "The installed Unity bridge package is OLDER than this MCP server's wire " +
427
+ "contract" +
428
+ (bridge === null
429
+ ? " (it does not report `wireContract` at all, so it predates revision 1)"
430
+ : ` (revision ${bridge} vs ${EXPECTED_BRIDGE_WIRE_CONTRACT})`) +
431
+ ". A tool failing in a way the docs say is fixed is almost certainly " +
432
+ "this staleness, not a regression: reinstall/update the bridge package " +
433
+ "in Unity (Package Manager > the unity-open-mcp-bridge git URL) and " +
434
+ "retry before filing anything. bridgeVersion alone cannot tell you " +
435
+ "this — it does not move for a wire-contract fix."
436
+ : null,
437
+ };
438
+ }
439
+ function bridgeStatusRecoveryHint(status, wedge) {
440
+ if (status === "wedged") {
441
+ return wedge?.reason === "main_thread_wedged"
442
+ ? {
443
+ tool: "unity_open_mcp_editor_status",
444
+ reason: "Main thread wedged (likely a modal dialog): /ping answers but the " +
445
+ "editor heartbeat stopped. No tool can dismiss a modal — an operator " +
446
+ "must close it in the Unity UI, then editor_status confirms recovery.",
447
+ }
448
+ : {
449
+ tool: "unity_open_mcp_read_compile_errors",
450
+ reason: "editor_fd_exhaustion in the freshest Editor.log — the Editor answers " +
451
+ "HTTP but can no longer build, so C# edits never take effect. Confirm " +
452
+ "with read_compile_errors, then restart the Editor.",
453
+ };
454
+ }
375
455
  if (status === "dead_bridge") {
376
456
  return {
377
457
  tool: "unity_open_mcp_read_compile_errors",
@@ -490,10 +570,21 @@ export class ToolRouter {
490
570
  }
491
571
  // Compact drill-down reads: offline-first for text-serialized assets, fall
492
572
  // back to live bridge for binary formats. The compressible-router handles
493
- // the source selection internally.
573
+ // the source selection internally and tags the payload `_source`
574
+ // ("offline" when it was served from the disk parser without touching the
575
+ // bridge; error results tag their origin too — "local" for caller-side
576
+ // validation, "offline" for a failed offline-first attempt with the
577
+ // bridge down). Derive `_route` from that tag: stamping route:"live"
578
+ // unconditionally mislabeled offline-served reads as bridge responses —
579
+ // contradictory metadata that pointed an agent's diagnostics at the
580
+ // bridge for data it never produced.
494
581
  if (isCompressible(toolName)) {
495
582
  const result = await routeCompressible(toolName, args, live, this.modelCache, this.projectPath);
496
- return injectRouteMeta(result, { route: "live" });
583
+ const source = parseResultBody(result)?._source;
584
+ const meta = source === "offline" || source === "local"
585
+ ? { route: source }
586
+ : { route: "live" };
587
+ return injectRouteMeta(result, meta);
497
588
  }
498
589
  const canBatch = this.batch.isBatchTool(toolName);
499
590
  const isPing = toolName === "unity_open_mcp_ping";
@@ -561,6 +652,11 @@ export class ToolRouter {
561
652
  mode: "batch",
562
653
  compiling: false,
563
654
  isPlaying: false,
655
+ // feedback 2026-08-17 — provenance marker distinguishing this
656
+ // synthesized fallback (no probe was possible; the live route was
657
+ // unavailable) from the live route's verified probe answer
658
+ // (source:"probe" + asOf).
659
+ source: "unprobed",
564
660
  _route: {
565
661
  route: "batch",
566
662
  fallbackReason: "live_unavailable",
@@ -700,6 +796,16 @@ export class ToolRouter {
700
796
  // newest Library/ScriptAssemblies/*.dll mtime against Assets/**/*.cs source
701
797
  // mtimes and surface staleAssembly when any source is newer than the DLL.
702
798
  const staleAsm = detectStaleAssembly(this.projectPath);
799
+ // specs/feedback.md 2026-08-14 — "these errors may predate your edits".
800
+ // Three consecutive reads returned the same error block for code that had
801
+ // already been fixed, with no staleness signal: Editor.log's FILE mtime
802
+ // was fresh (asset imports keep appending) while the error BLOCK inside it
803
+ // was old, so the staleLog heuristic could not fire. The built-assembly
804
+ // mtime is the anchor that works — a cited source newer than the newest
805
+ // built DLL proves no compile has COMPLETED since the edit.
806
+ const errorsPredateEdits = errors.length > 0
807
+ ? detectErrorsPredatingEdits(errors.map((e) => e.file), this.projectPath, staleAsm.dllMtimeMs)
808
+ : null;
703
809
  // feedback-04-08-opus §2 — log-authorship cross-check. A batch run of a
704
810
  // NEWER Unity writes a log whose errors (e.g. an API deprecation that is
705
811
  // only an error on that version) CANNOT occur in the live editor. Parse the
@@ -729,7 +835,17 @@ export class ToolRouter {
729
835
  : "no_errors_found";
730
836
  const errorsMayNotApply = health.unhealthy &&
731
837
  (!!staleLog || authorshipCheck.versionMismatch);
732
- const status = errorsMayNotApply ? "stale_log" : logVerdict;
838
+ // feedback 2026-08-17 — a clean bill of health read from the ROTATED log
839
+ // is not a clean bill of health. When logSource is a prev_log_* value the
840
+ // resolver fell back to Editor-prev.log while a live Editor writes a
841
+ // different file; an agent reading only `status` got a false green. A
842
+ // no-errors read from a rotated log is explicitly `indeterminate`.
843
+ const prevLogRead = resolvedLog.reason.startsWith("prev_log_");
844
+ const status = errorsMayNotApply
845
+ ? "stale_log"
846
+ : logVerdict === "no_errors_found" && prevLogRead
847
+ ? "indeterminate"
848
+ : logVerdict;
733
849
  // specs/feedback.md 2026-08-12 — partial-compile signal. Unity compiles
734
850
  // assemblies in dependency order; when an early assembly fails the pipeline
735
851
  // may not reach dependent ones, so the reported errors can be a SUBSET of
@@ -749,6 +865,54 @@ export class ToolRouter {
749
865
  const assembliesWithErrors = partialCompileLikely
750
866
  ? countAssembliesWithErrors(errors.map((e) => e.file), this.projectPath)
751
867
  : 0;
868
+ // Headline assembly. Precedence (most severe first):
869
+ // 1. errorsMayNotApply — stale log / version mismatch: the errors may
870
+ // not apply at all (feedback-fable-04-08 §4).
871
+ // 2. errorsPredateEdits — errors + a stale assembly: the error block
872
+ // was written by a compile that predates the on-disk edits (no
873
+ // compile has COMPLETED since: every built DLL is older than a cited
874
+ // source). The log-mtime staleness heuristic cannot see this — asset
875
+ // imports keep appending to Editor.log, so the FILE is fresh while
876
+ // the error BLOCK inside it is old. Say so in the headline rather
877
+ // than letting the agent re-read code it has already fixed
878
+ // (specs/feedback.md 2026-08-14).
879
+ // 3. feedback 2026-08-17 — errors + staleAssembly WITHOUT the
880
+ // cited-files proof: the whole-Assets scan still shows a source
881
+ // newer than the newest built DLL, so the error block may come from
882
+ // an earlier compile. The 2026-08-14 fix covered only the
883
+ // errorsPredateEdits shape; this extends the headline caveat to the
884
+ // staleAssembly shape (the field report's 12 stale CS errors had
885
+ // staleAssembly:true but the headline stated them as fact).
886
+ // 4. indeterminate (feedback 2026-08-17) — no errors, but read from the
887
+ // ROTATED log: not a verified clean bill of health.
888
+ // 5. the plain health headline.
889
+ const headline = errorsMayNotApply
890
+ ? `${errors.length} error(s) in Editor.log, but the log appears ` +
891
+ "STALE or authored by a different Unity — these errors may NOT " +
892
+ "apply to the running editor. Do NOT act on them until a genuine " +
893
+ `recompile (${toolHintReference("unity_open_mcp_recompile_scripts")}) ` +
894
+ "confirms them."
895
+ : errorsPredateEdits
896
+ ? `${health.headline} NOTE: these errors may PREDATE your edits — ` +
897
+ `${errorsPredateEdits.files.length} cited source file(s) are newer ` +
898
+ "than the newest built assembly, so no compile has completed since " +
899
+ "you changed them. Force a recompile " +
900
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) and ` +
901
+ "re-read before acting on them."
902
+ : hasErrors && staleAsm.staleAssembly
903
+ ? `${health.headline} NOTE: the error block may PREDATE the current ` +
904
+ `sources — ${staleAsm.newerSources.length} Assets/**/*.cs source(s) ` +
905
+ "are newer than the newest built assembly, so these errors may come " +
906
+ "from an earlier compile. Force a recompile " +
907
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) and ` +
908
+ "re-read before acting on them."
909
+ : status === "indeterminate"
910
+ ? "No errors found, but this is NOT a verified clean bill of " +
911
+ `health: the read came from the ROTATED log (${resolvedLog.reason === "prev_log_live_editor" ? "Editor-prev.log chosen because a live editor holds the project" : "Editor-prev.log fallback"}) ` +
912
+ "while a live Editor may be writing a different log. Confirm via " +
913
+ "the live bridge (unity_open_mcp_editor_status / a live tool " +
914
+ "probe) or re-read after the Editor settles."
915
+ : health.headline;
752
916
  return sourceResult({
753
917
  status,
754
918
  // When the errors may not apply (stale log / version mismatch), the
@@ -758,14 +922,11 @@ export class ToolRouter {
758
922
  // log verdict is preserved in `logVerdict` for callers that want the
759
923
  // literal signal regardless of freshness.
760
924
  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 } : {}),
925
+ headline,
926
+ // Raw verdict preserved whenever the top-level status was rewritten
927
+ // (stale_log downgrade OR the indeterminate prev-log case), so callers
928
+ // can still read what the log literally said.
929
+ ...(errorsMayNotApply || status === "indeterminate" ? { logVerdict } : {}),
769
930
  errorCount: errors.length,
770
931
  errors,
771
932
  // specs/feedback.md 2026-08-12 — present only when there are errors AND
@@ -816,6 +977,26 @@ export class ToolRouter {
816
977
  staleAssemblyNewerSources: staleAsm.newerSources,
817
978
  }
818
979
  : {}),
980
+ // specs/feedback.md 2026-08-14 — machine-readable sibling of the
981
+ // headline caveat above. Present ONLY when errors were returned AND at
982
+ // least one file they cite is newer than the newest built assembly, so
983
+ // the error block cannot reflect the current source. Distinct from
984
+ // `staleLogSuspected` (log-file mtime) and `staleAssembly` (whole
985
+ // Assets/ scan): this names the cited files the agent is about to
986
+ // re-read.
987
+ ...(errorsPredateEdits
988
+ ? {
989
+ errorsMayPredateEdits: true,
990
+ errorsMayPredateEditsFiles: errorsPredateEdits.files,
991
+ errorsMayPredateEditsHint: "These errors were parsed from a compile that ran BEFORE the " +
992
+ "listed files were last edited (each is newer than the newest " +
993
+ "Library/ScriptAssemblies/*.dll, so no compile has completed " +
994
+ "since). Do not re-read or re-fix that code on the strength of " +
995
+ "this block — force a recompile " +
996
+ `(${toolHintReference("unity_open_mcp_recompile_scripts")}) ` +
997
+ "and read again.",
998
+ }
999
+ : {}),
819
1000
  // feedback-04-08-opus §2 — log authorship. Emitted whenever the header
820
1001
  // was parseable (the live editor's long tail usually has no header, so
821
1002
  // these stay absent in the common case). On a version mismatch the hint
@@ -945,6 +1126,12 @@ export class ToolRouter {
945
1126
  ? args.kind
946
1127
  : undefined;
947
1128
  const includePlanned = args.include_planned !== false;
1129
+ // specs/feedback.md 2026-08-24 — the tool surface defaults to `compact`
1130
+ // (the unfolded catalog is ~513 KB, which overflows a typical harness
1131
+ // per-result ceiling). `buildCapabilities` itself keeps returning the full
1132
+ // shape so its other caller (generate_skill) is unaffected; the fold is a
1133
+ // view applied here.
1134
+ const profile = isOutputProfile(args.profile) ? args.profile : "compact";
948
1135
  // M18 Plan 2 / T18.2.3 — when the bridge is live, probe its compiled-
949
1136
  // state tool inventory so per-group `available` reflects whether each
950
1137
  // domain dependency compiled in (e.g. UNITY_OPEN_MCP_EXT_NAVIGATION).
@@ -970,7 +1157,11 @@ export class ToolRouter {
970
1157
  fixes: FIX_CATALOG,
971
1158
  availableBridgeTools,
972
1159
  }, { kind, includePlanned });
973
- return sourceResult(result, "local");
1160
+ return sourceResult(viewCapabilities(result, {
1161
+ profile,
1162
+ page_size: typeof args.page_size === "number" ? args.page_size : undefined,
1163
+ cursor: typeof args.cursor === "string" ? args.cursor : undefined,
1164
+ }), "local");
974
1165
  }
975
1166
  async routeListRules(args) {
976
1167
  const assetKind = typeof args.asset_kind === "string" ? args.asset_kind : undefined;
@@ -1474,8 +1665,52 @@ export class ToolRouter {
1474
1665
  if (proc)
1475
1666
  unityProcessPid = proc.pid;
1476
1667
  }
1668
+ // specs/feedback.md 2026-08-14 — wedge detection, evaluated BEFORE the
1669
+ // existing token ladder because both wedges masquerade as one of its
1670
+ // healthy-looking outcomes.
1671
+ //
1672
+ // (a) main_thread_wedged. `dead_bridge` means "live PID + stale
1673
+ // heartbeat". A modal dialog produces exactly that (the heartbeat is
1674
+ // written from EditorApplication.update, which a blocked pump stops
1675
+ // running) — but a dead bridge ASSEMBLY cannot answer /ping at all,
1676
+ // because the listener lives in the assembly that failed to compile.
1677
+ // So a reachable /ping alongside a stale heartbeat rules out the
1678
+ // safe-mode diagnosis and identifies a stalled main thread instead.
1679
+ // (b) editor_fd_exhaustion. Process, heartbeat and /ping all survive a
1680
+ // dead build driver, so nothing in the existing ladder can see it.
1681
+ // One regex over the freshest log tail can (the same scan
1682
+ // read_compile_errors runs), and it only runs when the ladder would
1683
+ // otherwise report a healthy editor — the offline/stopped paths have
1684
+ // no editor to be wedged.
1685
+ let wedge = null;
1686
+ if (classification === "dead_bridge" && pingReachable) {
1687
+ wedge = {
1688
+ reason: "main_thread_wedged",
1689
+ detail: "The instance lock's heartbeat is stale (the editor main thread stopped " +
1690
+ "ticking) but the bridge listener still answers /ping. A failed bridge " +
1691
+ "assembly would take the listener down with it, so this is a blocked main " +
1692
+ "thread — almost always a modal dialog — not Safe Mode.",
1693
+ };
1694
+ }
1695
+ else if (pingReachable && connected) {
1696
+ const fd = scanForFdExhaustion(this.projectPath, this.resolveLiveEditorPid(), this.globalLogPathOverride);
1697
+ if (fd.present) {
1698
+ wedge = {
1699
+ reason: "editor_fd_exhaustion",
1700
+ detail: "The freshest Editor.log carries the Bee build-driver file-descriptor " +
1701
+ "exhaustion signature. The Editor answers HTTP but cannot build: " +
1702
+ "Library/ScriptAssemblies stops updating, so C# edits never take effect " +
1703
+ "and execute_csharp keeps running the previous assembly.",
1704
+ ...(fd.logPath ? { logPath: fd.logPath } : {}),
1705
+ ...(fd.raw ? { raw: fd.raw } : {}),
1706
+ };
1707
+ }
1708
+ }
1477
1709
  let status;
1478
- if (classification === "dead_bridge") {
1710
+ if (wedge !== null) {
1711
+ status = "wedged";
1712
+ }
1713
+ else if (classification === "dead_bridge") {
1479
1714
  status = "dead_bridge";
1480
1715
  }
1481
1716
  else if (compiling) {
@@ -1517,7 +1752,13 @@ export class ToolRouter {
1517
1752
  // Safe Mode / compile failure", not the generic "stopped" the bare
1518
1753
  // status used to suggest.
1519
1754
  classification,
1520
- recoveryHint: bridgeStatusRecoveryHint(status),
1755
+ recoveryHint: bridgeStatusRecoveryHint(status, wedge),
1756
+ // specs/feedback.md 2026-08-14 — present ONLY when the editor is wedged.
1757
+ // `classification` keeps mirroring the instance lock verbatim (healthy |
1758
+ // reloading | dead_bridge | gone) so that contract is unchanged; the
1759
+ // wedge is carried by the tool's own `status` token plus this block, and
1760
+ // `ready` is false either way.
1761
+ ...(wedge !== null ? { wedged: wedge } : {}),
1521
1762
  instance: {
1522
1763
  lockPath: lockOnDisk,
1523
1764
  classification,
@@ -1535,10 +1776,15 @@ export class ToolRouter {
1535
1776
  isPlaying: pingBody?.isPlaying ?? null,
1536
1777
  unityVersion: pingBody?.unityVersion ?? null,
1537
1778
  bridgeVersion: pingBody?.bridgeVersion ?? null,
1779
+ wireContract: pingBody?.wireContract ?? null,
1538
1780
  mode: pingBody?.mode ?? null,
1539
1781
  }
1540
1782
  : { reachable: false },
1541
- nextStep: bridgeStatusNextStep(status),
1783
+ // specs/feedback.md 2026-08-24 — null when /ping was unreachable; a
1784
+ // `stale: true` block is the signal to reinstall the Unity package
1785
+ // rather than file a regression.
1786
+ wireContract: wireContractBlock(pingReachable, pingBody?.wireContract),
1787
+ nextStep: bridgeStatusNextStep(status, wedge),
1542
1788
  };
1543
1789
  // bridge_status never reports an error — even a stopped bridge is a
1544
1790
  // successful status read. _source=local because the synthesis happens in
@@ -1894,74 +2140,110 @@ export class ToolRouter {
1894
2140
  const probe = countFileDescriptors(pid);
1895
2141
  const count = probe.count;
1896
2142
  const approximate = "approximate" in probe ? probe.approximate : false;
1897
- const headroom = computeFdHeadroom(count, approximate, ceiling);
2143
+ const partial = "partial" in probe ? probe.partial === true : false;
2144
+ // The threshold softening is about WHAT the metric counts, not about how
2145
+ // precise the number is. Only Windows HandleCount counts more than fds; a
2146
+ // timed-out lsof is imprecise but still counts real fds (a lower bound),
2147
+ // so it takes the normal warn/critical thresholds — softening it made a
2148
+ // partial 900/1024 report `ok` on the very platform this tool targets.
2149
+ const broaderThanFds = probe.method === "handle_count";
2150
+ const headroom = computeFdHeadroom(count, broaderThanFds, ceiling);
1898
2151
  const ts = Date.now();
1899
2152
  this.sessionState.recordFdSample({ ts, pid, count });
1900
2153
  const samples = this.sessionState.fdSamplesSnapshot();
1901
2154
  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:
2155
+ // Samples are per-PID (as the tool description states): after an Editor
2156
+ // restart the ring still holds the previous process's entries — report
2157
+ // only this PID's series. The trend already analyzes the latest PID only.
2158
+ const pidSamples = samples.filter((s) => s.pid === pid);
2159
+ // 4. Decide whether to raise an operator warning. Alarm on:
2160
+ // - a real fd count at/past the ceiling (`over_ceiling` from lsof//proc):
2161
+ // POSIX allocates the lowest free descriptor number, so every NEW
2162
+ // descriptor now lands above the ceiling and the next IOSelector
2163
+ // registration (any async pipe/socket — e.g. the Bee build driver)
2164
+ // can hang the Editor. A fresh healthy Unity 6 editor sits at ~160
2165
+ // fds; real counts past 1024 are never legitimate now that
2166
+ // countLsofFdRows excludes the cwd/txt/mmap rows that once inflated
2167
+ // the count.
1907
2168
  // - a monotonic leak (the actionable trend signal), or
1908
2169
  // - 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).
2170
+ // The one downgraded case is a Windows HandleCount probe over the
2171
+ // ceiling: handle counts cover kernel/GDI/user objects and routinely
2172
+ // exceed 1024 on a healthy process, so it gets a `pressureNote` and
2173
+ // alarms only on the trend. `rising` is any net-positive delta that is
2174
+ // NOT a qualifying leak — it fires on ordinary drift, so it never
2175
+ // alarms on its own.
1914
2176
  const fdMethod = probe.method;
1915
2177
  const probeReason = "reason" in probe ? probe.reason : null;
1916
2178
  const probeMessage = "message" in probe ? probe.message : null;
1917
- const shouldWarn = trend.state === "leaking" ||
2179
+ // A partial (timed-out) lsof listing is a LOWER BOUND on real fds, so an
2180
+ // over-ceiling verdict from it still stands; only the Windows HandleCount
2181
+ // metric measures something broader than fds. Same predicate that picked
2182
+ // the headroom thresholds above, so the two can never disagree.
2183
+ const overCeilingRealFds = headroom.state === "over_ceiling" && !broaderThanFds;
2184
+ const shouldWarn = overCeilingRealFds ||
2185
+ trend.state === "leaking" ||
1918
2186
  headroom.state === "warn" ||
1919
2187
  headroom.state === "critical";
1920
2188
  const warningState = shouldWarn
1921
2189
  ? {
1922
- level: headroom.state === "critical"
2190
+ level: headroom.state === "critical" || overCeilingRealFds
1923
2191
  ? "critical"
1924
2192
  : trend.state === "leaking"
1925
2193
  ? "leaking"
1926
2194
  : "warn",
1927
- message: headroom.state === "critical"
1928
- ? `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
1929
- `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
1930
- `likely to trip the Bee build-driver hang. Save scene work and restart ` +
1931
- `Unity via the Hub now, before the Editor hangs.`
1932
- : trend.state === "leaking"
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.`
1943
- : `Editor fd usage is at ${Math.round(headroom.pressureRatio * 100)}% of ` +
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.`,
2195
+ message: overCeilingRealFds
2196
+ ? `Editor fd count (${count}) is at or above the ${ceiling}-descriptor ` +
2197
+ `Mono IOSelector ceiling. Every newly allocated descriptor number now ` +
2198
+ `lands above the ceiling, so the next async pipe/socket registration ` +
2199
+ `(e.g. the Bee build driver's IPC) can hang the Editor mid-build` +
2200
+ (trend.state === "leaking"
2201
+ ? ` — and the count is still climbing (trend delta ${trend.delta} ` +
2202
+ `over ${trend.sampleCount} sample(s))`
2203
+ : "") +
2204
+ `. Save scene work and restart Unity via the Hub now.`
2205
+ : headroom.state === "critical"
2206
+ ? broaderThanFds
2207
+ ? `Windows handle count is at ${Math.floor(headroom.pressureRatio * 100)}% ` +
2208
+ `of the ${ceiling}-descriptor ceiling proxy. HandleCount covers ` +
2209
+ `kernel/GDI/user objects — far broader than fds — so this is a soft ` +
2210
+ `signal, not fd risk: watch the trend, and treat a monotonic climb ` +
2211
+ `(trend.state "leaking") as the real alarm.`
2212
+ : `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
2213
+ `the ${ceiling}-descriptor ceiling — the next domain reload is ` +
2214
+ `likely to trip the Bee build-driver hang. Save scene work and restart ` +
2215
+ `Unity via the Hub now, before the Editor hangs.`
2216
+ : trend.state === "leaking"
2217
+ ? headroom.state === "over_ceiling"
2218
+ ? `Editor handle count (${count}) is ABOVE the ${ceiling} ceiling ` +
2219
+ `proxy and climbing monotonically (leak in progress): trend ` +
2220
+ `delta ${trend.delta} over ${trend.sampleCount} sample(s). ` +
2221
+ `Save scene work and restart Unity via the Hub now.`
2222
+ : `Editor fd usage is climbing monotonically across samples (leak in ` +
2223
+ `progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
2224
+ `sample(s). Save scene work and plan a restart before the count ` +
2225
+ `crosses the ${ceiling}-descriptor ceiling.`
2226
+ : `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
2227
+ `the ${ceiling}-descriptor ceiling. Monitor the trend; if it keeps ` +
2228
+ `climbing across domain reloads, save scene work and restart Unity ` +
2229
+ `via the Hub before the Editor hangs.`,
1947
2230
  }
1948
2231
  : null;
1949
- // Informational (non-alarming) note for the over-ceiling-but-stable case.
2232
+ // Informational note for the remaining soft case: a Windows HandleCount
2233
+ // over the ceiling proxy with no qualifying leak trend.
1950
2234
  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.`
2235
+ ? `Windows handle count (${count}) is above the ${ceiling} fd-ceiling ` +
2236
+ `proxy, but HandleCount covers kernel/GDI/user objects — far broader ` +
2237
+ `than Unix fds — so this is expected on a healthy process. Treat it as ` +
2238
+ `a concern ONLY if the trend becomes a monotonic leak (leaking) — ` +
2239
+ `re-sample after the next domain reload to establish one.`
1959
2240
  : null;
1960
2241
  return sourceResult({
1961
2242
  pid,
1962
2243
  fdCount: count,
1963
2244
  fdMethod,
1964
2245
  approximate,
2246
+ ...(partial ? { partial } : {}),
1965
2247
  ceiling,
1966
2248
  ceilingSource,
1967
2249
  headroom: headroom.headroom,
@@ -1969,12 +2251,12 @@ export class ToolRouter {
1969
2251
  state: headroom.state,
1970
2252
  reliable: headroom.reliable,
1971
2253
  trend,
1972
- samples: samples.map((s) => ({
2254
+ samples: pidSamples.map((s) => ({
1973
2255
  ts: s.ts,
1974
2256
  pid: s.pid,
1975
2257
  count: s.count,
1976
2258
  })),
1977
- sampleCount: samples.length,
2259
+ sampleCount: pidSamples.length,
1978
2260
  launchContextCaveat: LAUNCH_CONTEXT_CAVEAT,
1979
2261
  ...(probeReason !== null ? { probeReason } : {}),
1980
2262
  ...(probeMessage !== null ? { probeMessage } : {}),