import { type ProjectContext } from "./project.js"; import { type EngineState } from "./engine-observer.js"; import { type DialogMode } from "./user-state.js"; import type { ElicitFn, ProgressFn } from "./types.js"; /** * Is anything answering on this port? Exported so a session can be reported as * reachable or not without opening a bridge connection to find out (#817). */ export declare function isBridgeReachable(port: number, host?: string, timeoutMs?: number): Promise; export interface ReadyPhase { phase: string; atSeconds: number; detail?: string; } export interface ReadyResult { ready: boolean; elapsedSeconds: number; /** Phase transitions with the second each happened, oldest first. */ timeline: ReadyPhase[]; reason?: string; state?: EngineState; } /** * Block until the editor is genuinely usable, rendering progress to the * terminal while it happens. * * "The bridge socket answers" is not the same as "the editor is ready": the * socket comes up mid-startup, while shaders compile and the map loads. A tool * that returned there left the caller polling in a loop, burning tokens to * rediscover state the plugin already publishes four times a second. So this * waits for the snapshot to say `ready` and reports the whole startup as a * progress bar rather than handing control back early. */ export declare function waitForEditorReady(projectPath: string | null | undefined, projectDir: string | undefined, maxWaitSeconds: number, opts?: { showProgress?: boolean; onProgress?: ProgressFn; launchedAtMs?: number; }): Promise; /** * Decide the next progress update, or null when there is nothing new to send. * * Pure and exported so the monotonicity rule is testable without an editor. * The rule matters: the MCP spec requires `progress` to increase, and clients * that draw a bar (or drop out-of-order updates) rely on it. Elapsed seconds * against the timeout is the only value that holds for the whole wait; the * engine's own slow-task percentage swings up and down as tasks come and go, * so it belongs in the message where it is free to do that. */ export declare function nextProgressUpdate(input: { elapsedSeconds: number; maxWaitSeconds: number; lastReportedProgress: number; label: string; detail: string; slowTaskFraction?: number; }): { progress: number; total: number; message: string; } | null; /** * What a launch returns. * * The verdict answers one question: did this call start an editor. An editor * that was already up means it did not, so `success` is false. That is the * honest report, and it stays the honest report even when a caller would * rather hear yes: a step that wanted an editor running got one, but the call * it made did nothing, and a handler that says otherwise is lying about what * happened to make somebody else's control flow easier. * * `alreadyRunning` is what makes the failure readable. A caller has to tell * "there was nothing to do" apart from "the launch broke", and the marker is * that distinction as a field rather than as prose to be parsed. A flow whose * step expects this failure absorbs it with `ignore_failure: true`, which * records the step as failed and walks on; see docs/flows.md. * * `bridgeReady` distinguishes the two ways an editor can already be up: its * bridge is answering (the port is carried in `port`), or its process is alive * but has not started listening yet. A caller that needs the bridge learns * which from the flag rather than from the sentence. */ export interface StartEditorResult { success: boolean; message: string; state?: EngineState; timeline?: ReadyPhase[]; elapsedSeconds?: number; /** Set when an editor for this project was already running, so none was spawned. */ alreadyRunning?: boolean; /** With `alreadyRunning`: whether that editor's bridge is answering yet. */ bridgeReady?: boolean; /** The port the already-running editor published, when it is answering there. */ port?: number; } export declare function startEditor(project: ProjectContext, timeoutSeconds?: number, onProgress?: ProgressFn, opts?: { /** * #968: `pattern=response;...`, handed to the editor as * UE_MCP_DIALOG_POLICY so the plugin can answer a prompt raised during * startup. The bridge is not listening yet at that point, so a policy set * over the socket afterwards is always too late for the modal that stalled * the launch in the first place. */ dialogPolicy?: string; /** * Arm the bridge's parameter echo for this editor. The live tests' leak * assertions, which prove a routing key never reaches an editor, can only * run when the editor was LAUNCHED with it: it is read at startup, so * turning it on over the socket afterwards is too late, exactly like the * dialog policy above. Without it those cases skip and say why, which * leaves the sharpest part of the suite unexercised by default. */ paramEcho?: boolean; }): Promise; /** * A modal dialog blocking the editor, read straight out of the engine. * * `list_dialogs` is modal-safe, so it answers while the game thread is parked * inside the modal loop, which is exactly when every other handler times out. * The message is carried WHOLE: this is the text a person reads to decide, and * a question cut off mid-sentence is a question answered on incomplete * information. */ export interface BlockingDialog { title: string; /** The complete message text, never truncated. */ message: string; /** Every button label, in the order the dialog lays them out. */ buttons: string[]; /** * Each button paired with the exact call that presses it. * * `respondWith` is the ACTUATION half and it is omitted in defer mode, where * the report exists so a person can recognise the window in front of them * and answer it there. Handing the call back in defer left the mode differing * from auto in prose alone, and an agent optimising for completion uses what * it is given. */ choices: { buttonLabel: string; respondWith?: string; }[]; } /** * The dialog handling mode that applied, and why it applied. * * The "why" is reported because the mode is resolved from three places and a * caller who sees a dialog handled differently than they expected needs to know * which one decided it, without reading the server's source. */ export interface ResolvedDialogMode { mode: DialogMode; /** Where the mode came from, in the reader's terms. */ source: string; } /** * Whether the user behind this call can actually be shown a form. * * NOT "was an elicit function handed over". The shipped server builds that * function at startup, before a client has connected, so it is always present * and testing it for undefined answers a different question: it says the server * has a gate, not that the client has a UI. Reading it that way put every * client that advertised nothing into the interactive path and reported the * reason as "the client advertised elicitation", which was false. * * A gate built outside the server (tests, embedders) carries no probe and is * taken at face value: it was handed over deliberately. */ export declare function clientAdvertisesElicitation(elicit?: ElicitFn): boolean; /** * Resolve the dialog handling mode. Precedence (highest wins), the same shape * the feedback approval mode uses: * * 1. UE_MCP_DIALOG_MODE env var - per-process override * 2. ~/.ue-mcp/state.json, this project - per-project, `dialog.mode` * 3. ~/.ue-mcp/state.json preference - per-user-per-device, `dialog.mode` * 4. default: "interactive" when the connected client advertised MCP * elicitation, otherwise "defer" * * THE DEFAULT NEVER RESOLVES TO "auto". A dialog is a question for a person, * and with no channel to that person the safe answer is to suspend and say so, * never to let something decide it because asking was inconvenient. "auto" is * reachable only by being named, in the env var or in the stored preference. * * An unrecognised env value is ignored rather than guessed at, and the fact * that it was ignored travels in `source` so a typo does not silently change * how dialogs are handled. * * Mode is NOT read from ue-mcp.yml, for the reason the feedback mode is not: * whether a person is at the keyboard to answer a modal is a property of the * machine and the session, not project policy a collaborator should inherit. */ export declare function resolveDialogMode(opts: { projectDir?: string | null; /** Whether the connected MCP client advertised the elicitation capability. */ canElicit: boolean; env?: NodeJS.ProcessEnv; }): ResolvedDialogMode; /** * What happened to the button the user picked. * * Three outcomes, not two. `confirmed` separates "the editor acknowledged the * press" from "the frame went out and nothing came back", which is what an 8s * timeout or a socket error on callBridgeOnce leaves behind - AFTER the press * has been sent. Collapsing the second into "nothing happened" is how a report * came to say the editor was untouched after the user chose Save All and * packages were written. */ export interface DialogPress { /** The label the user picked, which is the label that was sent. */ button: string; /** True when the editor answered that it pressed it. */ confirmed: boolean; } /** * Which editor belongs to the loaded project, decided once so that every * lifecycle action agrees about it. * * #967/#970: stop_editor and request_editor_shutdown act on the same editor * through different code paths, and only one of them checked ownership. The * check it used compared a lockfile pid against a process, then printed the * process's whole command line as evidence of a project mismatch - which, when * the command line parse was broken, was the loaded project's own .uproject. * Both actions now ask this one function, so they can no longer disagree. */ export type EditorOwnership = { owned: true; port: number; pid: number | null; /** Where the address came from, for a caller that wants to say so. */ source: string; /** Set when the shared lockfile was stale and a live editor was found instead. */ healed?: string; } | { owned: false; message: string; state?: EngineState; /** * Set when the reason there is no owned editor is that no editor for * this project is running at all. It is a label on the refusal, never a * softening of it: a stop that reaches this branch closed nothing, and * both `stopEditor` and `request_editor_shutdown` still report failure, * which is what their shared description promises they can never * disagree about. What the marker buys a caller is the difference * between "there was nothing to close" and "closing broke", which is the * half that decides what it does next - and, in a flow, whether the step * carries `ignore_failure: true`. Every other refusal here - no project * loaded, an editor that is up but published no port - leaves this unset. */ alreadyStopped?: boolean; }; /** * The editor holding `projectPath` open, resolved from what this project * published and cross-checked against the process table. * * A lockfile that no longer describes a live editor of this project is a stale * lockfile, and it is said in those words. It is never reported as a project * mismatch, and the file is never blamed while a healthy editor is still * listening: the recovery is to resolve the process that actually holds the * .uproject, which is what this does before it refuses anything. */ export declare function resolveOwnedEditor(projectDir?: string | null, projectPath?: string | null): Promise; export interface StopEditorResult { success: boolean; message: string; state?: EngineState; /** * Set when no editor for this project was running, so nothing was asked to * quit. The verdict stays false, because this call closed no editor. The * marker is how a caller tells that apart from a stop that reached a * running editor and failed, which is the distinction that decides what it * does next. In a flow, a step that expects this outcome says so itself * with `ignore_failure: true`. */ alreadyStopped?: boolean; /** Why the stop refused, for a caller that would rather branch than parse. */ refusedReason?: "unsaved-work" | "unknown-dirty-state"; /** Every package that was dirty when the stop was asked for. */ dirtyPackages?: string[]; } /** * What a restart returns: the start half's result, unchanged. * * It used to also carry the stop half's account of any dialog met on the way - * the mode, its source, who pressed what. Neither half meets a dialog any more. * The gate refuses both while a modal is up, and reports it there. */ export interface RestartEditorResult { success: boolean; message: string; state?: EngineState; timeline?: ReadyPhase[]; elapsedSeconds?: number; } /** * Stop the editor by asking it to quit ITSELF through the bridge. ue-mcp NEVER * lint-prose-allow: no-kill this comment states what the code refuses to do * issues an OS kill: `taskkill /IM UnrealEditor.exe` matches by image name and * would also close the user's other editors (e.g. their real project). * Success is confirmed by the project's own bridge port going quiet, so it is * specific to this editor even when others are open. * * IT NEVER PRESSES A BUTTON AND IT NEVER DISCARDS. Two questions are asked * before anything is sent: is a modal dialog blocking the editor, and is any * package unsaved. Either one refuses, in under a second, with the whole * question in the report - the dialog's full text and every button paired with * the exact call that presses it, or every dirty package by name. The quit is * not sent in that case, so no save prompt is raised, nothing hangs, and * nothing is lost. * * There is deliberately no flag that discards. * * What happens to a blocking dialog is the caller's choice, through the dialog * handling mode (resolveDialogMode above): * * interactive - the dialog is put to the person over MCP elicitation, with * its own buttons as the choices, and only the button THEY pick * is pressed. The default when the client advertised * elicitation. * auto - the dialog is handed back whole, every button paired with the * exact call that presses it, and the agent decides. The server * presses nothing. * defer - nothing is pressed and nothing is elicited. The dialog is * quoted and the user is told to answer it in the editor. The * default when the client did not advertise elicitation. * * The port comes from what this project published and nowhere else, and the * process behind it is checked before the quit goes out (#819). */ export declare function stopEditor(projectDir?: string, opts?: { /** * How long each poll of the confirm wait sleeps, in milliseconds. * * Internal, defaulted to a second, and not reachable from the tool schema: * it exists so the waiting paths (the 20 polls for the port to close, and * the 10 after a dialog was answered) can be exercised by a test in * milliseconds instead of half a minute. Production passes nothing. */ confirmPollMs?: number; }): Promise; export declare function restartEditor(project: ProjectContext, bridge?: { connect: (timeoutMs?: number) => Promise; }, opts?: { confirmPollMs?: number; }): Promise; export interface BuildResult { success: boolean; message: string; exitCode: number | null; } /** * How many compiles this machine can run at once without running out of room. * * UnrealBuildTool defaults to one process per physical core. Each one maps the * Unreal precompiled header, which costs several GB, so on a machine with less * memory than cores x PCH the compiler does not queue: it fails outright with * C3859 ("failed to create virtual memory for PCH") or C1076, and the build * dies after several minutes of work. The failure names a paging file, so it * reads as a misconfigured machine rather than a parallelism default that does * not fit the hardware. * * This is not a knob anybody should have to find. The number is computed from * the machine and passed to every build, and the cap is only applied when it * is below what UnrealBuildTool would have chosen, so a machine with headroom * builds exactly as it did before. * * `UE_MCP_MAX_PARALLEL_ACTIONS` overrides it, for a machine whose real ceiling * this estimate gets wrong in either direction. */ export declare function safeParallelActions(totalMemBytes?: number, cores?: number): number; /** Whether a build died for want of memory rather than for anything in the code. */ export declare function ranOutOfMemory(output: string): boolean; /** What to tell somebody whose build ran out of room, in the terms they hit it in. */ export declare function describeMemoryFailure(parallel: number, totalMemBytes?: number): string; export interface BuildOptions { onOutput?: (line: string) => void; /** Development (default), DebugGame, Shipping, Test. */ configuration?: string; /** Win64, Mac, Linux. Defaults to the host platform. */ platform?: string; /** Pass -Clean, which makes UnrealBuildTool rebuild from scratch. */ clean?: boolean; } /** * Compile a project's C++ out of process, with UnrealBuildTool. * * Out of process is the point: UnrealBuildTool refuses to link while an editor * holds the module DLLs, so a full rebuild is exactly the case where the editor * must be down, and an editor that is down cannot answer a bridge call (#958). */ export declare function buildProject(projectPath: string, opts?: BuildOptions): Promise;