unity-open-mcp 1.2.3 → 1.4.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 +34 -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 +83 -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 +116 -0
  33. package/dist/cli/setup-command.js +696 -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 +56 -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/project-path.d.ts +99 -0
  76. package/dist/project-path.js +171 -0
  77. package/dist/project-path.js.map +1 -0
  78. package/dist/release-lookup.d.ts +32 -0
  79. package/dist/release-lookup.js +147 -0
  80. package/dist/release-lookup.js.map +1 -0
  81. package/dist/routers.d.ts +17 -3
  82. package/dist/routers.js +29 -8
  83. package/dist/routers.js.map +1 -1
  84. package/dist/running-unity.d.ts +21 -0
  85. package/dist/running-unity.js +50 -0
  86. package/dist/running-unity.js.map +1 -1
  87. package/dist/schema-defaults.d.ts +4 -3
  88. package/dist/schema-defaults.js +20 -6
  89. package/dist/schema-defaults.js.map +1 -1
  90. package/dist/server-instructions.js +7 -1
  91. package/dist/server-instructions.js.map +1 -1
  92. package/dist/server.d.ts +10 -3
  93. package/dist/server.js +24 -25
  94. package/dist/server.js.map +1 -1
  95. package/dist/setup/codex-toml.d.ts +23 -0
  96. package/dist/setup/codex-toml.js +514 -0
  97. package/dist/setup/codex-toml.js.map +1 -0
  98. package/dist/setup/portable-config.d.ts +59 -0
  99. package/dist/setup/portable-config.js +241 -0
  100. package/dist/setup/portable-config.js.map +1 -0
  101. package/dist/skill/SKILL.md +68 -0
  102. package/dist/skill/client-paths.js +2 -4
  103. package/dist/skill/client-paths.js.map +1 -1
  104. package/dist/skill/copy-references.d.ts +2 -0
  105. package/dist/skill/copy-references.js +16 -0
  106. package/dist/skill/copy-references.js.map +1 -0
  107. package/dist/skill/generate-skill.js +5 -3
  108. package/dist/skill/generate-skill.js.map +1 -1
  109. package/dist/skill/references/batch-and-gates.md +142 -0
  110. package/dist/skill/references/compile-and-safe-mode.md +163 -0
  111. package/dist/skill/references/discovery-and-groups.md +30 -0
  112. package/dist/skill/references/routing-and-lifecycle.md +62 -0
  113. package/dist/skill/references/senses-and-tests.md +41 -0
  114. package/dist/skill/references/yaml-and-offline-work.md +33 -0
  115. package/dist/templates/mcp-wrapper.sh +35 -0
  116. package/dist/tool-contract.d.ts +8 -0
  117. package/dist/tool-contract.js +253 -0
  118. package/dist/tool-contract.js.map +1 -0
  119. package/dist/tool-router.d.ts +62 -7
  120. package/dist/tool-router.js +455 -17
  121. package/dist/tool-router.js.map +1 -1
  122. package/dist/tool-session-state.d.ts +6 -0
  123. package/dist/tool-session-state.js +14 -0
  124. package/dist/tool-session-state.js.map +1 -1
  125. package/dist/tools/agent-capabilities.js +9 -0
  126. package/dist/tools/agent-capabilities.js.map +1 -1
  127. package/dist/tools/batch-execute.js +17 -14
  128. package/dist/tools/batch-execute.js.map +1 -1
  129. package/dist/tools/bridge-status.js +4 -5
  130. package/dist/tools/bridge-status.js.map +1 -1
  131. package/dist/tools/compile-check.js +6 -4
  132. package/dist/tools/compile-check.js.map +1 -1
  133. package/dist/tools/component-get.js +2 -2
  134. package/dist/tools/component-get.js.map +1 -1
  135. package/dist/tools/component-modify.js +4 -4
  136. package/dist/tools/component-modify.js.map +1 -1
  137. package/dist/tools/execute-csharp.js +8 -4
  138. package/dist/tools/execute-csharp.js.map +1 -1
  139. package/dist/tools/gameobject-modify.js +2 -1
  140. package/dist/tools/gameobject-modify.js.map +1 -1
  141. package/dist/tools/index.d.ts +2 -0
  142. package/dist/tools/index.js +10 -2
  143. package/dist/tools/index.js.map +1 -1
  144. package/dist/tools/inputsim-key.js +12 -14
  145. package/dist/tools/inputsim-key.js.map +1 -1
  146. package/dist/tools/inputsim-pointer.js +3 -3
  147. package/dist/tools/inputsim-pointer.js.map +1 -1
  148. package/dist/tools/inputsim-step.js +7 -8
  149. package/dist/tools/inputsim-step.js.map +1 -1
  150. package/dist/tools/inputsim-touch.js +9 -10
  151. package/dist/tools/inputsim-touch.js.map +1 -1
  152. package/dist/tools/jobs.d.ts +40 -0
  153. package/dist/tools/jobs.js +18 -0
  154. package/dist/tools/jobs.js.map +1 -0
  155. package/dist/tools/manage-tools.js +10 -5
  156. package/dist/tools/manage-tools.js.map +1 -1
  157. package/dist/tools/project-commands.d.ts +40 -0
  158. package/dist/tools/project-commands.js +23 -0
  159. package/dist/tools/project-commands.js.map +1 -0
  160. package/dist/tools/read-compile-errors.js +14 -93
  161. package/dist/tools/read-compile-errors.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
@@ -24,7 +24,7 @@
24
24
  // deps beyond MCP SDK"): only child_process and os.
25
25
  import { execFile, execFileSync } from "node:child_process";
26
26
  import { platform as nodePlatform } from "node:os";
27
- import { DIALOG_TITLE_FRAGMENTS, parseDialogPolicy, preferenceTokensForPolicy, genericFallbackTokens, blockedKindsForPolicy, } from "./dialog-policy.js";
27
+ import { DIALOG_TITLE_FRAGMENTS, DIALOG_FOCUSED_BUTTON_TOKENS, parseDialogPolicy, preferenceTokensForPolicy, genericFallbackTokens, blockedKindsForPolicy, titleMatchesDialogKind, UNITY_EDITOR_WINDOW_TITLE_MARKER, } from "./dialog-policy.js";
28
28
  /**
29
29
  * Window-title fragments matched against the Unity launch-errors dialog.
30
30
  * Kept as the M13 T4.5 back-compat export (the launch-errors subset of
@@ -120,7 +120,7 @@ export async function tryDismissDialog(opts) {
120
120
  function buildTokenTable(opts) {
121
121
  const kinds = {};
122
122
  for (const kind of Object.keys(DIALOG_TITLE_FRAGMENTS)) {
123
- const tokens = preferenceTokensForPolicy(kind, opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss);
123
+ const tokens = preferenceTokensForPolicy(kind, opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch);
124
124
  kinds[kind] = {
125
125
  fragments: [...DIALOG_TITLE_FRAGMENTS[kind]],
126
126
  tokens: tokens === null ? null : [...tokens],
@@ -129,7 +129,7 @@ function buildTokenTable(opts) {
129
129
  return {
130
130
  kinds,
131
131
  blocked: [
132
- ...blockedKindsForPolicy(opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss),
132
+ ...blockedKindsForPolicy(opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch),
133
133
  ],
134
134
  genericTokens: [...genericFallbackTokens(opts.policy)],
135
135
  };
@@ -145,8 +145,9 @@ function buildTokenTable(opts) {
145
145
  * Strategy:
146
146
  * 1. P/Invoke `EnumWindows` to enumerate every visible top-level window
147
147
  * owned by `Unity.exe`.
148
- * 2. For each, normalize the title (alphanumeric lowercase) and match it
149
- * against the embedded per-kind fragment table to classify it.
148
+ * 2. For each, skip the main Editor window ({@link isUnityEditorWindowTitle}),
149
+ * normalize the title (alphanumeric lowercase) and match it against the
150
+ * embedded per-kind fragment table to classify it.
150
151
  * 3. If the kind is in the blocked list → emit `blocked:<kind>` and skip
151
152
  * the click. If the kind's token list is null → skip (not-found).
152
153
  * 4. Walk child windows with `EnumChildWindows`, find a Button whose
@@ -212,6 +213,8 @@ namespace UnityOpenMcp.Dialogs {
212
213
  sb.Length = 0;
213
214
  GetWindowTextW(hWnd, sb, sb.Capacity);
214
215
  var title = sb.ToString();
216
+ // The main Editor window title embeds project/scene names; never classify it.
217
+ if (title.IndexOf("${UNITY_EDITOR_WINDOW_TITLE_MARKER}", StringComparison.OrdinalIgnoreCase) >= 0) continue;
215
218
  var norm = Norm(title);
216
219
  string kind = null;
217
220
  foreach (var kv in table.kinds) {
@@ -312,49 +315,37 @@ async function tryDismissWindows(opts) {
312
315
  // ---------------------------------------------------------------------------
313
316
  /**
314
317
  * The AppleScript template used for the macOS dismiss path. Exposed as a
315
- * function of the token table so tests can assert the script shape without
318
+ * function of the probe options so tests can assert the script shape without
316
319
  * launching osascript.
317
320
  *
318
- * Strategy: iterate every window of the Unity process, normalize its title,
319
- * classify it against the table, and click the first button whose normalized
320
- * label contains a policy token. If the kind is blocked, emit `blocked:<kind>`
321
- * without clicking. If Unity is not running OR no matching button exists, the
322
- * script reports `not-found`. Any AppleScript exception (e.g. Accessibility
323
- * permission not granted) is reported as `error:<msg>`.
321
+ * Strategy: iterate every window of the Unity process except the main Editor
322
+ * window (its title contains {@link UNITY_EDITOR_WINDOW_TITLE_MARKER}),
323
+ * classify it by its title normalized with `normLabel` (see
324
+ * {@link macosTitleMatch}), and act per kind as {@link focusDialogAction}
325
+ * decides from the shared policy table (see {@link macosDialogKindBlock}):
326
+ *
327
+ * - `focus` — the policy picks the kind's focused/default button
328
+ * ({@link DIALOG_FOCUSED_BUTTON_TOKENS}): press Return, but
329
+ * only when the window shows a button with that label.
330
+ * - `named` — the policy picks another button (e.g. Enter Safe Mode, or
331
+ * Quit/Cancel under `cancel`): click the first button whose
332
+ * normalized title equals a policy token, in priority order,
333
+ * or report `blocked:<kind>` when none is found. Return is
334
+ * never pressed, because it would click the focused button.
335
+ * - `blocked` — the policy blocks the kind: report `blocked:<kind>`.
336
+ * - `skip` — the policy declines the kind: no block; the window falls
337
+ * through to `not-found` so polling continues.
338
+ *
339
+ * Project Upgrade is always reported as `blocked` (never clicked), and
340
+ * unsaved scene changes have their own named-button block behind the opt-in.
341
+ * If Unity is not running or nothing matches, the script reports `not-found`.
342
+ * Any AppleScript exception (e.g. Accessibility permission not granted) is
343
+ * reported as `error:<msg>`.
324
344
  *
325
345
  * Requires the Terminal / `node` binary to have been granted Accessibility
326
346
  * permission in System Settings → Privacy & Security → Accessibility.
327
- *
328
- * The token table is JSON-escaped into the script header.
329
347
  */
330
348
  export function macosDismissAppleScript(opts) {
331
- // The macOS path cannot do per-button selection as precisely as the Windows
332
- // path (AppleScript's named-button click works but is brittle across
333
- // Unity versions); instead it classifies the window title and presses
334
- // Return to click the FOCUSED (default) button — which under the default /
335
- // auto / ignore / recover policies IS the safe choice (Ignore on
336
- // launch-errors, Continue on non-matching-editor, OK on auto-graphics-api).
337
- // Project Upgrade is detected and reported as `blocked` (never Return-clicked
338
- // without the explicit opt-in). The token table from buildTokenTable is
339
- // consulted only to decide whether the policy declines a kind entirely
340
- // (manual / safe-mode on a kind with no safe button → no click, not-found);
341
- // when it declines, the script returns not-found for that kind.
342
- const launchFrags = DIALOG_TITLE_FRAGMENTS.launch_errors;
343
- const nonMatchFrags = DIALOG_TITLE_FRAGMENTS.non_matching_editor;
344
- const graphicsFrags = DIALOG_TITLE_FRAGMENTS.auto_graphics_api;
345
- const sceneModifiedFrags = DIALOG_TITLE_FRAGMENTS.scene_modified_externally;
346
- // Whether the active policy dismisses each safe kind at all. Under manual,
347
- // or safe-mode on non_matching/auto_graphics (no safe button), the loop
348
- // should NOT click — return not-found so polling continues.
349
- const dismissesLaunch = preferenceTokensForPolicy("launch_errors", opts.policy, opts.allowProjectUpgrade) !== null;
350
- const dismissesNonMatch = preferenceTokensForPolicy("non_matching_editor", opts.policy, opts.allowProjectUpgrade) !== null;
351
- const dismissesGraphics = preferenceTokensForPolicy("auto_graphics_api", opts.policy, opts.allowProjectUpgrade) !== null;
352
- // scene_modified_externally: safe to Return-click (focused button is Reload)
353
- // under auto/ignore/recover. unsaved_scene_changes is intentionally NOT
354
- // Return-clicked here — it's destructive (data loss either way) and is
355
- // blocked by default; even with the opt-in the per-button Windows path is
356
- // the safer surface for it.
357
- const dismissesSceneModified = preferenceTokensForPolicy("scene_modified_externally", opts.policy, opts.allowProjectUpgrade) !== null;
358
349
  const unsavedTokens = opts.allowUnsavedSceneDismiss
359
350
  ? preferenceTokensForPolicy("unsaved_scene_changes", opts.policy, opts.allowProjectUpgrade, true)
360
351
  : null;
@@ -373,6 +364,9 @@ export function macosDismissAppleScript(opts) {
373
364
  return "Cancel";
374
365
  return t;
375
366
  });
367
+ const unsavedClick = opts.allowUnsavedSceneDismiss && unsavedButtonLabels.length > 0
368
+ ? unsavedSceneMacOsClickBlocks(unsavedButtonLabels)
369
+ : { window: "", sheets: "" };
376
370
  return `
377
371
  on run
378
372
  try
@@ -387,22 +381,29 @@ on run
387
381
  on error
388
382
  set wt to ""
389
383
  end try
390
- -- Project Upgrade: NEVER click without the explicit opt-in, even
391
- -- though the focused button is usually Confirm. Report blocked.
392
- if (wt contains "Upgrade") and (wt contains "Project") then
393
- return "blocked:" & "project_upgrade"
384
+ -- Dialog fragments are normalized (letters and digits only), so match
385
+ -- them against the normalized title, never the raw one.
386
+ set wn to my normLabel(wt)
387
+ -- The main Editor window title ("<Project> - <Scene> - ... - Unity
388
+ -- <version>") embeds user-chosen names and "SAFE MODE"; never classify
389
+ -- it as a dialog. Its sheets are still scanned below.
390
+ if not (wt contains "${UNITY_EDITOR_WINDOW_TITLE_MARKER}") then
391
+ -- Project Upgrade: NEVER click without the explicit opt-in, even
392
+ -- though the focused button is usually Confirm. Report blocked.
393
+ if (wt contains "Upgrade") and (wt contains "Project") then
394
+ return "blocked:" & "project_upgrade"
395
+ end if
396
+ ${macosDialogKindBlock("non_matching_editor", opts)}
397
+ -- Unsaved scene changes: blocked unless the dedicated opt-in is set.
398
+ if ${(opts.allowUnsavedSceneDismiss ? "false" : "true")} then
399
+ if wt contains "Have Been Modified" or wt contains "Unsaved changes" or wt contains "Save Changes" then return "blocked:" & "unsaved_scene_changes"
400
+ end if
401
+ ${unsavedClick.window}
402
+ ${macosDialogKindBlock("launch_errors", opts)}
403
+ ${macosDialogKindBlock("auto_graphics_api", opts)}
404
+ ${macosDialogKindBlock("scene_modified_externally", opts)}
394
405
  end if
395
- -- Unsaved scene changes: blocked unless the dedicated opt-in is set.
396
- if ${(opts.allowUnsavedSceneDismiss ? "false" : "true")} then
397
- if wt contains "Have Been Modified" or wt contains "Unsaved changes" or wt contains "Save Changes" then return "blocked:" & "unsaved_scene_changes"
398
- end if
399
- ${opts.allowUnsavedSceneDismiss && unsavedButtonLabels.length > 0
400
- ? unsavedSceneMacOsClickBlock(unsavedButtonLabels)
401
- : ""}
402
- ${launchFrags.map((f) => fragmentCheck(f, "launch_errors", dismissesLaunch)).join("\n ")}
403
- ${nonMatchFrags.map((f) => fragmentCheck(f, "non_matching_editor", dismissesNonMatch)).join("\n ")}
404
- ${graphicsFrags.map((f) => fragmentCheck(f, "auto_graphics_api", dismissesGraphics)).join("\n ")}
405
- ${sceneModifiedFrags.map((f) => fragmentCheck(f, "scene_modified_externally", dismissesSceneModified)).join("\n ")}
406
+ ${unsavedClick.sheets}
406
407
  end repeat
407
408
  end tell
408
409
  end tell
@@ -411,10 +412,151 @@ on run
411
412
  return "error:" & errMsg
412
413
  end try
413
414
  end run
415
+
416
+ ${MACOS_NORM_LABEL_HANDLER}`;
417
+ }
418
+ /**
419
+ * AppleScript mirror of {@link normalizeDialogLabel}: keep ASCII letters and
420
+ * digits only. AppleScript string comparison (`is`, `contains`) ignores case
421
+ * by default, so the handler does not lowercase. Exported for tests.
422
+ */
423
+ export const MACOS_NORM_LABEL_HANDLER = `
424
+ -- Mirror of normalizeDialogLabel: keep ASCII letters and digits only.
425
+ -- (String comparison is case-insensitive by default, so no lowercasing.)
426
+ on normLabel(s)
427
+ set out to ""
428
+ repeat with c in (characters of s)
429
+ set ch to contents of c
430
+ set cid to id of ch
431
+ -- "id of" yields a LIST for a character made of several code points
432
+ -- (decomposed accents, emoji); comparing a list numerically would raise
433
+ -- and abort the whole probe, so such characters are simply dropped.
434
+ if class of cid is integer then
435
+ if (cid >= 48 and cid <= 57) or (cid >= 65 and cid <= 90) or (cid >= 97 and cid <= 122) then set out to out & ch
436
+ end if
437
+ end repeat
438
+ return out
439
+ end normLabel
414
440
  `;
441
+ /**
442
+ * How a focus-driven platform (macOS AppleScript, Linux xdotool) may act on a
443
+ * dialog of the given kind under the active policy. Derived from the shared
444
+ * policy table so every platform agrees:
445
+ *
446
+ * - `blocked` — the policy blocks the kind (e.g. Project Upgrade without its
447
+ * opt-in). Report `blocked:<kind>`, never click.
448
+ * - `skip` — the policy declines the kind (no preference tokens). Do
449
+ * nothing; try the next kind.
450
+ * - `focus` — the policy's first choice is the kind's focused button
451
+ * ({@link DIALOG_FOCUSED_BUTTON_TOKENS}): pressing Return is
452
+ * safe.
453
+ * - `named` — the policy's first choice is some other button (e.g. Enter
454
+ * Safe Mode, Quit, Cancel). Return would press the focused
455
+ * button instead, so only a named-button click may act.
456
+ *
457
+ * Pure; exported for tests.
458
+ */
459
+ export function focusDialogAction(kind, opts) {
460
+ const blocked = blockedKindsForPolicy(opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch);
461
+ if (blocked.includes(kind))
462
+ return "blocked";
463
+ const tokens = preferenceTokensForPolicy(kind, opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch);
464
+ if (tokens === null)
465
+ return "skip";
466
+ return tokens[0] === DIALOG_FOCUSED_BUTTON_TOKENS[kind] ? "focus" : "named";
467
+ }
468
+ /**
469
+ * AppleScript title condition per focus-classified kind, over `wn` — the
470
+ * window title normalized by `normLabel`. The fragments are normalized
471
+ * (letters and digits only), and AppleScript `contains` ignores case but not
472
+ * spaces or punctuation, so matching the raw title would never fire
473
+ * (`"Enter Safe Mode?" contains "safemode"` is false). Non-Matching Editor
474
+ * matches the short `nonmatchingeditor` stem so both the hyphenated and the
475
+ * spaced spelling qualify. Exported for tests.
476
+ */
477
+ export function macosTitleMatch(kind) {
478
+ const fragments = kind === "non_matching_editor" ? ["nonmatchingeditor"] : DIALOG_TITLE_FRAGMENTS[kind];
479
+ return fragments.map((f) => `(wn contains "${f}")`).join(" or ");
480
+ }
481
+ /**
482
+ * AppleScript block for one dialog kind inside the window loop (`w` / `wt`),
483
+ * shaped by {@link focusDialogAction}:
484
+ *
485
+ * - `blocked` → `blocked:<kind>` when the title matches.
486
+ * - `skip` → a comment only; the window falls through.
487
+ * - `focus` → press Return on the focused button, when the window has a
488
+ * button whose normalized title equals the kind's focused
489
+ * token; otherwise fall through (no press).
490
+ * - `named` → for each policy token in priority order, click the first
491
+ * button whose normalized title equals it; if none matches,
492
+ * report `blocked:<kind>`. Never presses Return.
493
+ *
494
+ * Exact normalized equality (not substring) keeps a short token such as `no`
495
+ * from matching an unrelated label such as "Ignore".
496
+ *
497
+ * Pure; exported for tests.
498
+ */
499
+ export function macosDialogKindBlock(kind, opts) {
500
+ const titleMatch = macosTitleMatch(kind);
501
+ switch (focusDialogAction(kind, opts)) {
502
+ case "skip":
503
+ return `-- policy declines ${kind}; skip`;
504
+ case "blocked":
505
+ return `-- policy blocks ${kind}
506
+ if ${titleMatch} then
507
+ return "blocked:" & "${kind}"
508
+ end if`;
509
+ case "focus": {
510
+ const focusedToken = DIALOG_FOCUSED_BUTTON_TOKENS[kind];
511
+ return `-- policy picks the focused button on ${kind}: press Return, but only
512
+ -- when the window shows that button, so Return lands on a real dialog
513
+ if ${titleMatch} then
514
+ repeat with btn in buttons of w
515
+ try
516
+ set bt to (title of btn) as text
517
+ on error
518
+ set bt to ""
519
+ end try
520
+ if my normLabel(bt) is "${focusedToken}" then
521
+ set frontmost to true
522
+ key code 36
523
+ return "dismissed:Focus:${kind}"
524
+ end if
525
+ end repeat
526
+ end if`;
527
+ }
528
+ case "named": {
529
+ const tokens = preferenceTokensForPolicy(kind, opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch) ?? [];
530
+ const tokenList = tokens.map((t) => `"${t}"`).join(", ");
531
+ return `-- policy picks a non-focused button on ${kind}: click it by name,
532
+ -- never press Return (that would click the focused button)
533
+ if ${titleMatch} then
534
+ repeat with tok in {${tokenList}}
535
+ repeat with btn in buttons of w
536
+ try
537
+ set bt to (title of btn) as text
538
+ on error
539
+ set bt to ""
540
+ end try
541
+ if my normLabel(bt) is (contents of tok) then
542
+ set frontmost to true
543
+ click btn
544
+ return "dismissed:" & bt & ":${kind}"
545
+ end if
546
+ end repeat
547
+ end repeat
548
+ return "blocked:" & "${kind}"
549
+ end if`;
550
+ }
551
+ }
415
552
  }
416
- /** AppleScript block: match unsaved-scene title fragments and click a named button. */
417
- function unsavedSceneMacOsClickBlock(buttonLabels) {
553
+ /**
554
+ * AppleScript blocks that match the unsaved-scene prompt and click a named
555
+ * button: `window` checks the dialog window's own title (skipped for the main
556
+ * Editor window), `sheets` checks the sheets of any window (Unity 6 attaches
557
+ * the prompt to the main Editor window).
558
+ */
559
+ function unsavedSceneMacOsClickBlocks(buttonLabels) {
418
560
  const textFrags = [
419
561
  "Have Been Modified",
420
562
  "Unsaved changes",
@@ -424,9 +566,9 @@ function unsavedSceneMacOsClickBlock(buttonLabels) {
424
566
  ];
425
567
  const titleMatch = (varName) => textFrags.map((f) => `${varName} contains "${f}"`).join(" or ");
426
568
  const staticTextScan = (container, flagVar) => `
427
- repeat with st in static texts of ${container}
569
+ repeat with stx in static texts of ${container}
428
570
  try
429
- set tx to (value of st) as text
571
+ set tx to (value of stx) as text
430
572
  on error
431
573
  set tx to ""
432
574
  end try
@@ -450,11 +592,13 @@ function unsavedSceneMacOsClickBlock(buttonLabels) {
450
592
  end if
451
593
  end repeat`)
452
594
  .join("");
453
- return `
454
- -- Top-level window title (some Unity versions).
455
- if (${titleMatch("wt")}) then
456
- ${clickButtons("w")}
457
- end if
595
+ return {
596
+ window: `
597
+ -- Top-level window title (some Unity versions).
598
+ if (${titleMatch("wt")}) then
599
+ ${clickButtons("w")}
600
+ end if`,
601
+ sheets: `
458
602
  -- Unity 6 save prompt is usually a sheet on the main Editor window.
459
603
  try
460
604
  repeat with s in sheets of w
@@ -470,27 +614,8 @@ function unsavedSceneMacOsClickBlock(buttonLabels) {
470
614
  ${clickButtons("s")}
471
615
  end if
472
616
  end repeat
473
- end try`;
474
- }
475
- /**
476
- * Build the per-fragment AppleScript check block. When the policy dismisses
477
- * the kind, the block activates the window + presses Return and returns the
478
- * dismissed token; when the policy declines the kind (null tokens), the block
479
- * is omitted entirely (the loop falls through to the next fragment).
480
- *
481
- * AppleScript `contains` is case-insensitive by default, so the lowercased
482
- * fragment matches Unity's mixed-case titles ("Enter Safe Mode?" contains
483
- * "entersafemode" → false, BUT "safemode" matches; the fragment list is built
484
- * to have at least one case-insensitive hit per title).
485
- */
486
- function fragmentCheck(fragment, kind, dismisses) {
487
- if (!dismisses)
488
- return `-- policy declines ${kind}; skip`;
489
- return `if wt contains "${fragment}" then
490
- set frontmost to true
491
- key code 36
492
- return "dismissed:Focus:${kind}"
493
- end if`;
617
+ end try`,
618
+ };
494
619
  }
495
620
  async function tryDismissMacOS(opts) {
496
621
  return new Promise((resolve) => {
@@ -553,6 +678,24 @@ export function _resetXdotoolPresenceForTests() {
553
678
  export function regexEscapeForXdotool(s) {
554
679
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
555
680
  }
681
+ /**
682
+ * The `xdotool search --name` pattern for a normalized title fragment.
683
+ * Fragments are letters and digits only ("entersafemode"), while real titles
684
+ * carry spaces and punctuation ("Enter Safe Mode?"), so the pattern allows any
685
+ * run of non-alphanumerics between consecutive characters and spells each
686
+ * letter in both cases. That is the regex form of "the normalized title
687
+ * contains the fragment" and does not depend on xdotool's case handling.
688
+ * Any other character is escaped literally.
689
+ */
690
+ export function xdotoolTitlePattern(fragment) {
691
+ return [...fragment]
692
+ .map((ch) => /[a-z]/i.test(ch)
693
+ ? `[${ch.toLowerCase()}${ch.toUpperCase()}]`
694
+ : /[0-9]/.test(ch)
695
+ ? ch
696
+ : regexEscapeForXdotool(ch))
697
+ .join("[^A-Za-z0-9]*");
698
+ }
556
699
  /**
557
700
  * Look up currently-running Unity Editor PIDs on the local box. Used by the
558
701
  * Linux/X11 path to scope the title-fragment match to Unity processes only.
@@ -574,6 +717,19 @@ function getUnityPidsLinux() {
574
717
  return [];
575
718
  }
576
719
  }
720
+ /**
721
+ * What the Linux/X11 path does with a Unity-owned window of the given kind:
722
+ * {@link focusDialogAction} with `named` folded into `blocked`, because
723
+ * xdotool can only press Return on the focused (default) button — it cannot
724
+ * click a named one (e.g. `cancel` on the Safe Mode prompt, whose focused
725
+ * button is Ignore, is reported as blocked for a human).
726
+ *
727
+ * Pure; exported for tests.
728
+ */
729
+ export function linuxFocusAction(kind, opts) {
730
+ const action = focusDialogAction(kind, opts);
731
+ return action === "named" ? "blocked" : action;
732
+ }
577
733
  async function tryDismissLinuxX11(opts) {
578
734
  if (!isXdotoolAvailable()) {
579
735
  return {
@@ -589,11 +745,11 @@ async function tryDismissLinuxX11(opts) {
589
745
  // first kind whose xdotool search surfaces a Unity-owned window.
590
746
  // - blocked kinds (project_upgrade without opt-in) → report blocked.
591
747
  // - decline kinds (tokens === null) → skip (treat as not-found).
592
- // - otherwise activate the window + send Return to click the focused
593
- // (default) button. Under default/auto/ignore/recover the default
594
- // button is the safe choice for launch_errors / non_matching_editor /
595
- // auto_graphics_api. Project upgrade is never clicked here (blocked).
596
- const blocked = new Set(blockedKindsForPolicy(opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss));
748
+ // - policy picks the kind's focused button → activate the window + send
749
+ // Return to click it.
750
+ // - policy picks any other button → blocked: xdotool cannot click a
751
+ // named button, and Return would press the focused one instead (see
752
+ // linuxFocusAction / DIALOG_FOCUSED_BUTTON_TOKENS).
597
753
  const kinds = Object.keys(DIALOG_TITLE_FRAGMENTS);
598
754
  // Prefer launch_errors first (the common stall), then the safe kinds, then
599
755
  // the destructive kinds last so a blocked destructive modal doesn't shadow a
@@ -640,7 +796,7 @@ async function tryDismissLinuxX11(opts) {
640
796
  return;
641
797
  }
642
798
  const kind = order[idx++];
643
- const tokens = preferenceTokensForPolicy(kind, opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss);
799
+ const action = linuxFocusAction(kind, opts);
644
800
  const fragments = DIALOG_TITLE_FRAGMENTS[kind];
645
801
  // Probe this kind's fragments one at a time.
646
802
  let fIdx = 0;
@@ -652,7 +808,7 @@ async function tryDismissLinuxX11(opts) {
652
808
  return;
653
809
  }
654
810
  const fragment = fragments[fIdx++];
655
- const child = execFile("xdotool", ["search", "--name", regexEscapeForXdotool(fragment)], { timeout: XDOTOOL_PROBE_TIMEOUT_MS }, (err, stdout) => {
811
+ const child = execFile("xdotool", ["search", "--name", xdotoolTitlePattern(fragment)], { timeout: XDOTOOL_PROBE_TIMEOUT_MS }, (err, stdout) => {
656
812
  activeChild = null;
657
813
  disarmWatchdog();
658
814
  if (settled)
@@ -662,21 +818,28 @@ async function tryDismissLinuxX11(opts) {
662
818
  return;
663
819
  }
664
820
  const candidateIds = stdout.trim().split(/\s+/).filter(Boolean);
665
- findUnityOwnedWindow(candidateIds, unityPids, (winId) => {
821
+ // Re-check the title in Node: it must still classify as this
822
+ // kind and must not be the main Editor window, whose title can
823
+ // contain a fragment (a scene name, or "SAFE MODE").
824
+ const acceptTitle = (title) => titleMatchesDialogKind(title, kind);
825
+ findUnityOwnedWindow(candidateIds, unityPids, acceptTitle, (winId) => {
666
826
  if (!winId) {
667
827
  tryNextFragment();
668
828
  return;
669
829
  }
670
- if (blocked.has(kind) || tokens === null) {
830
+ if (action !== "focus") {
671
831
  // Found the dialog but the policy declines. Report blocked
672
- // only for genuinely blocked kinds (project_upgrade); a null-
673
- // token decline (manual / safe-mode on a kind with no safe
674
- // button) is reported as not-found so the loop keeps ticking.
675
- if (blocked.has(kind)) {
832
+ // for genuinely blocked kinds; a `skip` decline (manual /
833
+ // safe-mode on a kind with no safe button) is reported as
834
+ // not-found so the loop keeps ticking.
835
+ if (action === "blocked") {
836
+ const policyBlocks = blockedKindsForPolicy(opts.policy, opts.allowProjectUpgrade, opts.allowUnsavedSceneDismiss, opts.allowVersionMismatch).includes(kind);
676
837
  finish({
677
838
  kind: "blocked",
678
839
  dialog: kind,
679
- message: `Policy '${opts.policy}' declines to dismiss ${kind} dialog`,
840
+ message: policyBlocks
841
+ ? `Policy '${opts.policy}' declines to dismiss ${kind} dialog`
842
+ : `Policy '${opts.policy}' selects a button other than the focused one on the ${kind} dialog; xdotool can only press Return on the focused button, so it needs a human`,
680
843
  });
681
844
  return;
682
845
  }
@@ -722,14 +885,15 @@ async function tryDismissLinuxX11(opts) {
722
885
  }
723
886
  /**
724
887
  * Walk `candidateIds` and call `done(winId)` with the first window owned by
725
- * a Unity PID, or `done(undefined)` if none match. Sequential — the list is
726
- * small and parallelism buys nothing meaningful.
888
+ * a Unity PID whose title (`xdotool getwindowname`) passes `acceptTitle`, or
889
+ * `done(undefined)` if none match. Sequential — the list is small and
890
+ * parallelism buys nothing meaningful.
727
891
  *
728
892
  * `stop` (optional) is polled before each probe and recursion step: once the
729
893
  * outer probe has settled, the walk must not spawn further getwindowpid
730
894
  * children (the result would be discarded anyway).
731
895
  */
732
- function findUnityOwnedWindow(candidateIds, unityPids, done, stop) {
896
+ function findUnityOwnedWindow(candidateIds, unityPids, acceptTitle, done, stop) {
733
897
  let idx = 0;
734
898
  const next = () => {
735
899
  if (stop?.())
@@ -749,7 +913,7 @@ function findUnityOwnedWindow(candidateIds, unityPids, done, stop) {
749
913
  }
750
914
  const pid = parseInt(stdout.trim(), 10);
751
915
  if (Number.isFinite(pid) && unityPids.has(pid)) {
752
- done(winId);
916
+ checkTitle(winId);
753
917
  return;
754
918
  }
755
919
  next();
@@ -757,6 +921,23 @@ function findUnityOwnedWindow(candidateIds, unityPids, done, stop) {
757
921
  // SIGKILL escalation for a wedged getwindowpid probe.
758
922
  const disarmWatchdog = armSigkillEscalation(child, LINUX_PROBE_TIMEOUT_MS, () => stop?.() ?? false);
759
923
  };
924
+ const checkTitle = (winId) => {
925
+ if (stop?.())
926
+ return;
927
+ const child = execFile("xdotool", ["getwindowname", winId], { timeout: LINUX_PROBE_TIMEOUT_MS }, (err, stdout) => {
928
+ disarmWatchdog();
929
+ if (stop?.())
930
+ return;
931
+ // An unreadable title is not evidence of a dialog: skip the window.
932
+ if (!err && acceptTitle(stdout.replace(/\r?\n$/, ""))) {
933
+ done(winId);
934
+ return;
935
+ }
936
+ next();
937
+ });
938
+ // SIGKILL escalation for a wedged getwindowname probe.
939
+ const disarmWatchdog = armSigkillEscalation(child, LINUX_PROBE_TIMEOUT_MS, () => stop?.() ?? false);
940
+ };
760
941
  next();
761
942
  }
762
943
  // ---------------------------------------------------------------------------
@@ -896,6 +1077,7 @@ export function readDismissConfig(env = process.env) {
896
1077
  policy,
897
1078
  allowProjectUpgrade: env.UNITY_OPEN_MCP_ALLOW_PROJECT_UPGRADE === "1",
898
1079
  allowUnsavedSceneDismiss: env.UNITY_OPEN_MCP_ALLOW_UNSAVED_SCENE_DISMISS === "1",
1080
+ allowVersionMismatch: env.UNITY_OPEN_MCP_ALLOW_VERSION_MISMATCH === "1",
899
1081
  };
900
1082
  }
901
1083
  function parsePositiveInt(raw, fallback) {
@@ -948,6 +1130,7 @@ export async function pollAndDismissDialogs(opts) {
948
1130
  policy: opts.policy,
949
1131
  allowProjectUpgrade: opts.allowProjectUpgrade,
950
1132
  allowUnsavedSceneDismiss: opts.allowUnsavedSceneDismiss,
1133
+ allowVersionMismatch: opts.allowVersionMismatch,
951
1134
  // M12 — thread the loop's abort signal into the probe so an in-flight
952
1135
  // osascript/PowerShell child is killed the moment the readiness abort
953
1136
  // fires, instead of running out its own ~5s timeout after the compile
@@ -966,17 +1149,19 @@ export async function pollAndDismissDialogs(opts) {
966
1149
  // one error and surfaces the next.
967
1150
  }
968
1151
  else if (outcome.kind === "blocked") {
969
- // Project-upgrade / unsaved-scene-changes (or future blocked kind):
970
- // log once per kind per run, then keep polling. A human must dismiss
1152
+ // Destructive or version-mismatch dialog: log once per kind per run,
1153
+ // then keep polling. A human must dismiss
971
1154
  // it; exiting would hide the stall behind a clean-looking readiness
972
1155
  // abort.
973
1156
  if (!seenBlocked.has(outcome.dialog)) {
974
1157
  seenBlocked.add(outcome.dialog);
975
1158
  const optIn = outcome.dialog === "project_upgrade"
976
1159
  ? "Set UNITY_OPEN_MCP_ALLOW_PROJECT_UPGRADE=1 to opt in to auto-confirm (irreversible)."
977
- : outcome.dialog === "unsaved_scene_changes"
978
- ? "Set UNITY_OPEN_MCP_ALLOW_UNSAVED_SCENE_DISMISS=1 to opt in to auto-dismiss (destructive — data loss either way)."
979
- : "Dismiss it manually or change UNITY_OPEN_MCP_DIALOG_POLICY.";
1160
+ : outcome.dialog === "non_matching_editor"
1161
+ ? "Set UNITY_OPEN_MCP_ALLOW_VERSION_MISMATCH=1 only for an intentional upgrade or compatibility run."
1162
+ : outcome.dialog === "unsaved_scene_changes"
1163
+ ? "Set UNITY_OPEN_MCP_ALLOW_UNSAVED_SCENE_DISMISS=1 to opt in to auto-dismiss (destructive — data loss either way)."
1164
+ : "Dismiss it manually or change UNITY_OPEN_MCP_DIALOG_POLICY.";
980
1165
  log(`[unity-open-mcp] dialog auto-dismiss blocked on ${outcome.dialog} ` +
981
1166
  `(policy=${opts.policy}): ${outcome.message}. ${optIn}`);
982
1167
  }