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
@@ -3,6 +3,38 @@ import { stripEditorTarget } from "../types.js";
3
3
  import { prepareCall, finishCall } from "../call-pipeline.js";
4
4
  import { liftRollback } from "./rollback.js";
5
5
  import { applyHandlerOutcome } from "./handler-outcome.js";
6
+ import { DialogGuard, ensureGuard } from "../dialog-guard.js";
7
+ /**
8
+ * Refuse a flow step while a modal is up, through the one guard.
9
+ *
10
+ * The bridge-backed steps are covered at the bridge boundary. An in-process
11
+ * handler never goes near it, so without this the gate simply was not on that
12
+ * route: a modal raised mid-run was missed by every remaining step.
13
+ *
14
+ * Returns the refusal to report, or null to run the step.
15
+ */
16
+ async function refuseStepIfBlocked(ctx, taskName, options) {
17
+ const session = ctx.session;
18
+ if (!session)
19
+ return null;
20
+ // The micro gateway arrives as one task carrying the real category and
21
+ // method in its options. Asking the allowlist about the wrapper refused
22
+ // respond_to_dialog, which is the single call that clears the dialog, so the
23
+ // gateway could never escape one.
24
+ const subject = taskName === "tools.call"
25
+ ? `${String(options.category ?? "")}.${String(options.method ?? "")}`
26
+ : taskName;
27
+ if (DialogGuard.actionAllowed(subject))
28
+ return null;
29
+ const guard = await ensureGuard(session);
30
+ const decision = await guard.check(subject, "action");
31
+ return decision.allow ? null : (decision.refusal ?? null);
32
+ }
33
+ /** The error a refused step reports, worded by the refusal itself. */
34
+ function refusalError(taskName, refusal) {
35
+ const said = typeof refusal.error === "string" ? refusal.error : undefined;
36
+ return new Error(said ?? `'${taskName}' was refused: a modal dialog is blocking the editor.`);
37
+ }
6
38
  /**
7
39
  * Create a TaskConstructor for a bridge-delegation action.
8
40
  * The bridge method (and optional param mapper) are closed over in the class,
@@ -84,6 +116,15 @@ export function handlerTaskClass(name, fn, prep) {
84
116
  const ctx = pipeline.timeoutMs === undefined
85
117
  ? this.ctx
86
118
  : { ...this.ctx, callTimeoutMs: pipeline.timeoutMs };
119
+ // A flow is a minutes-long run, and a modal can appear at any point in
120
+ // it. The tool route checks once, before flow.run starts; without this,
121
+ // a dialog raised at step 3 was missed by every later step, which
122
+ // includes the editor lifecycle handlers. Checking per step is what
123
+ // makes "any process, at any point" true of flows as well.
124
+ const refusal = await refuseStepIfBlocked(this.ctx, name, this.options);
125
+ if (refusal) {
126
+ return { success: false, data: refusal, error: refusalError(name, refusal) };
127
+ }
87
128
  const answered = await fn(ctx, pipeline.params);
88
129
  const data = finishCall(answered, pipeline);
89
130
  // Same defect as the bridge class had, from the same hardcoded `true`:
@@ -1 +1 @@
1
- {"version":3,"file":"task-factory.js","sourceRoot":"","sources":["../../src/flow/task-factory.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"task-factory.js","sourceRoot":"","sources":["../../src/flow/task-factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAwB,MAAM,qBAAqB,CAAC;AAEpF,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE9D;;;;;;;;GAQG;AACH,KAAK,UAAU,mBAAmB,CAChC,GAAgB,EAChB,QAAgB,EAChB,OAAgC;IAEhC,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IAC5B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,uEAAuE;IACvE,wEAAwE;IACxE,6EAA6E;IAC7E,kCAAkC;IAClC,MAAM,OAAO,GAAG,QAAQ,KAAK,YAAY;QACvC,CAAC,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE;QACrE,CAAC,CAAC,QAAQ,CAAC;IACb,IAAI,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IACpD,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACtD,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;AAC5D,CAAC;AAED,sEAAsE;AACtE,SAAS,YAAY,CAAC,QAAgB,EAAE,OAAgC;IACtE,MAAM,IAAI,GAAG,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3E,OAAO,IAAI,KAAK,CAAC,IAAI,IAAI,IAAI,QAAQ,uDAAuD,CAAC,CAAC;AAChG,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAC7B,IAAY,EACZ,MAAc,EACd,SAAmE,EACnE,SAAkB,EAClB,IAAsB;IAEtB,MAAM,iBAAkB,SAAQ,SAAS;QACvC,IAAI,QAAQ,KAAK,OAAO,IAAI,CAAC,CAAC,CAAC;QAC/B,KAAK,CAAC,OAAO;YACX,yEAAyE;YACzE,yEAAyE;YACzE,gCAAgC;YAChC,MAAM,OAAO,GAAG,iBAAiB,CAAC,IAAI,CAAC,OAAkC,CAAC,CAAC;YAC3E,yEAAyE;YACzE,uEAAuE;YACvE,mEAAmE;YACnE,iEAAiE;YACjE,MAAM,QAAQ,GAAG,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;YAC5C,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC;YACxE,qEAAqE;YACrE,oEAAoE;YACpE,wBAAwB;YACxB,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,SAAS,IAAI,SAAS,CAAC,CAAC;YACzF,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;YAC3C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;gBAC5C,OAAO,mBAAmB,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;YAC3F,CAAC;YACD,wEAAwE;YACxE,oEAAoE;YACpE,uDAAuD;YACvD,EAAE;YACF,uEAAuE;YACvE,mEAAmE;YACnE,yEAAyE;YACzE,yEAAyE;YACzE,8DAA8D;YAC9D,0DAA0D;YAC1D,MAAM,IAAI,GAAG,GAA8B,CAAC;YAC5C,MAAM,MAAM,GAAe,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;YACzD,uEAAuE;YACvE,yEAAyE;YACzE,4DAA4D;YAC5D,0EAA0E;YAC1E,wEAAwE;YACxE,gCAAgC;YAChC,MAAM,gBAAgB,GAAG,QAAQ,KAAK,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ;gBACxE,CAAC,CAAE,QAAoC,CAAC,QAAQ;gBAChD,CAAC,CAAC,SAAS,CAAC;YACd,MAAM,MAAM,GAAG,YAAY,CAAC,gBAAgB,IAAI,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/D,IAAI,MAAM;gBAAE,MAAM,CAAC,QAAQ,GAAG,MAAM,CAAC;YACrC,qEAAqE;YACrE,uEAAuE;YACvE,sEAAsE;YACtE,yEAAyE;YACzE,wCAAwC;YACxC,OAAO,mBAAmB,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;QACzD,CAAC;KACF;IACD,MAAM,CAAC,cAAc,CAAC,iBAAiB,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,cAAc,IAAI,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,iBAA+C,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAAY,EACZ,EAA2E,EAC3E,IAAsB;IAEtB,MAAM,kBAAmB,SAAQ,SAAS;QACxC,IAAI,QAAQ,KAAK,OAAO,IAAI,CAAC,CAAC,CAAC;QAC/B,KAAK,CAAC,OAAO;YACX,MAAM,QAAQ,GAAG,WAAW,CAAC,IAAI,CAAC,OAAkC,EAAE,IAAI,CAAC,CAAC;YAC5E,qEAAqE;YACrE,yEAAyE;YACzE,iEAAiE;YACjE,MAAM,GAAG,GAAG,QAAQ,CAAC,SAAS,KAAK,SAAS;gBAC1C,CAAC,CAAC,IAAI,CAAC,GAAG;gBACV,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,aAAa,EAAE,QAAQ,CAAC,SAAS,EAAE,CAAC;YACvD,uEAAuE;YACvE,wEAAwE;YACxE,kEAAkE;YAClE,oEAAoE;YACpE,2DAA2D;YAC3D,MAAM,OAAO,GAAG,MAAM,mBAAmB,CACvC,IAAI,CAAC,GAAG,EACR,IAAI,EACJ,IAAI,CAAC,OAAkC,CACxC,CAAC;YACF,IAAI,OAAO,EAAE,CAAC;gBACZ,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC;YAC/E,CAAC;YACD,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;YAChD,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;YAC5C,uEAAuE;YACvE,qEAAqE;YACrE,wEAAwE;YACxE,qEAAqE;YACrE,yEAAyE;YACzE,wDAAwD;YACxD,OAAO,mBAAmB,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE;gBAC7C,OAAO,EAAE,IAAI;gBACb,IAAI,EAAE,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI;oBAC7C,CAAC,CAAE,IAAgC;oBACnC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE;aACrB,CAAC,CAAC;QACL,CAAC;KACF;IACD,MAAM,CAAC,cAAc,CAAC,kBAAkB,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,IAAI,EAAE,EAAE,CAAC,CAAC;IACpF,OAAO,kBAAgD,CAAC;AAC1D,CAAC"}
@@ -25,6 +25,8 @@ import { discoverTaskGuards as discoverFlowkitGuards } from "@db-lyon/flowkit/gu
25
25
  import { writeScope } from "./guard.js";
26
26
  import { McpError, ErrorCode } from "../errors.js";
27
27
  import { debug, info } from "../log.js";
28
+ import { withoutDialogActuation } from "../dialog-guard.js";
29
+ import { DialogGatedBridge } from "./guarded-bridge.js";
28
30
  /** Adapts flowkit's logger to this server's `guard`-component logging. */
29
31
  const GUARD_LOGGER = {
30
32
  debug: (...args) => debug("guard", args.map(String).join(" ")),
@@ -57,8 +59,22 @@ export function discoverTaskGuards(registry, ctx, rawBridge) {
57
59
  // bridge happened to be built first. cc.bridge is the RAW bridge of the
58
60
  // session serving this call, so a guard can neither recurse through the
59
61
  // pipeline nor act on another project's editor.
62
+ // The raw bridge, gated. Raw is deliberate and stays: a guard task must
63
+ // not re-enter the flow pipeline that is running it. That is a statement
64
+ // about the registry, not about dialogs, and taking it to mean both left
65
+ // guard tasks as the one route that reached a parked game thread
66
+ // unrefused, where every call hung until it timed out. DialogGatedBridge
67
+ // puts the gate back with no pipeline attached, and
68
+ // withoutDialogActuation stays on top because set_dialog_policy is
69
+ // modal-safe in the plugin, so a guard task could otherwise arm a policy
70
+ // that answers the modal already on screen.
60
71
  contextFor: (cc) => cc.session
61
- ? { ...ctx, bridge: cc.bridge, project: cc.session.project, session: cc.session }
72
+ ? {
73
+ ...ctx,
74
+ bridge: withoutDialogActuation(cc.session, new DialogGatedBridge(cc.bridge, cc.session)),
75
+ project: cc.session.project,
76
+ session: cc.session,
77
+ }
62
78
  : { ...ctx, bridge: rawBridge },
63
79
  optionsFor: (cc, result) => ({
64
80
  method: cc.method,
@@ -1 +1 @@
1
- {"version":3,"file":"task-guards.js","sourceRoot":"","sources":["../../src/flow/task-guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,EAAE,kBAAkB,IAAI,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAMrF,OAAO,EAAE,UAAU,EAAgE,MAAM,YAAY,CAAC;AACtG,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAExC,0EAA0E;AAC1E,MAAM,YAAY,GAAW;IAC3B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9D,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC5D,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC5D,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC7D,KAAK,EAAE,GAAG,EAAE,CAAC,YAAY;CAC1B,CAAC;AAEF,mFAAmF;AACnF,MAAM,UAAU,uBAAuB,CAAC,OAAuB;IAC7D,OAAO,CAAC,WAAmB,EAAiB,EAAE;QAC5C,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,OAAO,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAC;YACpD,OAAO,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAChC,QAAsB,EACtB,GAAgB,EAChB,SAAkB;IAElB,OAAO,qBAAqB,CAAuB,QAAQ,EAAE;QAC3D,MAAM,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE;QAE7B,2EAA2E;QAC3E,wEAAwE;QACxE,wEAAwE;QACxE,gDAAgD;QAChD,UAAU,EAAE,CAAC,EAAE,EAAe,EAAE,CAC9B,EAAE,CAAC,OAAO;YACR,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC,OAAO,EAAE;YACjF,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE;QAEnC,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;YAC3B,MAAM,EAAE,EAAE,CAAC,MAAM;YACjB,MAAM,EAAE,EAAE,CAAC,MAAM;YACjB,KAAK,EAAE,EAAE,CAAC,UAAU,EAAE;YACtB,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5C,CAAC;QAEF,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC;YACpC,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5D,OAAO,IAAI,QAAQ,CACjB,SAAS,CAAC,aAAa,EACvB,YAAY,IAAI,CAAC,GAAG,CAAC,MAAM,IAAI,KAAK,KAAK,IAAI,CAAC,MAAM,EAAE,CACvD,CAAC;QACJ,CAAC;QAED,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAChB,IAAI,QAAQ,CACV,SAAS,CAAC,aAAa,EACvB,UAAU,IAAI,CAAC,KAAK,gBAAgB,IAAI,CAAC,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,CACtE;QAEH,gFAAgF;QAChF,cAAc,EAAE,CAAC,IAAI,EAAE,EAAE,CACvB,KAAK,CAAC,OAAO,EAAE,gBAAgB,IAAI,CAAC,KAAK,yBAAyB,IAAI,CAAC,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC;QAEtG,MAAM,EAAE,YAAY;KACrB,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"task-guards.js","sourceRoot":"","sources":["../../src/flow/task-guards.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { resolveContextStrategy, applyLeanContext, buildMicroGateway } from "./l
9
9
  import { routeEditorCall, effectiveTaskName, refuseUntargetedInRegistry, editorAttribution, } from "./editor-gate.js";
10
10
  import { injectEditorTarget, removeEditorTarget, injectMigrateTarget, removeMigrateTarget, stripEditorTarget, sessionContext, EDITOR_TARGET_PARAM, } from "./types.js";
11
11
  import { McpError, ErrorCode } from "./errors.js";
12
- import { gateForDialog, reshapeDialogRefusal, noteDialogBlocking, isDialogRefusal, } from "./dialog-gate.js";
12
+ import { DialogGuard, guardFor, existingGuard, isDialogRefusal, stampBlockedEditor, } from "./dialog-guard.js";
13
13
  import { resolveDialogMode, clientAdvertisesElicitation } from "./editor-control.js";
14
14
  import { info, warn, debug } from "./log.js";
15
15
  import { startVersionCheck, consumeUpgradeNotice } from "./version-check.js";
@@ -29,7 +29,8 @@ import { ALL_TOOLS, setLiveToolGraph } from "./tools.js";
29
29
  import { nearestActions } from "./action-schema.js";
30
30
  import { enrichToolsWithEpicCatalog } from "./epic-enrich.js";
31
31
  import { checkPluginFreshness } from "./plugin-freshness.js";
32
- import { saveCatalogCache, loadCatalogCache, loadBakedCatalog } from "./epic-cache.js";
32
+ import { readEngineSnapshot } from "./engine-observer.js";
33
+ import { saveCatalogCache, loadCatalogCache, loadBakedCatalog, catalogOrRefusal, } from "./epic-cache.js";
33
34
  import { baseGraphFor, unionSurface, unionKnowledge, explainMissingAction, } from "./session-surface.js";
34
35
  function withUpgradeNotice(content) {
35
36
  const notice = consumeUpgradeNotice();
@@ -162,6 +163,13 @@ async function main() {
162
163
  const surfaces = [];
163
164
  const perSession = new Map();
164
165
  for (const session of sessions.list()) {
166
+ // BEFORE the surface build, which calls epic_list_toolsets on the editor.
167
+ // Creating guards afterwards meant the first call this server ever makes
168
+ // was the one call with no guard behind it: a modal refused it, the
169
+ // refusal has no toolsets, and the surface silently fell back to a cached
170
+ // one with nothing latched. startWatching is idempotent, so the later
171
+ // pass over sessions.list() stays harmless.
172
+ dialogGuardFor(session);
165
173
  const load = await buildSessionLoad(session, pkg.version, sessions.size > 1);
166
174
  perSession.set(session, load);
167
175
  surfaces.push(load.surface);
@@ -311,6 +319,35 @@ async function main() {
311
319
  // registry starts empty (pass-through) and is populated once the task
312
320
  // registry exists, below.
313
321
  const guardedBridge = primary.guarded;
322
+ /**
323
+ * The dialog guard for one editor, created on first use and kept.
324
+ *
325
+ * Watching starts with the guard, so a modal raised while the session sits
326
+ * idle is known before anything is called: the plugin refreshes its status
327
+ * file from the modal-loop tick, which is the tick that keeps running while
328
+ * the game thread is parked.
329
+ */
330
+ function dialogGuardFor(forSession, canElicit = false) {
331
+ const guard = guardFor(forSession, {
332
+ mode: () => resolveDialogMode({ projectDir: forSession.projectDir, canElicit }).mode,
333
+ probe: () => forSession.guarded.call("list_dialogs", {}),
334
+ press: (buttonLabel) => forSession.guarded.call("respond_to_dialog", { buttonLabel }),
335
+ elicit: () => (canElicit ? ctx.elicit : undefined),
336
+ isConnected: () => forSession.bridge.isConnected,
337
+ // The instance-aware reader, not a second one: it prefers
338
+ // status.<pid>.json over the shared file two editors of one project take
339
+ // turns writing, and it reports how old the snapshot is so a leftover
340
+ // from a crashed editor is not mistaken for a live modal.
341
+ readSnapshot: () => {
342
+ const proj = forSession.project.projectPath
343
+ ?? forSession.bridge.getTarget().projectPath
344
+ ?? null;
345
+ return proj ? readEngineSnapshot(proj) : null;
346
+ },
347
+ });
348
+ guard.startWatching();
349
+ return guard;
350
+ }
314
351
  const getToolGraph = (forSession = primary) => {
315
352
  const load = perSession.get(forSession);
316
353
  // D1 again. A session whose surface failed to build has no graph of its
@@ -378,6 +415,11 @@ async function main() {
378
415
  };
379
416
  for (const load of loads)
380
417
  discoverGuardsFor(load);
418
+ // A guard per registered editor, so a modal in the DESTINATION of a
419
+ // cross-editor call is seen by that editor's own guard rather than by
420
+ // whichever session happened to originate the call.
421
+ for (const s of sessions.list())
422
+ dialogGuardFor(s);
381
423
  /**
382
424
  * The dispatch surface for one session, built on demand (D1).
383
425
  *
@@ -421,6 +463,10 @@ async function main() {
421
463
  // its own surface exists.
422
464
  sessions.prepareSession = async (session) => {
423
465
  await ensureSessionLoad(session);
466
+ // Its own guard and its own watcher, before it is addressable. Without
467
+ // this an editor added at runtime had nothing watching it, so a modal
468
+ // there was never detected at all until a call happened to be routed in.
469
+ dialogGuardFor(session);
424
470
  };
425
471
  for (const session of sessions.list()) {
426
472
  if (session.guards.size === 0)
@@ -549,24 +595,17 @@ async function main() {
549
595
  isError: true,
550
596
  };
551
597
  }
552
- // THE DIALOG GATE, server half. The plugin refuses anything that needs
553
- // the editor while a modal is up; this covers what never reaches it, so
554
- // there is no action anywhere that quietly works around a stuck editor.
555
- //
556
- // The dialog handling mode is resolved HERE, per call, so it governs the
557
- // whole surface rather than the two lifecycle actions that used to be
558
- // the only things that consulted it.
598
+ // One guard per editor, shared by every route. The mode is read per
599
+ // call, so changing it takes effect without restarting anything.
559
600
  const canElicit = clientAdvertisesElicitation(ctx.elicit);
560
- const gated = await gateForDialog(session, taskName, {
561
- mode: resolveDialogMode({ projectDir: session.projectDir, canElicit }).mode,
562
- probe: () => session.guarded.call("list_dialogs", {}),
563
- press: (buttonLabel) => session.guarded.call("respond_to_dialog", { buttonLabel }),
564
- elicit: canElicit ? ctx.elicit : undefined,
565
- });
566
- if (gated) {
601
+ const guard = dialogGuardFor(session, canElicit);
602
+ // Actions served in this process never reach the bridge, so the bridge
603
+ // boundary cannot refuse them. Same guard, same decision.
604
+ const preflight = await guard.check(effectiveTaskName(tool, params), "action");
605
+ if (!preflight.allow) {
567
606
  return {
568
607
  content: withUpgradeNotice([
569
- { type: "text", text: JSON.stringify(gated, null, 2) },
608
+ { type: "text", text: JSON.stringify(preflight.refusal, null, 2) },
570
609
  ]),
571
610
  isError: true,
572
611
  };
@@ -648,10 +687,27 @@ async function main() {
648
687
  }
649
688
  try {
650
689
  const task = await sessionRegistry.create(taskName, flowCtx, taskParams);
651
- // Locks are acquired in the editor the call runs in, so they must be
652
- // taken on that session's bridge rather than the process default.
653
- const result = await withAssetLocks(session.bridge, lockingCfg, taskName, taskParams, () => task.run(), session.lockOwnerId);
654
- if (!result.success) {
690
+ // Locks are acquired in the editor the call runs in, and through the
691
+ // GUARDED bridge, so a lock request made while a modal is up is
692
+ // refused as a dialog rather than reported as somebody else holding
693
+ // the asset.
694
+ const result = await withAssetLocks(session.guarded, lockingCfg, taskName, taskParams, () => task.run(), session.lockOwnerId);
695
+ // A handler that makes several bridge calls can swallow a refusal and
696
+ // still report success, so ask the guard what it learned rather than
697
+ // trusting the shape. Nothing is re-run: the call already applied
698
+ // whatever it applied, and replaying it would double-apply.
699
+ // A task that failed for its own reasons reports that, not a dialog.
700
+ //
701
+ // But a failure DURING a modal is usually caused by it: the game thread
702
+ // is parked, so the call times out and the error says the editor was
703
+ // busy and suggests a bigger timeoutMs, which is the retry loop this
704
+ // gate exists to end. The task runner returns no data on a throw, so
705
+ // the refusal cannot be recognised from the result: ask the guard.
706
+ const failedUnderDialog = !result.success
707
+ && !isDialogRefusal(result.data)
708
+ && !DialogGuard.actionAllowed(effectiveTaskName(tool, params))
709
+ && (await guard.check(effectiveTaskName(tool, params), "action")).allow === false;
710
+ if (!result.success && !isDialogRefusal(result.data) && !failedUnderDialog) {
655
711
  const msg = result.error?.message ?? `Task ${taskName} failed`;
656
712
  return {
657
713
  content: withUpgradeNotice([
@@ -662,56 +718,71 @@ async function main() {
662
718
  isError: true,
663
719
  };
664
720
  }
665
- // The plugin's own gate refuses anything needing the editor, and it
666
- // does not know this machine's dialog mode. Reshaping its refusal here
667
- // is what stops the mode applying to some calls and not others: the
668
- // first blocked call would otherwise get the plugin's wording and the
669
- // next one, caught by the latch, would get the mode's.
670
- if (isDialogRefusal(result.data)) {
671
- const info = {
672
- dialogTitle: String(result.data.dialogTitle ?? ""),
673
- dialogMessage: String(result.data.dialogMessage ?? ""),
674
- buttons: Array.isArray(result.data.buttons) ? result.data.buttons : [],
675
- choices: Array.isArray(result.data.choices)
676
- ? result.data.choices
677
- : [],
678
- };
679
- noteDialogBlocking(session, info);
680
- const reshaped = await reshapeDialogRefusal(session, taskName, info, {
681
- mode: resolveDialogMode({ projectDir: session.projectDir, canElicit }).mode,
682
- probe: () => session.guarded.call("list_dialogs", {}),
683
- press: (buttonLabel) => session.guarded.call("respond_to_dialog", { buttonLabel }),
684
- elicit: canElicit ? ctx.elicit : undefined,
685
- });
686
- if (reshaped) {
721
+ // An allow-listed read still SAYS a dialog is up. get_status is the
722
+ // first call every client makes, and reporting a healthy editor while
723
+ // the game thread is parked is the one answer it must never give.
724
+ if (DialogGuard.actionAllowed(effectiveTaskName(tool, params))) {
725
+ // respond_to_dialog may have just cleared it. Nothing re-probes for
726
+ // an allow-listed bridge method (guardCall returns before check, and
727
+ // observe refuses to clear on a modal-safe reply), so this call came
728
+ // back stamped as blocked and told the caller to make the call it had
729
+ // just made.
730
+ if (effectiveTaskName(tool, params) === "editor.respond_to_dialog" && result.success) {
731
+ // refresh, NOT check. check applies the mode, so in interactive it
732
+ // raised a form for whatever prompt this answer surfaced and
733
+ // pressed a button on it, unasked, and then threw the decision
734
+ // away. Only the state needs correcting here.
735
+ await guard.refresh();
736
+ }
737
+ const seen = guard.current;
738
+ if (result.success)
739
+ stampBlockedEditor(result.data, seen);
740
+ }
741
+ // Whatever the route, the caller gets ONE refusal shape, built here.
742
+ //
743
+ // A modal appearing between the preflight and the inner call means the
744
+ // plugin refuses, and its payload lands in result.data. Passing that
745
+ // through handed the caller a second shape with no dialogMode and, in
746
+ // defer, the very press calls defer exists to withhold.
747
+ //
748
+ // Actions allowed through a modal are exempt: reading the dialog and
749
+ // answering it must not come back refused because of the dialog they
750
+ // are about.
751
+ if (!DialogGuard.actionAllowed(effectiveTaskName(tool, params))) {
752
+ const fromPlugin = isDialogRefusal(result.data)
753
+ ? result.data
754
+ : null;
755
+ if (fromPlugin)
756
+ guard.observe("__refused__", fromPlugin);
757
+ const blocking = guard.current;
758
+ if (blocking) {
759
+ // A refusal means nothing ran. Anything else means the call had
760
+ // already started when the dialog appeared, so it may have applied
761
+ // part of its work; say so rather than implying it did nothing.
762
+ const started = fromPlugin === null;
687
763
  return {
688
764
  content: withUpgradeNotice([
689
- { type: "text", text: JSON.stringify(reshaped, null, 2) },
765
+ {
766
+ type: "text",
767
+ text: JSON.stringify(started
768
+ ? {
769
+ ...guard.refusal(effectiveTaskName(tool, params), blocking),
770
+ partiallyApplied: true,
771
+ note: `'${taskName}' had already started when the dialog appeared, so it may `
772
+ + "have applied some of its changes. Answer the dialog, then read the "
773
+ + "state back before deciding whether to run it again.",
774
+ // Whatever it did manage to return, kept rather than
775
+ // dropped: a mutation that completed still has the
776
+ // path it created in here.
777
+ partialResult: result.data ?? null,
778
+ }
779
+ : guard.refusal(effectiveTaskName(tool, params), blocking), null, 2),
780
+ },
690
781
  ...attribution(session),
691
782
  ]),
692
783
  isError: true,
693
784
  };
694
785
  }
695
- // Answered through the elicitation form, so the dialog is gone and
696
- // the caller is told to make the call again rather than being handed
697
- // a refusal that is no longer true.
698
- return {
699
- content: withUpgradeNotice([
700
- {
701
- type: "text",
702
- text: JSON.stringify({
703
- success: false,
704
- dialogAnswered: true,
705
- dialogTitle: info.dialogTitle,
706
- retry: taskName,
707
- note: "The blocking dialog was answered by the user, so the editor is running "
708
- + `again. '${taskName}' did not run; call it again.`,
709
- }, null, 2),
710
- },
711
- ...attribution(session),
712
- ]),
713
- isError: true,
714
- };
715
786
  }
716
787
  const stringify = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
717
788
  // Preserve directive responses (execute_python workaround tracking).
@@ -740,6 +811,26 @@ async function main() {
740
811
  };
741
812
  }
742
813
  catch (e) {
814
+ // A refusal can arrive as a throw: acquiring an asset lock is a bridge
815
+ // call, so it is refused like any other, and locking reports failure by
816
+ // throwing. Shaped through the guard so a caller gets the same payload
817
+ // whether the refusal came back as a result or as an exception.
818
+ const thrownRefusal = e instanceof McpError && isDialogRefusal(e.details)
819
+ ? e.details
820
+ : null;
821
+ if (thrownRefusal) {
822
+ guard.observe("__refused__", thrownRefusal);
823
+ const blocking = guard.current;
824
+ if (blocking) {
825
+ return {
826
+ content: withUpgradeNotice([
827
+ { type: "text", text: JSON.stringify(guard.refusal(effectiveTaskName(tool, params), blocking), null, 2) },
828
+ ...attribution(session),
829
+ ]),
830
+ isError: true,
831
+ };
832
+ }
833
+ }
743
834
  const msg = e instanceof Error ? e.message : String(e);
744
835
  const code = e instanceof McpError ? e.code : "UNKNOWN";
745
836
  return {
@@ -820,6 +911,21 @@ async function main() {
820
911
  isError: true,
821
912
  };
822
913
  }
914
+ // The flow tool reaches the editor through the same guarded bridge, so
915
+ // its steps are refused individually. This covers the run being STARTED
916
+ // while a modal is already up, and flow(list)/flow(plan), which are
917
+ // in-process and never touch the bridge at all.
918
+ const flowGuard = dialogGuardFor(session, clientAdvertisesElicitation(ctx.elicit));
919
+ const flowCheck = await flowGuard.check(`${flowTool.name}.${String(params.action ?? "")}`, "action");
920
+ if (!flowCheck.allow) {
921
+ return {
922
+ content: withUpgradeNotice([
923
+ { type: "text", text: JSON.stringify(flowCheck.refusal, null, 2) },
924
+ ...attribution(session),
925
+ ]),
926
+ isError: true,
927
+ };
928
+ }
823
929
  const result = await flowTool.handler(sessionContext(ctx, session), params);
824
930
  const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
825
931
  return { content: withUpgradeNotice([{ type: "text", text }, ...attribution(session)]) };
@@ -913,12 +1019,29 @@ async function buildSessionLoad(session, packageVersion, multi) {
913
1019
  // its own.
914
1020
  let catalog = null;
915
1021
  let source = "";
916
- const bridge = session.bridge;
917
- if (!bridge.isConnected) {
918
- await bridge.connect(2000).catch(() => { });
1022
+ // The guarded bridge, not the raw one. This is the first call the server
1023
+ // makes against an editor, so sending it raw meant a modal was met by
1024
+ // the one route with no gate on it: the refusal came back, the surface
1025
+ // fell back to a cache, and nothing was latched for the calls that
1026
+ // followed.
1027
+ const bridge = session.guarded;
1028
+ if (!session.bridge.isConnected) {
1029
+ await session.bridge.connect(2000).catch(() => { });
919
1030
  }
920
- if (bridge.isConnected) {
1031
+ if (session.bridge.isConnected) {
921
1032
  catalog = (await bridge.call("epic_list_toolsets", { includeSchemas: true }, 20000));
1033
+ // A modal refuses this like anything else, and the refusal has no
1034
+ // toolsets, so it read as "this editor advertises none" and the surface
1035
+ // silently fell back to a cache. Say what actually happened, and let
1036
+ // the guard learn from it rather than swallowing the evidence.
1037
+ const read = catalogOrRefusal(catalog);
1038
+ if (read.refusedByDialog) {
1039
+ existingGuard(session)?.observe("epic_list_toolsets", catalog);
1040
+ warn("epic", `${session.name}: the Epic toolset list was refused because a modal dialog is blocking `
1041
+ + "the editor, so its tools are being taken from the cache. Answer the dialog and "
1042
+ + "re-register the editor to pick up the live set.");
1043
+ }
1044
+ catalog = read.catalog;
922
1045
  if (catalog?.toolsets?.length) {
923
1046
  saveCatalogCache(configDir, catalog, project.engineAssociation);
924
1047
  source = "live editor";