ue-mcp 1.3.3 → 1.3.5

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 (36) hide show
  1. package/dist/dialog-guard.d.ts +287 -0
  2. package/dist/dialog-guard.js +700 -0
  3. package/dist/dialog-guard.js.map +1 -0
  4. package/dist/editor-control.d.ts +101 -3
  5. package/dist/editor-control.js +233 -110
  6. package/dist/editor-control.js.map +1 -1
  7. package/dist/epic-cache.d.ts +12 -0
  8. package/dist/epic-cache.js +13 -0
  9. package/dist/epic-cache.js.map +1 -1
  10. package/dist/flow/guarded-bridge.d.ts +44 -10
  11. package/dist/flow/guarded-bridge.js +88 -20
  12. package/dist/flow/guarded-bridge.js.map +1 -1
  13. package/dist/flow/http-server.js +47 -4
  14. package/dist/flow/http-server.js.map +1 -1
  15. package/dist/flow/task-factory.js +41 -0
  16. package/dist/flow/task-factory.js.map +1 -1
  17. package/dist/flow/task-guards.js +17 -1
  18. package/dist/flow/task-guards.js.map +1 -1
  19. package/dist/index.js +191 -68
  20. package/dist/index.js.map +1 -1
  21. package/dist/locking.js +15 -0
  22. package/dist/locking.js.map +1 -1
  23. package/dist/offline.js +11 -2
  24. package/dist/offline.js.map +1 -1
  25. package/dist/session.js +8 -1
  26. package/dist/session.js.map +1 -1
  27. package/dist/tool-counts.json +2 -2
  28. package/dist/tools/editor.js +27 -5
  29. package/dist/tools/editor.js.map +1 -1
  30. package/package.json +2 -2
  31. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +1 -1
  32. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +1 -1
  33. package/plugin/ue_mcp_bridge/UE_MCP_Bridge.uplugin +1 -1
  34. package/dist/dialog-gate.d.ts +0 -73
  35. package/dist/dialog-gate.js +0 -197
  36. package/dist/dialog-gate.js.map +0 -1
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { spawn } from "child_process";
4
4
  import * as net from "net";
5
+ import * as os from "node:os";
5
6
  import WebSocket from "ws";
6
7
  import { readUeMcpConfig } from "./project.js";
7
8
  import { EngineResolutionError, selectEngine, trySelectEngine } from "./engine-root.js";
@@ -10,6 +11,7 @@ import { editorOwnsProject, findEditorByPid, findInteractiveEditors, readEngineS
10
11
  import { findLiveInstanceRecord, isPidAlive, lockfileIsFromThisLaunch, resolveBridgeTarget } from "./editor-target.js";
11
12
  import { startProgress } from "./ui/progress.js";
12
13
  import { getDialogMode, getUserStatePath } from "./user-state.js";
14
+ import { DialogGuard } from "./dialog-guard.js";
13
15
  // Process control is cross-platform: the editor binary path and the running-
14
16
  // process probe differ per OS, and stopping goes through the bridge (#790).
15
17
  const IS_WINDOWS = process.platform === "win32";
@@ -176,10 +178,7 @@ const WINDOW_PROBE_INTERVAL_MS = 60_000;
176
178
  * waits for the snapshot to say `ready` and reports the whole startup as a
177
179
  * progress bar rather than handing control back early.
178
180
  */
179
- export async function waitForEditorReadyExternal(projectPath, projectDir, maxWaitSeconds = 300, launchedAtMs) {
180
- return waitForEditorReady(projectPath, projectDir, maxWaitSeconds, { launchedAtMs });
181
- }
182
- async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts = {}) {
181
+ export async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts = {}) {
183
182
  const startTime = Date.now();
184
183
  const maxWaitMs = maxWaitSeconds * 1000;
185
184
  const timeline = [];
@@ -291,7 +290,11 @@ async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts
291
290
  message: snapshot.modal.message ?? "",
292
291
  buttons,
293
292
  choices: buttons.map((label) => ({ buttonLabel: label, respondWith: respondCallFor(label) })),
294
- }, { pressCalls: opts.pressCalls !== false }),
293
+ },
294
+ // Forwarded, not defaulted: describeBlockingDialog applies the
295
+ // default, and a second copy of it here is a second place for the
296
+ // rule to drift away from the guard's.
297
+ { pressCalls: opts.pressCalls }),
295
298
  state: await readEngineState(projectPath ?? null),
296
299
  });
297
300
  }
@@ -683,14 +686,14 @@ function respondCallFor(label) {
683
686
  ? `editor(action='respond_to_dialog', buttonLabel="${label}")`
684
687
  : `editor(action='respond_to_dialog', buttonLabel='${label}')`;
685
688
  }
686
- /** The dialog blocking this editor, or null when nothing is. */
689
+ const UNREADABLE = { dialog: null, readable: false };
687
690
  async function readBlockingDialog(port, host) {
688
691
  const reply = await callBridgeOnce(port, "list_dialogs", {}, host);
689
692
  if (!accepted(reply) || !reply.result)
690
- return null;
693
+ return UNREADABLE;
691
694
  const dialogs = reply.result.dialogs;
692
695
  if (!Array.isArray(dialogs) || dialogs.length === 0)
693
- return null;
696
+ return { dialog: null, readable: true };
694
697
  const first = dialogs[0];
695
698
  const buttons = Array.isArray(first.buttons)
696
699
  ? first.buttons.filter((b) => typeof b === "string" && b !== "")
@@ -704,10 +707,13 @@ async function readBlockingDialog(port, host) {
704
707
  };
705
708
  });
706
709
  return {
707
- title: typeof first.title === "string" ? first.title : "",
708
- message: typeof first.message === "string" ? first.message : "",
709
- buttons,
710
- choices,
710
+ readable: true,
711
+ dialog: {
712
+ title: typeof first.title === "string" ? first.title : "",
713
+ message: typeof first.message === "string" ? first.message : "",
714
+ buttons,
715
+ choices,
716
+ },
711
717
  };
712
718
  }
713
719
  /**
@@ -830,7 +836,13 @@ export function resolveDialogMode(opts) {
830
836
  * of having them, so each says that plainly rather than leaving the reader to
831
837
  * infer it from a mode name.
832
838
  */
833
- function dialogModeGuidance(resolved, canElicit, opts = {}) {
839
+ export function dialogModeGuidance(resolved, canElicit, opts = {}) {
840
+ // What the mode MEANS is the guard's answer, not a second reading of it
841
+ // here. This branched on the configured mode and re-derived the "interactive
842
+ // with nobody to ask behaves as defer" rule inline, which is one more place
843
+ // for it to drift away from the rule the refusal is actually built from.
844
+ const effective = DialogGuard.effectiveMode(resolved.mode, canElicit);
845
+ const downgraded = effective !== resolved.mode;
834
846
  const applied = `Dialog handling mode: ${resolved.mode} (${resolved.source}).`;
835
847
  // Whether a quit went out is a fact about the call, and this text is reused
836
848
  // on both sides of it: before the quit, where nothing was asked to close, and
@@ -838,89 +850,56 @@ function dialogModeGuidance(resolved, canElicit, opts = {}) {
838
850
  const quitState = opts.quitSent
839
851
  ? "The quit went out before this dialog appeared and the editor has not closed."
840
852
  : "The editor was not asked to quit.";
841
- if (resolved.mode === "auto") {
853
+ const again = "then call editor(action='stop_editor') again.";
854
+ if (effective === "auto") {
842
855
  return (`${applied} Nothing was pressed for you: in auto mode the whole dialog is handed back and the ` +
843
- "choice is yours. Press the button you choose with the call listed beside it, then call " +
844
- "editor(action='stop_editor') again.");
856
+ `choice is yours. Press the button you choose with the call listed beside it, ${again}`);
845
857
  }
846
- if (resolved.mode === "interactive" && !canElicit) {
858
+ if (effective === "interactive") {
859
+ return `${applied} ${quitState} Answer the dialog, ${again}`;
860
+ }
861
+ if (downgraded) {
847
862
  return (`${applied} Interactive mode shows this dialog to the user in an MCP elicitation form, and this ` +
848
863
  "client did not advertise that capability, so no form could be shown and nothing was pressed. " +
849
864
  "Answer the dialog in the Unreal Editor window, or set UE_MCP_DIALOG_MODE=auto to answer it from " +
850
- "here with editor(action='respond_to_dialog'), then call editor(action='stop_editor') again.");
851
- }
852
- if (resolved.mode === "interactive") {
853
- return `${applied} ${quitState} Answer the dialog, then call editor(action='stop_editor') again.`;
865
+ `here with editor(action='respond_to_dialog'), ${again}`);
854
866
  }
855
867
  return (`${applied} Nothing was pressed and nothing was asked of you: in defer mode a blocking dialog is ` +
856
- "left alone. Go to the Unreal Editor window and answer the dialog quoted above yourself, then call " +
857
- "editor(action='stop_editor') again.");
868
+ `left alone. Go to the Unreal Editor window and answer the dialog quoted above yourself, ${again}`);
858
869
  }
859
870
  /**
860
- * Put the dialog to the person, through MCP elicitation, and press whichever
861
- * button they choose.
871
+ * Put the dialog to the person and press what they choose.
862
872
  *
863
- * This is the one path that ends with a button being pressed without a separate
864
- * respond_to_dialog call, and it presses only what the user picked from the
865
- * dialog's own labels, in a prompt carrying its full text. Declining leaves the
866
- * dialog exactly where it is.
873
+ * The decision, the form and the press all belong to DialogGuard: this only
874
+ * supplies the transport, because a quit may already be in flight and the
875
+ * session's own bridge is not usable then. Keeping a second copy of the
876
+ * interactive logic here is what let the two disagree.
867
877
  *
868
- * Returns null ONLY when nothing was sent, or when the editor answered saying
869
- * it pressed nothing. A send whose outcome is unknown comes back as a press
870
- * with confirmed: false, because that is what it is.
878
+ * Returns the button pressed, or null when nothing was.
871
879
  */
872
- async function askUserToAnswerDialog(port, host, dialog, elicit) {
873
- if (dialog.choices.length === 0)
874
- return null;
875
- const LEAVE_OPEN = "Leave the dialog open";
876
- let answer;
877
- try {
878
- answer = await elicit({
879
- message: [
880
- "Unreal Editor is blocked on a modal dialog and is waiting for an answer.",
881
- "",
882
- `Title: ${dialog.title}`,
883
- dialog.message === "" ? "(no message text)" : dialog.message,
884
- "",
885
- "Choose the button to press. Nothing is pressed unless you choose it.",
886
- ].join("\n"),
887
- requestedSchema: {
888
- type: "object",
889
- properties: {
890
- button: {
891
- type: "string",
892
- title: "Button",
893
- description: "The dialog's own buttons, in the order it lays them out.",
894
- enum: [...dialog.buttons, LEAVE_OPEN],
895
- },
896
- },
897
- required: ["button"],
898
- },
899
- });
900
- }
901
- catch {
902
- // No elicitation UI, or the prompt failed. The dialog stays up and the
903
- // report is what the caller gets.
904
- return null;
905
- }
906
- if (answer.action !== "accept")
907
- return null;
908
- const chosen = answer.content?.button;
909
- if (typeof chosen !== "string" || chosen === LEAVE_OPEN)
910
- return null;
911
- if (!dialog.buttons.includes(chosen))
912
- return null;
913
- const pressed = await callBridgeOnce(port, "respond_to_dialog", { buttonLabel: chosen }, host);
914
- if (accepted(pressed))
915
- return { button: chosen, confirmed: true };
916
- // The bridge answered and said it did not press: an unregistered method on an
917
- // older plugin build, or a handler that refused. Nothing happened to the
918
- // dialog, and "nothing was sent" is the honest report.
919
- if (pressed.answered)
920
- return null;
921
- // It did not answer at all. The frame was already on the wire, so whether the
922
- // button was pressed is genuinely unknown and must not be reported either way.
923
- return { button: chosen, confirmed: false };
880
+ async function askUserToAnswerDialog(port, host, dialog, elicit, mode, canElicit, sessionGuard) {
881
+ // The editor's own guard when the caller had one, so its ask-once record and
882
+ // its knowledge of what is on screen are shared rather than duplicated. The
883
+ // fallback exists for callers with no session in hand.
884
+ const guard = sessionGuard ?? new DialogGuard({
885
+ // The resolved mode, not a hardcoded one. Hardcoding it here meant this
886
+ // route ignored what the machine asked for and elicited under auto and
887
+ // defer too.
888
+ mode: () => mode,
889
+ probe: () => callBridgeOnce(port, "list_dialogs", {}, host),
890
+ press: (buttonLabel) => callBridgeOnce(port, "respond_to_dialog", { buttonLabel }, host),
891
+ elicit: () => elicit,
892
+ });
893
+ const decision = await guard.decideFor("editor.stop_editor", {
894
+ title: dialog.title,
895
+ message: dialog.message,
896
+ buttons: dialog.buttons,
897
+ choices: (dialog.choices ?? []).filter((c) => typeof c.respondWith === "string"),
898
+ }, { canElicit });
899
+ // An unconfirmed press refuses (the guard cannot prove the way is clear) but
900
+ // still has to be reported, so read the outcome rather than the decision.
901
+ void decision;
902
+ return guard.lastPressed;
924
903
  }
925
904
  /**
926
905
  * What is holding the editor open, in the words of the thing holding it.
@@ -1054,6 +1033,42 @@ export async function resolveOwnedEditor(projectDir, projectPath) {
1054
1033
  "for this project republishes it.",
1055
1034
  };
1056
1035
  }
1036
+ /**
1037
+ * What a restart returns. It is the start's result plus the stop half's account
1038
+ * of any dialog met on the way, because the restart's stop is a full
1039
+ * editor(stop_editor) and a caller cannot see which dialog handling mode
1040
+ * applied if the fields are dropped on the way out.
1041
+ */
1042
+ /**
1043
+ * The stop half's account of a dialog, forwarded onto the restart's result.
1044
+ *
1045
+ * Both the record of WHAT HAPPENED (mode, source, the dialog, any press) and
1046
+ * the guard's refusal fields travel. Copying only the former dropped
1047
+ * dialogBlocking, refusedMethod, dialogTitle, dialogMessage and error, so a
1048
+ * restart refused by a dialog came back unrecognisable to isDialogRefusal and
1049
+ * to every client branching on that flag.
1050
+ */
1051
+ export function restartDialogAccount(stopResult) {
1052
+ return {
1053
+ ...(stopResult.dialogBlocking ? { dialogBlocking: stopResult.dialogBlocking } : {}),
1054
+ ...(stopResult.refusedMethod ? { refusedMethod: stopResult.refusedMethod } : {}),
1055
+ ...(stopResult.dialogTitle ? { dialogTitle: stopResult.dialogTitle } : {}),
1056
+ ...(stopResult.dialogMessage !== undefined ? { dialogMessage: stopResult.dialogMessage } : {}),
1057
+ ...(stopResult.buttons ? { buttons: stopResult.buttons } : {}),
1058
+ ...(stopResult.choices ? { choices: stopResult.choices } : {}),
1059
+ ...(stopResult.error ? { error: stopResult.error } : {}),
1060
+ ...(stopResult.refusedReason ? { refusedReason: stopResult.refusedReason } : {}),
1061
+ ...(stopResult.dialogMode ? { dialogMode: stopResult.dialogMode } : {}),
1062
+ ...(stopResult.dialogModeSource ? { dialogModeSource: stopResult.dialogModeSource } : {}),
1063
+ ...(stopResult.blockingDialog ? { blockingDialog: stopResult.blockingDialog } : {}),
1064
+ ...(stopResult.dialogAnsweredByUser
1065
+ ? { dialogAnsweredByUser: stopResult.dialogAnsweredByUser }
1066
+ : {}),
1067
+ ...(stopResult.dialogPressUnconfirmed
1068
+ ? { dialogPressUnconfirmed: stopResult.dialogPressUnconfirmed }
1069
+ : {}),
1070
+ };
1071
+ }
1057
1072
  /**
1058
1073
  * What this tool will and will not do to a process that is not closing.
1059
1074
  *
@@ -1155,7 +1170,13 @@ export async function stopEditor(projectDir, opts = {}) {
1155
1170
  * every button label in order - and nothing that actuates it.
1156
1171
  */
1157
1172
  const dialogMode = resolveDialogMode({ projectDir, canElicit });
1158
- const pressCalls = dialogMode.mode !== "defer";
1173
+ // The guard owns this rule. Computing it here as well meant the same
1174
+ // decision existed twice and the two renderings disagreed about what defer
1175
+ // strips.
1176
+ // canElicit matters here too: interactive with nobody to ask handed the
1177
+ // press calls over anyway, which is the leak defer exists to close, on the
1178
+ // mode that promises a person chooses.
1179
+ const pressCalls = DialogGuard.handsOverPressCalls(dialogMode.mode, canElicit);
1159
1180
  const ownership = await resolveOwnedEditor(projectDir, projectPath);
1160
1181
  if (!ownership.owned) {
1161
1182
  // No editor was closed, so the verdict is false on every branch here. What
@@ -1201,7 +1222,22 @@ export async function stopEditor(projectDir, opts = {}) {
1201
1222
  // while the game thread is parked inside the modal loop, which is exactly the
1202
1223
  // state where sending a quit would time out and teach nobody anything. Ask
1203
1224
  // before sending, and report the whole question rather than acting on it.
1204
- let dialog = await readBlockingDialog(port, host);
1225
+ const firstRead = await readBlockingDialog(port, host);
1226
+ if (!firstRead.readable) {
1227
+ // The same rule readDirtyPackages already follows: a question that cannot
1228
+ // be answered is not an answer of "clean". Sending a quit here is how an
1229
+ // editor sitting on a modal got one anyway, under every mode including
1230
+ // defer, because a slow reply was reported as no dialog at all.
1231
+ return {
1232
+ success: false,
1233
+ refusedReason: "unknown-dialog-state",
1234
+ message: "The editor's bridge did not answer when asked whether a dialog is blocking it, so whether "
1235
+ + "one is on screen cannot be established and stopping could dismiss it. Nothing was asked "
1236
+ + "to quit. Read it with editor(action='list_dialogs'), or close the editor yourself and "
1237
+ + "answer whatever it asks.",
1238
+ };
1239
+ }
1240
+ let dialog = firstRead.dialog;
1205
1241
  let answeredByUser;
1206
1242
  /** A press that went out with no answer. Not an answer, and not silence. */
1207
1243
  let unconfirmedPress;
@@ -1226,11 +1262,11 @@ export async function stopEditor(projectDir, opts = {}) {
1226
1262
  }
1227
1263
  : {};
1228
1264
  if (dialog) {
1229
- // Interactive is the ONLY mode that reaches a button, and only through the
1230
- // user's own pick in the elicitation form. auto and defer both leave the
1231
- // dialog exactly where it is.
1232
- if (dialogMode.mode === "interactive" && canElicit && opts.elicit) {
1233
- const press = await askUserToAnswerDialog(port, host, dialog, opts.elicit);
1265
+ // Always ask the guard. It applies the mode, so branching on the mode here
1266
+ // as well was a second copy of that decision and the two could disagree.
1267
+ // auto and defer press nothing; interactive puts it to the person.
1268
+ if (opts.elicit) {
1269
+ const press = await askUserToAnswerDialog(port, host, dialog, opts.elicit, dialogMode.mode, canElicit, opts.guard);
1234
1270
  if (press !== null) {
1235
1271
  if (press.confirmed)
1236
1272
  answeredByUser = press.button;
@@ -1239,11 +1275,26 @@ export async function stopEditor(projectDir, opts = {}) {
1239
1275
  // Their choice may have raised the next one, and on the unconfirmed
1240
1276
  // path this read is also the only evidence available about whether the
1241
1277
  // press landed. Look again rather than assuming the editor is clear.
1242
- dialog = await readBlockingDialog(port, host);
1278
+ // Unreadable here keeps the dialog rather than dropping it: this read
1279
+ // is the only evidence about whether the press landed, and treating
1280
+ // silence as success is what would report a clear editor.
1281
+ const after = await readBlockingDialog(port, host);
1282
+ dialog = after.readable ? after.dialog : dialog;
1243
1283
  }
1244
1284
  }
1245
1285
  if (dialog) {
1246
1286
  return {
1287
+ // The guard's own refusal fields, so this route hands a caller exactly
1288
+ // what every other route does: dialogBlocking, refusedMethod,
1289
+ // dialogTitle, dialogMessage, buttons, choices and error. Assembling
1290
+ // its own object left most of those undefined depending on which route
1291
+ // did the refusing.
1292
+ ...DialogGuard.describeRefusal("editor.stop_editor", {
1293
+ title: dialog.title,
1294
+ message: dialog.message,
1295
+ buttons: dialog.buttons,
1296
+ choices: (dialog.choices ?? []).filter((c) => typeof c.respondWith === "string"),
1297
+ }, dialogMode.mode, canElicit),
1247
1298
  success: false,
1248
1299
  refusedReason: "blocking-dialog",
1249
1300
  blockingDialog: pressCalls ? dialog : forRecognitionOnly(dialog),
@@ -1333,14 +1384,15 @@ export async function stopEditor(projectDir, opts = {}) {
1333
1384
  // It was clean and unblocked when the quit went out, so whatever is holding
1334
1385
  // it started afterwards. Say what that is, in full.
1335
1386
  let blockedState = await readEngineState(projectPath, { probeWindows: false });
1336
- let late = await readBlockingDialog(port, host);
1387
+ const lateRead = await readBlockingDialog(port, host);
1388
+ let late = lateRead.dialog;
1337
1389
  if (late)
1338
1390
  dialogSeen = true;
1339
1391
  // The same mode governs a dialog that came up behind the quit. Interactive
1340
1392
  // puts it to the user exactly as it would have before the quit; auto and
1341
1393
  // defer leave it alone, so the loop below never runs for them.
1342
- if (late && dialogMode.mode === "interactive" && canElicit && opts.elicit) {
1343
- const press = await askUserToAnswerDialog(port, host, late, opts.elicit);
1394
+ if (late && opts.elicit) {
1395
+ const press = await askUserToAnswerDialog(port, host, late, opts.elicit, dialogMode.mode, canElicit, opts.guard);
1344
1396
  if (press !== null) {
1345
1397
  if (press.confirmed)
1346
1398
  answeredByUser = press.button;
@@ -1364,7 +1416,8 @@ export async function stopEditor(projectDir, opts = {}) {
1364
1416
  };
1365
1417
  }
1366
1418
  }
1367
- late = await readBlockingDialog(port, host);
1419
+ const lateAfter = await readBlockingDialog(port, host);
1420
+ late = lateAfter.readable ? lateAfter.dialog : late;
1368
1421
  // The snapshot above was taken BEFORE the elicitation, so it still holds
1369
1422
  // the modal the user has since answered. Rendering it after the press is
1370
1423
  // how one message came to quote a dialog that no longer exists, hand back
@@ -1409,6 +1462,7 @@ export async function stopEditor(projectDir, opts = {}) {
1409
1462
  ...(late
1410
1463
  ? {
1411
1464
  blockingDialog: pressCalls ? late : forRecognitionOnly(late),
1465
+ dialogBlocking: true,
1412
1466
  refusedReason: "blocking-dialog",
1413
1467
  }
1414
1468
  : {}),
@@ -1428,11 +1482,19 @@ export async function restartEditor(project, bridge, opts = {}) {
1428
1482
  // result: the stop only reports one when it MET a dialog, and the start half
1429
1483
  // has to keep defer's no-actuation promise whether or not the stop ran into
1430
1484
  // anything.
1485
+ const restartCanElicit = clientAdvertisesElicitation(opts.elicit);
1431
1486
  const dialogMode = resolveDialogMode({
1432
1487
  projectDir: project.projectDir ?? null,
1433
- canElicit: clientAdvertisesElicitation(opts.elicit),
1488
+ canElicit: restartCanElicit,
1489
+ });
1490
+ // Forward the editor's OWN guard. Without it the stop half fell back to
1491
+ // constructing a second one, with its own ask-once record, so a restart
1492
+ // asked the person again about a dialog the session guard had already put
1493
+ // to them, and pressed a second real button for it.
1494
+ const stopResult = await stopEditor(project.projectDir ?? undefined, {
1495
+ elicit: opts.elicit,
1496
+ guard: opts.guard,
1434
1497
  });
1435
- const stopResult = await stopEditor(project.projectDir ?? undefined, { elicit: opts.elicit });
1436
1498
  // Whether the stop mattered is a question about THIS project's editor: a
1437
1499
  // failed stop with nothing of ours left running just means it was already
1438
1500
  // down, and another project's editor being up says nothing either way.
@@ -1440,22 +1502,16 @@ export async function restartEditor(project, bridge, opts = {}) {
1440
1502
  // account of it rather than flattening it into a message string. The doc
1441
1503
  // promises dialogMode on every result that met a dialog, and a return type
1442
1504
  // that cannot hold it would make that promise unkeepable here.
1443
- const dialogAccount = {
1444
- ...(stopResult.dialogMode ? { dialogMode: stopResult.dialogMode } : {}),
1445
- ...(stopResult.dialogModeSource ? { dialogModeSource: stopResult.dialogModeSource } : {}),
1446
- ...(stopResult.blockingDialog ? { blockingDialog: stopResult.blockingDialog } : {}),
1447
- ...(stopResult.dialogAnsweredByUser ? { dialogAnsweredByUser: stopResult.dialogAnsweredByUser } : {}),
1448
- ...(stopResult.dialogPressUnconfirmed
1449
- ? { dialogPressUnconfirmed: stopResult.dialogPressUnconfirmed }
1450
- : {}),
1451
- };
1505
+ const dialogAccount = restartDialogAccount(stopResult);
1452
1506
  if (!stopResult.success && (await findInteractiveEditors(project.projectPath)).length > 0) {
1453
1507
  return { success: false, message: `Failed to stop editor: ${stopResult.message}`, ...dialogAccount };
1454
1508
  }
1455
1509
  // Wait for process to fully terminate and release locks
1456
1510
  await new Promise((resolve) => setTimeout(resolve, 3000));
1457
1511
  const startResult = await startEditor(project, undefined, undefined, {
1458
- pressCalls: dialogMode.mode !== "defer",
1512
+ // The guard's rule, not a second copy of it. This one also ignored
1513
+ // canElicit, so interactive with nobody to ask handed the buttons over.
1514
+ pressCalls: DialogGuard.handsOverPressCalls(dialogMode.mode, restartCanElicit),
1459
1515
  });
1460
1516
  if (!startResult.success) {
1461
1517
  return { ...startResult, ...dialogAccount };
@@ -1478,6 +1534,56 @@ function getPlatformString() {
1478
1534
  return "Mac";
1479
1535
  return "Linux";
1480
1536
  }
1537
+ /**
1538
+ * How many compiles this machine can run at once without running out of room.
1539
+ *
1540
+ * UnrealBuildTool defaults to one process per physical core. Each one maps the
1541
+ * Unreal precompiled header, which costs several GB, so on a machine with less
1542
+ * memory than cores x PCH the compiler does not queue: it fails outright with
1543
+ * C3859 ("failed to create virtual memory for PCH") or C1076, and the build
1544
+ * dies after several minutes of work. The failure names a paging file, so it
1545
+ * reads as a misconfigured machine rather than a parallelism default that does
1546
+ * not fit the hardware.
1547
+ *
1548
+ * This is not a knob anybody should have to find. The number is computed from
1549
+ * the machine and passed to every build, and the cap is only applied when it
1550
+ * is below what UnrealBuildTool would have chosen, so a machine with headroom
1551
+ * builds exactly as it did before.
1552
+ *
1553
+ * `UE_MCP_MAX_PARALLEL_ACTIONS` overrides it, for a machine whose real ceiling
1554
+ * this estimate gets wrong in either direction.
1555
+ */
1556
+ export function safeParallelActions(totalMemBytes = os.totalmem(), cores = os.cpus().length) {
1557
+ const override = Number(process.env.UE_MCP_MAX_PARALLEL_ACTIONS);
1558
+ if (Number.isInteger(override) && override > 0)
1559
+ return override;
1560
+ // Measured against Unreal 5.8 on Windows: a PCH compile peaks around 5 GB,
1561
+ // and the OS plus the build server want a few GB that the compiles cannot
1562
+ // have. Both are deliberately pessimistic, because being one process under
1563
+ // costs a little wall clock and being one process over costs the whole build.
1564
+ const GB = 1024 ** 3;
1565
+ const PER_COMPILE_GB = 5;
1566
+ const RESERVED_GB = 4;
1567
+ const usableGB = totalMemBytes / GB - RESERVED_GB;
1568
+ const fits = Math.floor(usableGB / PER_COMPILE_GB);
1569
+ return Math.max(1, Math.min(cores, fits));
1570
+ }
1571
+ /** Whether a build died for want of memory rather than for anything in the code. */
1572
+ export function ranOutOfMemory(output) {
1573
+ return (output.includes("C3859")
1574
+ || output.includes("C1076")
1575
+ || output.includes("paging file is too small")
1576
+ || output.includes("internal heap limit reached"));
1577
+ }
1578
+ /** What to tell somebody whose build ran out of room, in the terms they hit it in. */
1579
+ export function describeMemoryFailure(parallel, totalMemBytes = os.totalmem()) {
1580
+ const gb = Math.round(totalMemBytes / 1024 ** 3);
1581
+ return (`The build ran out of memory rather than hitting anything wrong in the code. This machine has `
1582
+ + `${gb} GB of RAM and the compile was already limited to ${parallel} parallel `
1583
+ + `${parallel === 1 ? "process" : "processes"}, each of which needs several GB for Unreal's `
1584
+ + "precompiled header. Lower it further with UE_MCP_MAX_PARALLEL_ACTIONS, or give Windows a "
1585
+ + "larger paging file, and run the build again.");
1586
+ }
1481
1587
  /**
1482
1588
  * Compile a project's C++ out of process, with UnrealBuildTool.
1483
1589
  *
@@ -1519,7 +1625,12 @@ export async function buildProject(projectPath, opts = {}) {
1519
1625
  // plainly there - while the same command pasted into a terminal worked,
1520
1626
  // because the shell removed them first.
1521
1627
  const commonArgs = [target, platform, configuration];
1522
- const tailArgs = ["-WaitMutex", "-FromMsBuild", ...(opts.clean ? ["-Clean"] : [])];
1628
+ // Only when it is below what UnrealBuildTool would have picked, so a machine
1629
+ // with headroom builds exactly as it did before and nobody has to know this
1630
+ // exists.
1631
+ const parallel = safeParallelActions();
1632
+ const parallelArgs = parallel < os.cpus().length ? [`-MaxParallelActions=${parallel}`] : [];
1633
+ const tailArgs = ["-WaitMutex", "-FromMsBuild", ...parallelArgs, ...(opts.clean ? ["-Clean"] : [])];
1523
1634
  const windowsArgs = [...commonArgs, `-Project="${resolvedPath}"`, ...tailArgs];
1524
1635
  const posixArgs = [...commonArgs, `-Project=${resolvedPath}`, ...tailArgs];
1525
1636
  return new Promise((resolve) => {
@@ -1532,8 +1643,14 @@ export async function buildProject(projectPath, opts = {}) {
1532
1643
  else {
1533
1644
  proc = spawn(buildTool, posixArgs, { stdio: "pipe" });
1534
1645
  }
1646
+ // Kept so a failure can be read afterwards. The compiler reports running
1647
+ // out of room as a paging-file problem, which sends people to their
1648
+ // virtual memory settings for what is really a parallelism default that
1649
+ // does not fit the machine.
1650
+ let transcript = "";
1535
1651
  const forward = (data) => {
1536
1652
  const text = data.toString();
1653
+ transcript += text;
1537
1654
  if (opts.onOutput)
1538
1655
  opts.onOutput(text);
1539
1656
  else
@@ -1551,7 +1668,13 @@ export async function buildProject(projectPath, opts = {}) {
1551
1668
  invalidatePluginFreshness(resolvedPath);
1552
1669
  resolve(code === 0
1553
1670
  ? { success: true, exitCode: 0, message: `Build succeeded (${target} ${platform} ${configuration})` }
1554
- : { success: false, exitCode: code, message: `Build failed with exit code ${code}` });
1671
+ : {
1672
+ success: false,
1673
+ exitCode: code,
1674
+ message: ranOutOfMemory(transcript)
1675
+ ? describeMemoryFailure(parallel)
1676
+ : `Build failed with exit code ${code}`,
1677
+ });
1555
1678
  });
1556
1679
  proc.on("error", (err) => {
1557
1680
  resolve({ success: false, exitCode: null, message: `Build error: ${err.message}` });