ue-mcp 1.3.3 → 1.3.4
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.
- package/dist/dialog-guard.d.ts +287 -0
- package/dist/dialog-guard.js +700 -0
- package/dist/dialog-guard.js.map +1 -0
- package/dist/editor-control.d.ts +77 -3
- package/dist/editor-control.js +163 -108
- package/dist/editor-control.js.map +1 -1
- package/dist/epic-cache.d.ts +12 -0
- package/dist/epic-cache.js +13 -0
- package/dist/epic-cache.js.map +1 -1
- package/dist/flow/guarded-bridge.d.ts +44 -10
- package/dist/flow/guarded-bridge.js +88 -20
- package/dist/flow/guarded-bridge.js.map +1 -1
- package/dist/flow/http-server.js +47 -4
- package/dist/flow/http-server.js.map +1 -1
- package/dist/flow/task-factory.js +41 -0
- package/dist/flow/task-factory.js.map +1 -1
- package/dist/flow/task-guards.js +17 -1
- package/dist/flow/task-guards.js.map +1 -1
- package/dist/index.js +191 -68
- package/dist/index.js.map +1 -1
- package/dist/locking.js +15 -0
- package/dist/locking.js.map +1 -1
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/session.js +8 -1
- package/dist/session.js.map +1 -1
- package/dist/tool-counts.json +2 -2
- package/dist/tools/editor.js +27 -5
- package/dist/tools/editor.js.map +1 -1
- package/package.json +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/UE_MCP_Bridge.uplugin +1 -1
- package/dist/dialog-gate.d.ts +0 -73
- package/dist/dialog-gate.js +0 -197
- package/dist/dialog-gate.js.map +0 -1
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge methods the plugin answers while a modal is up.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors ModalSafeMethods in BridgeServer.cpp plus the handshake reads served
|
|
5
|
+
* before the gate. Pinned by tests/unit/dialog-modal-safe-parity.test.ts,
|
|
6
|
+
* because a method the plugin serves but this list omits would be read as proof
|
|
7
|
+
* the editor is running.
|
|
8
|
+
*/
|
|
9
|
+
const MODAL_SAFE_METHODS = new Set([
|
|
10
|
+
"list_dialogs",
|
|
11
|
+
"respond_to_dialog",
|
|
12
|
+
"get_dialog_policy",
|
|
13
|
+
"set_dialog_policy",
|
|
14
|
+
"clear_dialog_policy",
|
|
15
|
+
"get_engine_state",
|
|
16
|
+
"get_bridge_capabilities",
|
|
17
|
+
"get_param_echo",
|
|
18
|
+
"clear_param_echo",
|
|
19
|
+
]);
|
|
20
|
+
export function isModalSafeMethod(method) {
|
|
21
|
+
return MODAL_SAFE_METHODS.has(method);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Bridge methods that stay callable while a modal is up.
|
|
25
|
+
*
|
|
26
|
+
* `set_dialog_policy` and `clear_dialog_policy` are modal-safe in the plugin,
|
|
27
|
+
* so it will serve them, but an armed policy PRESSES BUTTONS on a dialog
|
|
28
|
+
* already on screen. Letting one through while blocked would answer the modal
|
|
29
|
+
* with no person involved, which is the whole thing `defer` promises not to do.
|
|
30
|
+
* They are armed in advance or not at all.
|
|
31
|
+
*/
|
|
32
|
+
const BRIDGE_ALLOWED_WHILE_BLOCKED = new Set([
|
|
33
|
+
"list_dialogs",
|
|
34
|
+
"respond_to_dialog",
|
|
35
|
+
"get_dialog_policy",
|
|
36
|
+
"get_engine_state",
|
|
37
|
+
"get_bridge_capabilities",
|
|
38
|
+
"get_param_echo",
|
|
39
|
+
]);
|
|
40
|
+
/** The actions that exist to get a stuck editor moving again. */
|
|
41
|
+
const RECOVERY_ACTIONS = new Set([
|
|
42
|
+
"editor.start_editor",
|
|
43
|
+
"editor.stop_editor",
|
|
44
|
+
"editor.restart_editor",
|
|
45
|
+
]);
|
|
46
|
+
/** Tool actions that stay callable, or the dialog could never be answered. */
|
|
47
|
+
const ACTIONS_ALLOWED_WHILE_BLOCKED = new Set([
|
|
48
|
+
// Read the dialog and answer it.
|
|
49
|
+
"editor.list_dialogs",
|
|
50
|
+
"editor.respond_to_dialog",
|
|
51
|
+
"editor.get_dialog_policy",
|
|
52
|
+
// Work out what is going on.
|
|
53
|
+
"editor.get_engine_state",
|
|
54
|
+
"project.get_status",
|
|
55
|
+
// Editor lifecycle. Permitted because they are how a caller gets out of a
|
|
56
|
+
// stuck editor, and refusing them leaves no way back at all. They are NOT
|
|
57
|
+
// invisible to the dialog: every allowed subject still probes, and every
|
|
58
|
+
// allowed result carries the same "the editor is blocked" stamp, so a modal
|
|
59
|
+
// is reported on these routes rather than passed over.
|
|
60
|
+
"editor.start_editor",
|
|
61
|
+
"editor.stop_editor",
|
|
62
|
+
"editor.restart_editor",
|
|
63
|
+
]);
|
|
64
|
+
/** True for the refusal the plugin's own gate emits. */
|
|
65
|
+
export function isDialogRefusal(v) {
|
|
66
|
+
return typeof v === "object" && v !== null
|
|
67
|
+
&& v.dialogBlocking === true;
|
|
68
|
+
}
|
|
69
|
+
/** Read a dialog out of whatever shape reported it. */
|
|
70
|
+
function asDialog(v) {
|
|
71
|
+
if (typeof v !== "object" || v === null)
|
|
72
|
+
return null;
|
|
73
|
+
const r = v;
|
|
74
|
+
const title = typeof r.dialogTitle === "string" ? r.dialogTitle
|
|
75
|
+
: typeof r.title === "string" ? r.title : null;
|
|
76
|
+
if (title === null)
|
|
77
|
+
return null;
|
|
78
|
+
const message = typeof r.dialogMessage === "string" ? r.dialogMessage
|
|
79
|
+
: typeof r.message === "string" ? r.message : "";
|
|
80
|
+
return {
|
|
81
|
+
title,
|
|
82
|
+
message,
|
|
83
|
+
buttons: Array.isArray(r.buttons) ? r.buttons : [],
|
|
84
|
+
choices: Array.isArray(r.choices)
|
|
85
|
+
? r.choices
|
|
86
|
+
: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* How old a status file may be and still describe a live editor. The plugin
|
|
91
|
+
* flushes it on a timer while running, so anything older is a leftover.
|
|
92
|
+
*/
|
|
93
|
+
export const STATUS_STALE_AFTER_MS = 15_000;
|
|
94
|
+
export class DialogGuard {
|
|
95
|
+
deps;
|
|
96
|
+
blocking = null;
|
|
97
|
+
/** True when the only evidence of a dialog is a file nobody is updating. */
|
|
98
|
+
staleStatus = false;
|
|
99
|
+
/** What the last interactive answer did, for callers that report it. */
|
|
100
|
+
pressed = null;
|
|
101
|
+
/** Identity of the dialog the last press was aimed at. */
|
|
102
|
+
lastAsked = null;
|
|
103
|
+
/** The ask in flight, so parallel calls share it instead of each asking. */
|
|
104
|
+
asking = null;
|
|
105
|
+
/** Which dialog that in-flight ask is about. */
|
|
106
|
+
askingFor = null;
|
|
107
|
+
poll = null;
|
|
108
|
+
constructor(deps) {
|
|
109
|
+
this.deps = deps;
|
|
110
|
+
}
|
|
111
|
+
/** Replace the dependencies without losing what the guard knows. */
|
|
112
|
+
setDeps(deps) {
|
|
113
|
+
this.deps = deps;
|
|
114
|
+
}
|
|
115
|
+
/** What the last interactive answer did, or null if nothing was pressed. */
|
|
116
|
+
get lastPressed() {
|
|
117
|
+
return this.pressed;
|
|
118
|
+
}
|
|
119
|
+
/** The dialog currently believed to be blocking, if any. */
|
|
120
|
+
get current() {
|
|
121
|
+
return this.blocking;
|
|
122
|
+
}
|
|
123
|
+
/** How this machine wants a blocking dialog handled, before elicitation. */
|
|
124
|
+
get mode() {
|
|
125
|
+
return this.deps.mode();
|
|
126
|
+
}
|
|
127
|
+
/** Whether there is anybody to put an elicitation form in front of. */
|
|
128
|
+
get canElicit() {
|
|
129
|
+
return this.deps.elicit?.() !== undefined;
|
|
130
|
+
}
|
|
131
|
+
/** Record a dialog. Called by the watcher, a probe, or a plugin refusal. */
|
|
132
|
+
note(dialog) {
|
|
133
|
+
this.blocking = dialog;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Record that the editor is clear.
|
|
137
|
+
*
|
|
138
|
+
* Only ever called with positive evidence: a probe that ANSWERED and listed
|
|
139
|
+
* nothing, or a non-modal-safe bridge call that ran. A failed probe is not
|
|
140
|
+
* evidence and must never land here, or a dropped socket would disarm the
|
|
141
|
+
* guard while the dialog is still on screen.
|
|
142
|
+
*/
|
|
143
|
+
clear() {
|
|
144
|
+
this.blocking = null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether this mode hands over the calls that press a dialog's buttons.
|
|
148
|
+
*
|
|
149
|
+
* The one place that rule lives. stop_editor computed `mode !== "defer"`
|
|
150
|
+
* itself, so the same decision existed twice and could drift: the guard's
|
|
151
|
+
* defer branch omits `choices` entirely while the other kept a stripped
|
|
152
|
+
* version of them.
|
|
153
|
+
*
|
|
154
|
+
* Not conditioned on elicitation. `auto` exists precisely for a caller
|
|
155
|
+
* with nobody to ask, so withholding the press calls from it would leave
|
|
156
|
+
* that caller no way to answer at all. Routes with nobody on them are
|
|
157
|
+
* handled where they are known about, in `refusal`.
|
|
158
|
+
*/
|
|
159
|
+
static handsOverPressCalls(mode, canElicit = true) {
|
|
160
|
+
return DialogGuard.effectiveMode(mode, canElicit) !== "defer";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The mode as it actually applies, given whether anyone can be asked.
|
|
164
|
+
*
|
|
165
|
+
* interactive with no channel to a person is not interactive: it would
|
|
166
|
+
* report "interactive" and hand the buttons to the agent anyway, which is
|
|
167
|
+
* auto's behaviour under a mode whose contract is that a PERSON chooses.
|
|
168
|
+
* The mode resolver already falls back to defer when the client advertises
|
|
169
|
+
* no elicitation; this applies the same rule everywhere else.
|
|
170
|
+
*/
|
|
171
|
+
static effectiveMode(mode, canElicit) {
|
|
172
|
+
return mode === "interactive" && !canElicit ? "defer" : mode;
|
|
173
|
+
}
|
|
174
|
+
/** True when this bridge method may be sent while a modal is up. */
|
|
175
|
+
static bridgeAllowed(method) {
|
|
176
|
+
return BRIDGE_ALLOWED_WHILE_BLOCKED.has(method);
|
|
177
|
+
}
|
|
178
|
+
/** True when this tool action may run while a modal is up. */
|
|
179
|
+
static actionAllowed(taskName) {
|
|
180
|
+
return ACTIONS_ALLOWED_WHILE_BLOCKED.has(taskName);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The single decision.
|
|
184
|
+
*
|
|
185
|
+
* `subject` is the bridge method or the `tool.action` being attempted, and is
|
|
186
|
+
* only used to name it back to the caller and to check the allow list.
|
|
187
|
+
*/
|
|
188
|
+
async check(subject, kind, opts = {}) {
|
|
189
|
+
const allowed = kind === "bridge"
|
|
190
|
+
? DialogGuard.bridgeAllowed(subject)
|
|
191
|
+
: DialogGuard.actionAllowed(subject);
|
|
192
|
+
// An allowed subject still REFRESHES what is known, it just is not refused
|
|
193
|
+
// for it. Returning early without asking meant a cold guard never learned
|
|
194
|
+
// the dialog at all (so get_status reported a healthy editor while the
|
|
195
|
+
// game thread was parked) and a stale one was never corrected (so the very
|
|
196
|
+
// call that answered the dialog came back stamped as blocked).
|
|
197
|
+
const dialog = await this.currentDialog();
|
|
198
|
+
if (allowed)
|
|
199
|
+
return { allow: true };
|
|
200
|
+
if (!dialog)
|
|
201
|
+
return { allow: true };
|
|
202
|
+
return this.decideFor(subject, dialog, opts);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Recovery actions: always permitted, whatever is latched.
|
|
206
|
+
*
|
|
207
|
+
* Relaunching or stopping an editor is how a caller gets out of a state
|
|
208
|
+
* where the editor is stuck. An editor whose socket has parked while its
|
|
209
|
+
* status thread keeps publishing holds the latch fresh indefinitely, so
|
|
210
|
+
* gating these refused the only way back forever. They still report the
|
|
211
|
+
* dialog through the same stamp every allowed read carries.
|
|
212
|
+
*/
|
|
213
|
+
static isRecoveryAction(taskName) {
|
|
214
|
+
return RECOVERY_ACTIONS.has(taskName);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The decision about a dialog the caller ALREADY has, with no probe.
|
|
218
|
+
*
|
|
219
|
+
* `check` is this plus finding the dialog first. A caller that just read it
|
|
220
|
+
* (stop_editor, which must read before it sends a quit) uses this, so the
|
|
221
|
+
* mode is applied in exactly one place without paying for a second read or
|
|
222
|
+
* risking a re-probe that answers differently.
|
|
223
|
+
*/
|
|
224
|
+
async decideFor(subject, dialog, opts = {}) {
|
|
225
|
+
// interactive: the dialog goes to the person, and only the button they
|
|
226
|
+
// pick is pressed. Answering it clears the way, so the call proceeds.
|
|
227
|
+
//
|
|
228
|
+
// Asked ONCE per dialog. A single call is checked twice, once before
|
|
229
|
+
// dispatch and once at the bridge, and an in-process action needs the
|
|
230
|
+
// first while a bridge call needs the second. Without this the person was
|
|
231
|
+
// shown the same prompt twice and the button was pressed TWICE, which on a
|
|
232
|
+
// save prompt is two real answers for one action. If the same dialog is
|
|
233
|
+
// still there after a press, pressing again will not help: report it.
|
|
234
|
+
const identity = `${dialog.title} :: ${dialog.message}`;
|
|
235
|
+
// canElicit: false means this route has nobody to ask. An HTTP request
|
|
236
|
+
// has no person on it, and eliciting would raise a form in whichever
|
|
237
|
+
// MCP client last touched this guard and press a real button on its
|
|
238
|
+
// answer, for a request that client never made.
|
|
239
|
+
const mayAsk = opts.canElicit !== false;
|
|
240
|
+
if (mayAsk && this.deps.mode() === "interactive" && this.lastAsked !== identity) {
|
|
241
|
+
// Parallel tool calls share one ask. Clients batch calls, and asking per
|
|
242
|
+
// call put three forms in front of the person and pressed three real
|
|
243
|
+
// buttons for one dialog, with presses two and three landing on whatever
|
|
244
|
+
// was on screen after the first.
|
|
245
|
+
// Shared only for the SAME dialog. Keyed on nothing, a caller that saw a
|
|
246
|
+
// different prompt would await this one's form and inherit its press,
|
|
247
|
+
// answering a question it was never shown.
|
|
248
|
+
if (!this.asking || this.askingFor !== identity) {
|
|
249
|
+
this.askingFor = identity;
|
|
250
|
+
this.asking = this.askUser(dialog).finally(() => {
|
|
251
|
+
this.asking = null;
|
|
252
|
+
this.askingFor = null;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const outcome = await this.asking;
|
|
256
|
+
const pressed = outcome.press;
|
|
257
|
+
// Recorded only once a form was actually SHOWN. Setting it before the
|
|
258
|
+
// ask meant an elicitation that threw, timed out, or was declined
|
|
259
|
+
// consumed the one chance, and the person was never asked about that
|
|
260
|
+
// dialog again for the life of the process.
|
|
261
|
+
if (outcome.shown)
|
|
262
|
+
this.lastAsked = identity;
|
|
263
|
+
this.pressed = pressed;
|
|
264
|
+
// Only a CONFIRMED press proves the way is clear. An unknown one is
|
|
265
|
+
// reported, not assumed.
|
|
266
|
+
if (pressed?.confirmed) {
|
|
267
|
+
this.clear();
|
|
268
|
+
return { allow: true };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { allow: false, refusal: this.refusal(subject, dialog, opts) };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Re-read what is on screen, deciding nothing.
|
|
275
|
+
*
|
|
276
|
+
* `check` applies the mode, which in interactive raises a form and presses a
|
|
277
|
+
* button. A caller that has just answered a dialog by hand and only wants
|
|
278
|
+
* the state corrected must not do that: it would put a form up for whatever
|
|
279
|
+
* prompt the first answer surfaced and press a button on it, unasked.
|
|
280
|
+
*/
|
|
281
|
+
async refresh() {
|
|
282
|
+
await this.currentDialog();
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* What is on screen right now.
|
|
286
|
+
*
|
|
287
|
+
* The watcher usually knows already. When it does not, ask the editor. A
|
|
288
|
+
* probe that THROWS is not an answer: the state is left exactly as it was,
|
|
289
|
+
* so an unreachable editor cannot disarm the guard.
|
|
290
|
+
*/
|
|
291
|
+
async currentDialog() {
|
|
292
|
+
// Always ask, even when a dialog is already believed to be up. Trusting
|
|
293
|
+
// the cached value meant a dialog answered by hand in the editor window
|
|
294
|
+
// left the guard latched forever, with no call able to clear it.
|
|
295
|
+
// A disconnected socket cannot be asked, so skip the probe: it would burn a
|
|
296
|
+
// connection attempt per call, twice per gated call, and the full timeout
|
|
297
|
+
// each time against an editor that is down.
|
|
298
|
+
//
|
|
299
|
+
// But it is NOT evidence there is no dialog. isConnected describes the
|
|
300
|
+
// SOCKET, and an editor can be alive and sitting on a modal with its socket
|
|
301
|
+
// dropped, still publishing that modal to its status file. Clearing here
|
|
302
|
+
// deleted what the watcher knew and let everything run against a frozen
|
|
303
|
+
// editor, permanently, which is worse than the cost it saved.
|
|
304
|
+
//
|
|
305
|
+
// So: keep what is known. A genuinely dead editor is already handled, its
|
|
306
|
+
// status file is absent or stale and the watcher has cleared the latch.
|
|
307
|
+
if (this.deps.isConnected?.() === false) {
|
|
308
|
+
// Nothing refreshing the status file means there is no editor to be
|
|
309
|
+
// blocked. Returning the latch unconditionally here is what made a dead
|
|
310
|
+
// editor refuse every action forever, including the two the refusal
|
|
311
|
+
// names, with no way back but restarting the server.
|
|
312
|
+
if (this.staleStatus) {
|
|
313
|
+
this.clear();
|
|
314
|
+
this.lastAsked = null;
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
return this.blocking;
|
|
318
|
+
}
|
|
319
|
+
let answered;
|
|
320
|
+
try {
|
|
321
|
+
answered = await this.deps.probe();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// The editor did not answer, and getting here means the socket was UP:
|
|
325
|
+
// the disconnected case returned above. A live socket that will not
|
|
326
|
+
// answer is an editor whose game thread is not running, which is the
|
|
327
|
+
// exact condition this guard exists for. So keep what is known and never
|
|
328
|
+
// clear.
|
|
329
|
+
//
|
|
330
|
+
// Clearing here on a missing status file was a permanent miss rather
|
|
331
|
+
// than a race: a project whose editor has not published a snapshot yet
|
|
332
|
+
// is the steady state right after launch, so the latch was dropped every
|
|
333
|
+
// time instead of occasionally.
|
|
334
|
+
//
|
|
335
|
+
// With no isConnected to consult there is no way to tell a parked editor
|
|
336
|
+
// from a dead one, and then a stale or absent snapshot is the only
|
|
337
|
+
// evidence available: clearing on it is what keeps a dead editor from
|
|
338
|
+
// refusing every action forever, including the two the refusal names.
|
|
339
|
+
const socketUp = this.deps.isConnected?.() === true;
|
|
340
|
+
if (!socketUp && (this.staleStatus || this.deps.readSnapshot === undefined)) {
|
|
341
|
+
this.clear();
|
|
342
|
+
this.lastAsked = null;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return this.blocking;
|
|
346
|
+
}
|
|
347
|
+
const list = answered?.dialogs;
|
|
348
|
+
if (!Array.isArray(list)) {
|
|
349
|
+
// Answered, but not in a shape this understands (an older plugin
|
|
350
|
+
// returning "Unknown method" as a result rather than throwing). That is
|
|
351
|
+
// not positive evidence the screen is clear, so keep what we know.
|
|
352
|
+
return this.blocking;
|
|
353
|
+
}
|
|
354
|
+
const first = list[0];
|
|
355
|
+
if (!first) {
|
|
356
|
+
// The editor ANSWERED and listed nothing, so the screen really is clear.
|
|
357
|
+
// That is the only place the asked-once record is reset: clearing after
|
|
358
|
+
// a press must not reset it, or the next check re-prompts for a dialog
|
|
359
|
+
// that press was meant to answer and presses a second button.
|
|
360
|
+
this.clear();
|
|
361
|
+
this.lastAsked = null;
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const dialog = asDialog(first);
|
|
365
|
+
if (dialog)
|
|
366
|
+
this.note(dialog);
|
|
367
|
+
return dialog;
|
|
368
|
+
}
|
|
369
|
+
/** Put it to the person; return the button they chose, or null. */
|
|
370
|
+
async askUser(dialog) {
|
|
371
|
+
const elicit = this.deps.elicit?.();
|
|
372
|
+
if (!elicit || dialog.buttons.length === 0)
|
|
373
|
+
return { shown: false, press: null };
|
|
374
|
+
const LEAVE_OPEN = "Leave the dialog open";
|
|
375
|
+
let answer;
|
|
376
|
+
try {
|
|
377
|
+
answer = await elicit({
|
|
378
|
+
message: [
|
|
379
|
+
"Unreal Editor is blocked on a modal dialog and is waiting for an answer.",
|
|
380
|
+
"Nothing else can run until it is answered.",
|
|
381
|
+
"",
|
|
382
|
+
`Title: ${dialog.title}`,
|
|
383
|
+
dialog.message === "" ? "(no message text)" : dialog.message,
|
|
384
|
+
"",
|
|
385
|
+
"Choose the button to press. Nothing is pressed unless you choose it.",
|
|
386
|
+
].join("\n"),
|
|
387
|
+
requestedSchema: {
|
|
388
|
+
type: "object",
|
|
389
|
+
properties: {
|
|
390
|
+
button: {
|
|
391
|
+
type: "string",
|
|
392
|
+
title: "Button",
|
|
393
|
+
description: "The dialog's own buttons, in the order it lays them out.",
|
|
394
|
+
enum: [...dialog.buttons, LEAVE_OPEN],
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
required: ["button"],
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// The form never rendered. Not an answer, and not a used-up chance.
|
|
403
|
+
return { shown: false, press: null };
|
|
404
|
+
}
|
|
405
|
+
if (answer.action !== "accept")
|
|
406
|
+
return { shown: true, press: null };
|
|
407
|
+
const chosen = answer.content?.button;
|
|
408
|
+
if (typeof chosen !== "string" || chosen === LEAVE_OPEN)
|
|
409
|
+
return { shown: true, press: null };
|
|
410
|
+
if (!dialog.buttons.includes(chosen))
|
|
411
|
+
return { shown: true, press: null };
|
|
412
|
+
// Three outcomes, not two.
|
|
413
|
+
//
|
|
414
|
+
// pressed the editor confirmed it
|
|
415
|
+
// not pressed the editor answered and said it did not (wrong label,
|
|
416
|
+
// dialog already gone, handler refused)
|
|
417
|
+
// unknown the frame went out and nothing came back, so whether the
|
|
418
|
+
// button was pressed genuinely cannot be reported either way
|
|
419
|
+
//
|
|
420
|
+
// Collapsing the third into "not pressed" claims the editor is untouched
|
|
421
|
+
// when it may not be.
|
|
422
|
+
try {
|
|
423
|
+
const reply = await this.deps.press(chosen);
|
|
424
|
+
const r = typeof reply === "object" && reply !== null
|
|
425
|
+
? reply
|
|
426
|
+
: {};
|
|
427
|
+
// The editor answered and the press went through.
|
|
428
|
+
const confirmed = r.answered === true
|
|
429
|
+
? !r.methodError && !r.refused && r.success !== false
|
|
430
|
+
: r.success === true;
|
|
431
|
+
if (confirmed)
|
|
432
|
+
return { shown: true, press: { button: chosen, confirmed: true } };
|
|
433
|
+
// It answered and said it did not press: nothing happened, report silence.
|
|
434
|
+
if (r.answered === true)
|
|
435
|
+
return { shown: true, press: null };
|
|
436
|
+
// It never answered. The frame was already on the wire, so whether the
|
|
437
|
+
// button was pressed is genuinely unknown and must not be claimed.
|
|
438
|
+
return { shown: true, press: { button: chosen, confirmed: false } };
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return { shown: true, press: null };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** The one refusal shape, whatever the route and whatever the mode. */
|
|
445
|
+
refusal(subject, dialog, opts = {}) {
|
|
446
|
+
const canAsk = opts.canElicit !== false && this.deps.elicit?.() !== undefined;
|
|
447
|
+
return DialogGuard.describeRefusal(subject, dialog, this.deps.mode(), canAsk);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The refusal shape. ONE definition, so every route hands a caller the same
|
|
451
|
+
* fields to branch on.
|
|
452
|
+
*
|
|
453
|
+
* Static because stop_editor builds its refusal without an instance: it runs
|
|
454
|
+
* over its own transport while a quit may be in flight. It used to assemble
|
|
455
|
+
* its own object, which carried `dialogBlocking` but no `refusedMethod`,
|
|
456
|
+
* `dialogTitle`, `dialogMessage` or `error`, so a client reading those got
|
|
457
|
+
* undefined depending on which route refused it.
|
|
458
|
+
*/
|
|
459
|
+
static describeRefusal(subject, dialog, resolvedMode, canElicit = true) {
|
|
460
|
+
const mode = DialogGuard.effectiveMode(resolvedMode, canElicit);
|
|
461
|
+
const common = {
|
|
462
|
+
success: false,
|
|
463
|
+
dialogBlocking: true,
|
|
464
|
+
refusedMethod: subject,
|
|
465
|
+
dialogMode: mode,
|
|
466
|
+
dialogTitle: dialog.title,
|
|
467
|
+
dialogMessage: dialog.message,
|
|
468
|
+
buttons: dialog.buttons,
|
|
469
|
+
};
|
|
470
|
+
const preamble = `A modal dialog is blocking the editor, so '${subject}' was refused without running. `
|
|
471
|
+
+ "Unreal cannot execute anything else until the dialog is answered. ";
|
|
472
|
+
if (mode === "defer") {
|
|
473
|
+
return {
|
|
474
|
+
...common,
|
|
475
|
+
error: preamble
|
|
476
|
+
+ "Dialog mode is defer, so this names the dialog but not the calls that press its "
|
|
477
|
+
+ "buttons: answer it in the Unreal Editor window. To answer it from here instead, "
|
|
478
|
+
+ "read it with editor(list_dialogs) and press with editor(respond_to_dialog).",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
...common,
|
|
483
|
+
choices: dialog.choices,
|
|
484
|
+
error: preamble
|
|
485
|
+
+ "Read it in dialogMessage, choose a button, and press it with the call beside it in "
|
|
486
|
+
+ "choices. Every other action returns this same refusal until then.",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Learn from a bridge reply.
|
|
491
|
+
*
|
|
492
|
+
* A refusal names the dialog. Anything else is evidence the game thread ran,
|
|
493
|
+
* but ONLY for a method the plugin would have refused: a modal-safe method
|
|
494
|
+
* answers either way, so reading the dialog list must not be mistaken for the
|
|
495
|
+
* dialog having gone.
|
|
496
|
+
*/
|
|
497
|
+
observe(method, result) {
|
|
498
|
+
if (isDialogRefusal(result)) {
|
|
499
|
+
const dialog = asDialog(result);
|
|
500
|
+
if (dialog)
|
|
501
|
+
this.note(dialog);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!isModalSafeMethod(method))
|
|
505
|
+
this.clear();
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Watch the editor's status file so a dialog raised while nothing is running
|
|
509
|
+
* is known immediately, rather than at the next call.
|
|
510
|
+
*
|
|
511
|
+
* The plugin refreshes that file from the modal-loop tick, which keeps firing
|
|
512
|
+
* while the game thread is parked. Watching costs nothing on the hot path and
|
|
513
|
+
* needs no bridge traffic; the poll is a fallback for platforms where the
|
|
514
|
+
* watch does not fire.
|
|
515
|
+
*/
|
|
516
|
+
startWatching(intervalMs = 1000) {
|
|
517
|
+
if (this.poll)
|
|
518
|
+
return;
|
|
519
|
+
const read = () => {
|
|
520
|
+
if (!this.deps.readSnapshot)
|
|
521
|
+
return;
|
|
522
|
+
let snap;
|
|
523
|
+
try {
|
|
524
|
+
snap = this.deps.readSnapshot();
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// No snapshot, or one nobody has refreshed. The plugin removes its file
|
|
530
|
+
// on a clean shutdown and a crash leaves a stale one behind, often
|
|
531
|
+
// recording the very modal that preceded it, so neither is evidence of a
|
|
532
|
+
// live dialog. Believing them refused every action forever.
|
|
533
|
+
const stale = snap === null
|
|
534
|
+
|| (snap.ageSeconds !== undefined && snap.ageSeconds * 1000 > STATUS_STALE_AFTER_MS);
|
|
535
|
+
if (stale) {
|
|
536
|
+
this.staleStatus = true;
|
|
537
|
+
this.clear();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.staleStatus = false;
|
|
541
|
+
const modal = snap?.modal;
|
|
542
|
+
const dialog = asDialog(modal);
|
|
543
|
+
if (dialog)
|
|
544
|
+
this.note(dialog);
|
|
545
|
+
else if (modal === null || modal === undefined)
|
|
546
|
+
this.clear();
|
|
547
|
+
};
|
|
548
|
+
this.poll = setInterval(read, intervalMs);
|
|
549
|
+
this.poll.unref?.();
|
|
550
|
+
read();
|
|
551
|
+
}
|
|
552
|
+
stopWatching() {
|
|
553
|
+
if (this.poll)
|
|
554
|
+
clearInterval(this.poll);
|
|
555
|
+
this.poll = null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* A raw bridge that still cannot press a dialog button.
|
|
560
|
+
*
|
|
561
|
+
* Plugin guard tasks run on the RAW bridge on purpose: routing them through
|
|
562
|
+
* GuardedBridge would re-enter the guard pipeline that is running them. That
|
|
563
|
+
* left one real hole, because `set_dialog_policy` is modal-safe in the plugin
|
|
564
|
+
* and WILL be served: a guard task could arm a policy that answers the modal
|
|
565
|
+
* already on screen, with no person involved, under a mode that promises
|
|
566
|
+
* exactly the opposite.
|
|
567
|
+
*
|
|
568
|
+
* Applied to the SESSION's bridge, so it holds on every path: the guarded
|
|
569
|
+
* bridge wraps it, guard tasks get it, and a handler reaching for
|
|
570
|
+
* `ctx.session.bridge` directly gets it too. Wrapping only one caller left the
|
|
571
|
+
* escape one line away.
|
|
572
|
+
*
|
|
573
|
+
* This refuses those two methods and nothing else, from state already held, so
|
|
574
|
+
* it adds no round-trip and cannot recurse.
|
|
575
|
+
*/
|
|
576
|
+
export function withoutDialogActuation(session, raw) {
|
|
577
|
+
return new Proxy(raw, {
|
|
578
|
+
get(target, prop, receiver) {
|
|
579
|
+
if (prop !== "call")
|
|
580
|
+
return Reflect.get(target, prop, receiver);
|
|
581
|
+
return async (method, params, timeoutMs) => {
|
|
582
|
+
const armsAPolicy = method === "set_dialog_policy" || method === "clear_dialog_policy";
|
|
583
|
+
if (!armsAPolicy)
|
|
584
|
+
return target.call(method, params, timeoutMs);
|
|
585
|
+
// This is the ONLY thing standing in front of an unattended button
|
|
586
|
+
// press: the plugin serves both methods during a modal on purpose, so
|
|
587
|
+
// whatever this lets through presses a button on the dialog already on
|
|
588
|
+
// screen. It therefore fails CLOSED, the same way refuseIfBlocked does
|
|
589
|
+
// at the other boundary, and it asks rather than reading a latch that
|
|
590
|
+
// may never have been armed.
|
|
591
|
+
//
|
|
592
|
+
// Reading `current` alone was not a race: with no readable snapshot
|
|
593
|
+
// the latch is empty in the steady state, so a live modal was missed
|
|
594
|
+
// every time rather than occasionally.
|
|
595
|
+
const guard = await ensureGuard(session);
|
|
596
|
+
await guard.refresh();
|
|
597
|
+
const dialog = guard.current;
|
|
598
|
+
if (dialog) {
|
|
599
|
+
return {
|
|
600
|
+
...DialogGuard.describeRefusal(method, dialog, guard.mode, guard.canElicit),
|
|
601
|
+
error: `'${method}' was refused: a modal dialog is on screen and an armed policy presses `
|
|
602
|
+
+ "its buttons. Arm a policy before a dialog appears, or answer this one with "
|
|
603
|
+
+ "editor(respond_to_dialog).",
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return target.call(method, params, timeoutMs);
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Mark a successful result as coming from an editor that is blocked.
|
|
613
|
+
*
|
|
614
|
+
* An allowed read still SAYS a dialog is up: get_status is the first call every
|
|
615
|
+
* client makes, and reporting a healthy editor while the game thread is parked
|
|
616
|
+
* is the one answer it must never give.
|
|
617
|
+
*
|
|
618
|
+
* `editorBlockedByDialog` deliberately is NOT `dialogBlocking`, which means
|
|
619
|
+
* "this call was refused". Stamping that here would have a client treat a
|
|
620
|
+
* successful read as a refusal.
|
|
621
|
+
*
|
|
622
|
+
* Returns the value unchanged when there is nothing to say, or when the shape
|
|
623
|
+
* cannot carry the fields: an array is `typeof "object"`, so it took the
|
|
624
|
+
* properties and then lost them silently in JSON.stringify.
|
|
625
|
+
*/
|
|
626
|
+
export function stampBlockedEditor(data, dialog) {
|
|
627
|
+
if (!dialog)
|
|
628
|
+
return data;
|
|
629
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
630
|
+
return data;
|
|
631
|
+
const out = data;
|
|
632
|
+
out.editorBlockedByDialog = true;
|
|
633
|
+
out.dialogTitle = dialog.title;
|
|
634
|
+
out.dialogMessage = dialog.message;
|
|
635
|
+
out.dialogNote =
|
|
636
|
+
"A modal dialog is blocking this editor. Every other action is refused until it is "
|
|
637
|
+
+ "answered: read it with editor(list_dialogs) and press with editor(respond_to_dialog).";
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
/** One guard per editor. */
|
|
641
|
+
const guards = new WeakMap();
|
|
642
|
+
/**
|
|
643
|
+
* The guard for a session, created from the session itself if it has none.
|
|
644
|
+
*
|
|
645
|
+
* The gate fails closed: no guard means the boundary cannot establish whether
|
|
646
|
+
* a modal is up, so it refuses. That is only safe if every session HAS one,
|
|
647
|
+
* and guards were created in one startup pass, so a session registered any
|
|
648
|
+
* other way had none and was refused everything it ever tried.
|
|
649
|
+
*
|
|
650
|
+
* Every dependency here is derivable from the session, so there is no reason
|
|
651
|
+
* for that gap to exist. What startup adds on top is the client's elicitation
|
|
652
|
+
* capability, which is not knowable here; guardFor replaces the deps, so that
|
|
653
|
+
* pass upgrades this guard rather than competing with it.
|
|
654
|
+
*/
|
|
655
|
+
export async function ensureGuard(session) {
|
|
656
|
+
const existing = guards.get(session);
|
|
657
|
+
if (existing)
|
|
658
|
+
return existing;
|
|
659
|
+
// Dynamic, because editor-control imports this module: a static import here
|
|
660
|
+
// would close the cycle.
|
|
661
|
+
const { resolveDialogMode } = await import("./editor-control.js");
|
|
662
|
+
const { readEngineSnapshot } = await import("./engine-observer.js");
|
|
663
|
+
const guard = guardFor(session, {
|
|
664
|
+
mode: () => resolveDialogMode({ projectDir: session.projectDir, canElicit: false }).mode,
|
|
665
|
+
probe: () => session.guarded.call("list_dialogs", {}),
|
|
666
|
+
press: (buttonLabel) => session.guarded.call("respond_to_dialog", { buttonLabel }),
|
|
667
|
+
isConnected: () => session.bridge.isConnected,
|
|
668
|
+
readSnapshot: () => {
|
|
669
|
+
const proj = session.project.projectPath ?? session.bridge.getTarget().projectPath ?? null;
|
|
670
|
+
return proj ? readEngineSnapshot(proj) : null;
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
guard.startWatching();
|
|
674
|
+
return guard;
|
|
675
|
+
}
|
|
676
|
+
export function guardFor(session, deps) {
|
|
677
|
+
const existing = guards.get(session);
|
|
678
|
+
if (existing) {
|
|
679
|
+
// The guard is kept, but its dependencies are REPLACED. They close over
|
|
680
|
+
// whether the connected client can be elicited, which is not known at
|
|
681
|
+
// startup and differs per call. Caching the first set froze every guard
|
|
682
|
+
// with canElicit=false, so the mode never resolved to interactive and the
|
|
683
|
+
// elicit hook was permanently undefined: interactive mode could not fire
|
|
684
|
+
// on any route, on any server.
|
|
685
|
+
existing.setDeps(deps);
|
|
686
|
+
return existing;
|
|
687
|
+
}
|
|
688
|
+
const created = new DialogGuard(deps);
|
|
689
|
+
guards.set(session, created);
|
|
690
|
+
return created;
|
|
691
|
+
}
|
|
692
|
+
export function existingGuard(session) {
|
|
693
|
+
return guards.get(session);
|
|
694
|
+
}
|
|
695
|
+
/** Test seam. */
|
|
696
|
+
export function forgetGuard(session) {
|
|
697
|
+
guards.get(session)?.stopWatching();
|
|
698
|
+
guards.delete(session);
|
|
699
|
+
}
|
|
700
|
+
//# sourceMappingURL=dialog-guard.js.map
|