/** * The dialog guard. One per editor, one implementation, one decision. * * A modal dialog parks Unreal's game thread. Nothing may run while one is up, * whatever raised it and whichever route the caller came in by, and the caller * has to be forced to deal with it rather than being left to notice. * * Everything that can reach an editor delegates here: * * GuardedBridge.call every bridge request, so tool actions, flow steps, * nested flow runs and handler-internal calls are all * covered at the one boundary they share * tool dispatch actions served in this process, which never reach the * bridge and so cannot be refused by it * the HTTP routes the same, through the same object * * There is no second copy of this logic. A call site decides nothing; it asks * `check` and does what it says. * * DETECTION is proactive. The plugin publishes the active modal to its status * file from the modal-loop tick, which is the one tick that keeps running while * the game thread is parked, so a watcher there knows a dialog appeared even if * nothing is being called and the session is completely idle. The plugin's own * refusal and an on-demand probe both feed the same state, so a dialog is * caught by whichever notices first. */ import type { EditorSession } from "./session.js"; import type { IBridge } from "./bridge.js"; import type { ElicitFn } from "./types.js"; import type { DialogMode } from "./user-state.js"; /** The dialog, as every layer describes it. */ /** One tickable row of a dialog that asks a question per item. */ export interface DialogItem { index: number; /** The row's first cell, which is the asset name on a save prompt. */ label: string; /** Every cell in Slate order: name, package path, class path. */ cells: string[]; checked: boolean; } export interface BlockingDialog { title: string; message: string; buttons: string[]; choices: Array<{ buttonLabel: string; respondWith: string; }>; /** * The rows this dialog lets a person tick, when it has any. * * Save Content is N questions, not one: a checkbox per unsaved package, and * "Save Selected" saves whatever is ticked. Without these the only honest * offer was all or nothing. */ items?: DialogItem[]; } export declare function isModalSafeMethod(method: string): boolean; /** * A dialog's message flattened onto one line, for a client that shows one. * * Unreal's own prompts are laid out over many lines (the shutdown Save Content * prompt lists a package per line), and a renderer that keeps two of them shows * two package names and hides the question. Flattened, the same budget carries * the question itself. */ export declare function oneLine(text: string, limit?: number): string; /** * A blocking dialog, rendered for a person to read. * * Unreal flattens a Slate dialog into lines, so a save prompt arrives as its * prompt, then the column headers, then one line per cell, then the button * labels. Printed raw that is an unreadable run of paths. This pulls the asset * rows back out and lays them in a table, and passes anything it does not * recognise through untouched. */ export declare function renderDialog(dialog: BlockingDialog): string; /** True for the refusal the plugin's own gate emits. */ export declare function isDialogRefusal(v: unknown): boolean; /** A button press, and whether the editor confirmed it landed. */ export interface DialogPress { button: string; confirmed: boolean; } /** * Where interactive has got to with one dialog. * * relay the whole dialog has just been handed back, nothing asked yet * asking the caller has had it, so the form is up or has been * * Reported as `dialogPhase` so a caller can tell "you have not seen this yet" * from "the person is looking at it", which are otherwise the same refusal. */ export type DialogPhase = "relay" | "asking"; /** What a caller must do about this call. */ export type GuardDecision = { allow: true; } | { allow: false; refusal: Record; }; export interface GuardDeps { /** How this machine wants a blocking dialog handled. */ mode: () => DialogMode; /** Reads the live dialog list. Modal-safe, so it answers while parked. */ probe: () => Promise; /** * Presses one button by label, applying any per-item ticks first. * * One call, because a modal is exactly where a caller cannot be relied on * to come back: setting the ticks and pressing the button either both * happen or neither does. */ press: (buttonLabel: string, items?: Array<{ index: number; checked: boolean; }>) => Promise; /** Present only when the connected client advertised elicitation. */ elicit?: () => ElicitFn | undefined; /** * The editor's published status snapshot, polled so a modal raised while * nothing is running is still noticed. * * Supplied rather than read here, so this is not a second reader of the * same file: the caller passes the instance-aware one, which prefers * `status..json` over the shared file two editors of one project * take turns writing, and which reports how old the snapshot is. */ readSnapshot?: () => { modal?: unknown; ageSeconds?: number; } | null; /** Whether the bridge socket is currently up. */ isConnected?: () => boolean; } /** * How old a status file may be and still describe a live editor. The plugin * flushes it on a timer while running, so anything older is a leftover. */ export declare const STATUS_STALE_AFTER_MS = 15000; export declare class DialogGuard { private deps; private blocking; /** True when the only evidence of a dialog is a file nobody is updating. */ private staleStatus; /** What the last interactive answer did, for callers that report it. */ private pressed; /** Identity of the dialog the last press was aimed at. */ private lastAsked; /** * Every dialog whose full text has already gone back to a caller. * * A SET, not the one slot `lastAsked` uses, because two dialogs can be in * play at once (parallel callers, or a prompt raised behind another) and one * slot thrashes between them: telling the caller about the second forgets the * first, so the first is relayed a second time and its form never goes up. * Bounded by the same clean screen that resets everything else. */ private told; /** The ask in flight, so parallel calls share it instead of each asking. */ private asking; /** Which dialog that in-flight ask is about. */ private askingFor; private poll; constructor(deps: GuardDeps); /** Replace the dependencies without losing what the guard knows. */ setDeps(deps: GuardDeps): void; /** What the last interactive answer did, or null if nothing was pressed. */ get lastPressed(): DialogPress | null; /** The dialog currently believed to be blocking, if any. */ get current(): BlockingDialog | null; /** How this machine wants a blocking dialog handled, before elicitation. */ get mode(): DialogMode; /** Whether there is anybody to put an elicitation form in front of. */ get canElicit(): boolean; /** Record a dialog. Called by the watcher, a probe, or a plugin refusal. */ note(dialog: BlockingDialog): void; /** * Record that the editor is clear. * * Only ever called with positive evidence: a probe that ANSWERED and listed * nothing, or a non-modal-safe bridge call that ran. A failed probe is not * evidence and must never land here, or a dropped socket would disarm the * guard while the dialog is still on screen. */ clear(): void; /** * Forget what has been said about a dialog, so the next one starts over. * * Both records are per-dialog and both are reset together: told-but-not-asked * is a real state (the caller has the text and has not retried yet), and * clearing one without the other either asks about a dialog nobody has read * or re-reads one already answered. * * Only ever called where the screen is PROVEN clear, alongside `clear`. */ private forgetDialogRecords; /** * Whether the AGENT gets to answer the dialog in this mode. * * The one place that rule lives, and it settles two things that used to be * settled separately: whether a refusal advertises the calls that press the * buttons, and whether a press arriving as a tool call is accepted. They are * the same question. Advertising without enforcing is how an agent came to * answer a modal under interactive, which is the one outcome that mode * exists to prevent. * * `auto` is the only mode whose contract hands the choice to the agent. * interactive puts the question to the person over an elicitation form and * presses only what they pick; defer waits for them at the editor's own * window. In neither is a button the agent chose an acceptable answer, so * neither names the press calls and neither accepts one. * * canElicit is still read, because interactive with nobody to ask is defer * (see effectiveMode) rather than a quiet promotion to auto. */ static handsOverPressCalls(mode: DialogMode, canElicit?: boolean): boolean; /** * The mode as it actually applies, given whether anyone can be asked. * * interactive with no channel to a person is not interactive: it would * report "interactive" and hand the buttons to the agent anyway, which is * auto's behaviour under a mode whose contract is that a PERSON chooses. * The mode resolver already falls back to defer when the client advertises * no elicitation; this applies the same rule everywhere else. */ static effectiveMode(mode: DialogMode, canElicit: boolean): DialogMode; /** True when this bridge method may be sent while a modal is up. */ static bridgeAllowed(method: string): boolean; /** True when this tool action may run while a modal is up. */ static actionAllowed(taskName: string): boolean; /** * The single decision. * * `subject` is the bridge method or the `tool.action` being attempted, and is * only used to name it back to the caller and to check the allow list. */ check(subject: string, kind: "bridge" | "action", opts?: { canElicit?: boolean; }): Promise; /** * Whether this call has a person on it who can be shown a form. * * Both halves matter: the route has to carry somebody (an HTTP request does * not) and the client has to have advertised elicitation. Computed once here * so the gate and the refusal cannot answer it differently. */ private canAsk; /** * Whether this client's rendering makes the handover worth a call. * * Asked of the live elicit function rather than stored, because the client is * only knowable once one has connected and this guard outlives connections. */ /** * Whether the text has to go back before a form can carry it. * * Two conditions, not one. A client that collapses a long elicitation is * only a problem when there IS a long elicitation: a save prompt renders as * a line and a short table, which every client shows in full, and relaying * that costs a round trip AND leaves raising the form to the agent, which is * not something to depend on. So the form goes up on the first call unless * the rendered block is genuinely too big for one. */ private needsRelay; /** * The decision about a dialog the caller ALREADY has, with no probe. * * `check` is this plus finding the dialog first. A caller that just read it * (stop_editor, which must read before it sends a quit) uses this, so the * mode is applied in exactly one place without paying for a second read or * risking a re-probe that answers differently. */ decideFor(subject: string, dialog: BlockingDialog, opts?: { canElicit?: boolean; }): Promise; /** * Re-read what is on screen, deciding nothing. * * `check` applies the mode, which in interactive raises a form and presses a * button. A caller that has just answered a dialog by hand and only wants * the state corrected must not do that: it would put a form up for whatever * prompt the first answer surfaced and press a button on it, unasked. */ refresh(): Promise; /** * What is on screen right now. * * The watcher usually knows already. When it does not, ask the editor. A * probe that THROWS is not an answer: the state is left exactly as it was, * so an unreachable editor cannot disarm the guard. */ private currentDialog; /** Put it to the person; return the button they chose, or null. */ private askUser; /** The one refusal shape, whatever the route and whatever the mode. */ refusal(subject: string, dialog: BlockingDialog, opts?: { canElicit?: boolean; }, phase?: DialogPhase): Record; /** * The refusal shape. ONE definition, so every route hands a caller the same * fields to branch on. * * Static because stop_editor builds its refusal without an instance: it runs * over its own transport while a quit may be in flight. It used to assemble * its own object, which carried `dialogBlocking` but no `refusedMethod`, * `dialogTitle`, `dialogMessage` or `error`, so a client reading those got * undefined depending on which route refused it. */ static describeRefusal(subject: string, dialog: BlockingDialog, resolvedMode: DialogMode, canElicit?: boolean, phase?: DialogPhase): Record; /** * Learn from a bridge reply. * * A refusal names the dialog. Anything else is evidence the game thread ran, * but ONLY for a method the plugin would have refused: a modal-safe method * answers either way, so reading the dialog list must not be mistaken for the * dialog having gone. */ observe(method: string, result: unknown): void; /** * Watch the editor's status file so a dialog raised while nothing is running * is known immediately, rather than at the next call. * * The plugin refreshes that file from the modal-loop tick, which keeps firing * while the game thread is parked. Watching costs nothing on the hot path and * needs no bridge traffic; the poll is a fallback for platforms where the * watch does not fire. */ startWatching(intervalMs?: number): void; stopWatching(): void; } /** * A raw bridge that still cannot press a dialog button. * * Plugin guard tasks run on the RAW bridge on purpose: routing them through * GuardedBridge would re-enter the guard pipeline that is running them. That * left one real hole, because `set_dialog_policy` is modal-safe in the plugin * and WILL be served: a guard task could arm a policy that answers the modal * already on screen, with no person involved, under a mode that promises * exactly the opposite. * * Applied to the SESSION's bridge, so it holds on every path: the guarded * bridge wraps it, guard tasks get it, and a handler reaching for * `ctx.session.bridge` directly gets it too. Wrapping only one caller left the * escape one line away. * * This refuses those two methods and nothing else, from state already held, so * it adds no round-trip and cannot recurse. */ export declare function withoutDialogActuation(session: EditorSession, raw: T): T; /** * Mark a successful result as coming from an editor that is blocked. * * An allowed read still SAYS a dialog is up: get_status is the first call every * client makes, and reporting a healthy editor while the game thread is parked * is the one answer it must never give. * * `editorBlockedByDialog` deliberately is NOT `dialogBlocking`, which means * "this call was refused". Stamping that here would have a client treat a * successful read as a refusal. * * Returns the value unchanged when there is nothing to say, or when the shape * cannot carry the fields: an array is `typeof "object"`, so it took the * properties and then lost them silently in JSON.stringify. */ export declare function stampBlockedEditor(data: unknown, dialog: BlockingDialog | null, mode?: DialogMode): unknown; /** * The guard for a session, created from the session itself if it has none. * * The gate fails closed: no guard means the boundary cannot establish whether * a modal is up, so it refuses. That is only safe if every session HAS one, * and guards were created in one startup pass, so a session registered any * other way had none and was refused everything it ever tried. * * Every dependency here is derivable from the session, so there is no reason * for that gap to exist. What startup adds on top is the client's elicitation * capability, which is not knowable here; guardFor replaces the deps, so that * pass upgrades this guard rather than competing with it. */ export declare function ensureGuard(session: EditorSession): Promise; export declare function guardFor(session: EditorSession, deps: GuardDeps): DialogGuard; export declare function existingGuard(session: EditorSession): DialogGuard | undefined; /** Test seam. */ export declare function forgetGuard(session: EditorSession): void;