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.
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 +77 -3
  5. package/dist/editor-control.js +163 -108
  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
@@ -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