import type { ChildProcess } from 'child_process'; import type { BridgeOwnerInfo } from './bridge-manager.js'; import { DebuggerProfiler } from './profiler.js'; import type { ActionBoundaryMark } from './bridge-protocol.js'; import type { OperationParams } from '../mcp.types.js'; /** * Thrown when the bridge socket closes (Godot exited, port closed, or peer * dropped the connection mid-flight). Lets callers distinguish * "session ended" from generic transport errors. */ export declare class BridgeDisconnectedError extends Error { constructor(message: string); } export declare const BRIDGE_WAIT_ATTACHED_TIMEOUT_MS = 20000; /** * Ceiling applied once a TCP connect to the bridge port has succeeded but no * pong has been validated yet. A successful connect is positive evidence the * bridge autoload ran and is listening - at that point the remaining wait is * the engine finishing its own startup on a large project, which is worth far * more patience than "nothing is listening yet". Exported for the same * reason as BRIDGE_WAIT_ATTACHED_TIMEOUT_MS above. * * Held under the MCP SDK's 60 s default per-request client timeout on purpose. * A client that attached no progressToken gets no heartbeats, so a wait past * that ceiling is aborted client-side and the server's own structured error - * the port-race diagnostic and its solutions - never reaches the agent. The * remaining headroom covers handleAttachProject's stopProject teardown. */ export declare const BRIDGE_WAIT_ATTACHED_CONNECTED_TIMEOUT_MS = 45000; /** * How many consecutive ping failures end an attached wait that has already * seen a TCP connect. The extended ceiling above exists for an engine still * finishing its startup, not for a socket that accepts and never answers: a * stale Godot from an earlier session holding the port, or an unrelated local * service on a user-supplied bridgePort, both connect and then fail every * ping. At the 1 s ping timeout plus the 2 s backed-off interval that is about * 24 s before the call reports, instead of the full ceiling. One successful * ping that simply is not a valid pong yet resets the count, so a bridge that * is answering is never cut off. */ export declare const BRIDGE_CONNECTED_PING_FAILURE_LIMIT = 8; export declare const BRIDGE_WAIT_ATTACHED_INTERVAL_MS = 500; export declare const BRIDGE_WAIT_BACKOFF_AFTER_MS = 5000; export declare const BRIDGE_WAIT_MAX_INTERVAL_MS = 2000; export declare const BRIDGE_PING_TIMEOUT_MS = 1000; export interface GodotProcess { process: ChildProcess; output: string[]; errors: string[]; totalErrorsWritten: number; exitCode: number | null; hasExited: boolean; sessionToken: string; /** * Action boundaries recorded from stderr during the current input batch. * Optional so a hand-built process literal (tests, fakes) stays valid; * `beginActionErrorCapture` resets it at the start of each batch. */ actionBoundaries?: ActionBoundaryMark[]; /** * True when the last line pushed to `errors` came from a chunk that did not * end in a newline, and so may be the front half of a line the next chunk * completes. `ingestStderrChunk` pops and rejoins it in that case. */ stderrTailIncomplete?: boolean; } /** Opaque handle returned by `beginActionErrorCapture`. */ export interface ActionErrorCapture { marker: number; } export interface ActionErrorBuckets { /** One entry per executed action, already filtered to runtime-error lines. */ buckets: string[][]; /** Runtime-error lines after the last boundary, for the last executed action. */ trailing: string[]; /** True when the expected boundary count never arrived before the deadline. */ sentinelTimedOut: boolean; } export type RuntimeSessionMode = 'spawned' | 'attached'; export interface RuntimeStopResult { mode: RuntimeSessionMode; output: string[]; errors: string[]; externalProcessPreserved?: boolean; /** * True when the spawned process had already exited on its own and * `handleSpawnedProcessExit` had already cleared the session and its bridge * artifacts. Read by `handleStopProject` for message wording and payload. */ alreadyExited?: boolean; /** Exit code captured by the auto-clear, when `alreadyExited`. */ exitCode?: number | null; } export interface GodotServerConfig { godotPath?: string; debugMode?: boolean; } export interface OperationResult { stdout: string; stderr: string; } export declare class GodotRunner { private godotPath; private operationsScriptPath; private bridge; private validatedPaths; private cachedVersion; activeProcess: GodotProcess | null; activeProjectPath: string | null; activeSessionMode: RuntimeSessionMode | null; activeBridgePort: number | null; hasEverAttached: boolean; activeProfiler: DebuggerProfiler | null; private activeSessionToken; /** * Monotonic counter bumped at the head of every session transition * (`runProject`, `attachProject`, `stopProject`). A spawned process's exit * handler captures the value current at registration and does nothing when * it no longer matches, so a late exit from a superseded session cannot * clear the session that replaced it. Identity of `activeProcess` is not * enough: under `profiling: true`, `runProject` awaits * `DebuggerProfiler.create()` between `bridge.inject()` and the new * `activeProcess` assignment, and an identity-guarded handler firing in that * window would clean the new session's freshly injected bridge script. */ private sessionEpoch; private socket; /** * True once a TCP connect to the bridge port has succeeded during the * current session. Set in `sendCommand`'s `ensureSocket` `onConnect` * callback, read by `pollBridge` to switch to the extended readiness * budget, and reset in `beginSessionTransition` so it never leaks across * sessions. */ private bridgeConnectObserved; private rxChunks; private rxTotal; private inFlight; constructor(config?: GodotServerConfig); private isValidGodotPathSync; private spawnAsync; private isValidGodotPath; detectGodotPath(): Promise; getGodotPath(): string | null; /** * True when `project.godot` currently registers the `McpBridge` autoload * pointing at this server's script. Thin pass-through to BridgeManager — * used by the bridge-not-ready timeout diagnostic to tell "the game started * with no bridge autoload at all" from "the bridge is registered but never * became ready". */ isBridgeAutoloadRegistered(projectPath: string): boolean; /** * Other live MCP sessions (different server process, or a different * `BridgeManager` instance in this same process) currently registered on * this project, excluding this runner's own session. Thin pass-through to * `BridgeManager.listOtherLiveOwners`, resolving the path the same way * `runProject`/`attachProject` do so the lookup matches their own owner * file's directory. Powers the cross-server edit guard in * `rejectIfLiveSessionOnProject` (src/utils/headless-op.ts). */ otherLiveSessionsOnProject(projectPath: string): BridgeOwnerInfo[]; getVersion(): Promise; executeOperation(operation: string, params: OperationParams, projectPath: string, timeoutMs?: number): Promise; launchEditor(projectPath: string): ChildProcess; /** * Run `godot --headless --import --path ` to (re)import assets * into `.godot/imported`. Called by `executeSceneOp` (src/utils/headless-op.ts) * when the scene-load probe in godot_operations.gd reports an unimported * dependency via the `[IMPORT_NEEDED]` stderr marker: a fresh project (or a * newly-added asset) has no imported artifacts yet, and resource-touching * operations would otherwise fail with `resource not found` even though the * file is on disk — the import step has never run for it. * * Note: Godot exits 0 even when individual assets fail to import; this * method inspects stderr for "ERROR: Error importing" and throws if found, * since the caller has no other signal that the import didn't fully succeed. */ importAssets(projectPath: string, timeoutMs?: number): Promise; runProject(projectPath: string, scene?: string, background?: boolean, bridgePort?: number, profiling?: boolean): Promise; /** * Open a new session epoch. Called as the first statement of every entry * point that installs or tears down session state, so handlers registered * under a previous epoch become inert the moment the transition starts. */ private beginSessionTransition; /** * `'exit'` handler for a spawned Godot process: the session auto-clear. * * WIDEST INPUT: this fires for every exit of every process this runner ever * spawned — a crash, a window the user closed, a `stopProject` kill, and the * kill `runProject` issues before starting a replacement. `exitCode` and * `hasExited` are recorded unconditionally because the buffer belongs to the * captured process regardless of which session is current; everything after * the epoch check mutates shared session state and so runs only for the * session that registered this handler. * * `activeProcess` and `activeProfiler` are deliberately left alone: the * output buffer and exit code live on the former, and a capture that * finished just before a crash stays readable through the latter. */ private handleSpawnedProcessExit; /** * Drop an attached session whose bridge has gone away. Mirrors the * attached branch of `stopProject` minus the `shutdown` command and the * process handling, since there is no process here and no peer to talk to. * * Production call site: the disconnect probe in `sendCommandWithReconnect`. */ private clearAttachedSession; /** * Synchronous, never-throwing bridge artifact removal for the active * project. `BridgeManager.cleanup` is pure synchronous `fs`, so this is safe * from a `process.on('exit')` handler, where promises never settle. * * Production call site: the `'exit'` handler registered by * `registerProcessLifecycle` in `src/index.ts`. */ cleanupBridgeArtifactsSync(): void; attachProject(projectPath: string, bridgePort?: number): Promise; stopProject(): Promise; private closeProfiler; hasActiveRuntimeSession(): boolean; /** * Send a JSON command to the McpBridge over a long-lived TCP connection. * * MCP serializes tool calls so we hold one in-flight command at a time. The * socket is lazy-connected on first call and persists across commands until * `closeConnection` (or a peer-side close). A close mid-flight rejects with * `BridgeDisconnectedError`; a per-command timeout rejects but does NOT * close the socket — a slow command does not invalidate the session. */ sendCommand(command: string, params?: Record, timeoutMs?: number): Promise; /** * Tear down the bridge socket. Idempotent. Any in-flight command is * rejected with a session-ended error. */ closeConnection(): void; private resetRxBuffer; getErrorCount(): number; getErrorsSince(marker: number): string[]; /** * Fold one raw stderr chunk into a spawned session's buffers. The only writer * of `GodotProcess.errors` and `totalErrorsWritten`. * * Action-boundary sentinels are recorded as marks and never retained, so * every reader of `errors` - `get_debug_output`, `stop_project`'s * `finalErrors`, `getErrorsSince`, `getRecentErrors` - is clean without a * per-read filter. `totalErrorsWritten` counts retained lines only, which * keeps the delta arithmetic in `getErrorsSince` correct and makes each * mark's `seq` survive the ring trim below. * * Public only so unit tests can drive ingestion without spawning Godot; the * production caller is the session stderr handler in `runProject`. * * A `'data'` event boundary can land mid-line, splitting one Godot stderr * line into two chunks. Every existing test here passes a chunk with no * trailing newline and expects the final segment retained immediately, so * this cannot withhold a trailing partial line the way a conventional carry * buffer would - that would turn every one of those tests red. Instead it * emits eagerly and coalesces retroactively: a chunk lacking a trailing * newline marks `proc.stderrTailIncomplete`, and the next chunk pops that * tail back off, prepends it to its own first segment, and re-runs * `parseActionBoundary` on the rejoined text - which is the entire fix, since * a sentinel split across the boundary is unrecognizable in either half. * * Why the bookkeeping stays correct: * - `totalErrorsWritten`: the pop decrements before the rejoined line's push * increments, landing exactly where an unsplit chunk would have left it. * A reader sampling between the two chunks sees the truncated line and a * count including it (today's behavior); the pop-then-push realigns it * with no drift. * - `actionBoundaries[].seq`: an incomplete tail is by definition the last * segment of its chunk, so no mark can have been recorded after it - * every existing mark's `seq` is <= the popped line's index and the pop * cannot invalidate one. A mark from the rejoined line itself gets its * `seq` from the already-decremented counter, which is correct. * - `STDERR_RING_LIMIT_LINES`: the incomplete tail is the newest line and * the trim removes from the front, so it is never the line trimmed; the * `proc.errors.length > 0` guard below covers the degenerate case anyway. * - Process exit with a dangling partial line: nothing to flush. The eager * emit already put it in `errors`, so `stop_project` and * `get_debug_output` see it exactly as they do today. No flush-on-close * handler is added; eager emission is what makes one unnecessary. * - `\r\n` on Windows: unchanged on purpose. `parseActionBoundary` already * trims each line, so a sentinel with a trailing `\r` still parses. A * chunk boundary falling between `\r` and `\n` produces a tail ending in * `\r`, then a next chunk whose first segment is `''`; the rejoin yields * the same text and the following empty line lands as it would unsplit. * Retained lines are not stripped of `\r` here - that would change the * text every existing stderr assertion compares against. * - Trailing empty segment: `'a\n'.split('\n')` is `['a', '']` and the `''` * is pushed and counted today. `endsWith('\n')` marks the tail complete in * that case, so the quirk is preserved byte for byte. */ ingestStderrChunk(proc: GodotProcess, text: string): void; /** * The same delta window as {@link getErrorsSince}, without its blank-line * filter and with the sequence number of the first line. Per-action error * attribution needs line positions that line up with the recorded boundary * marks, which dropping blanks would shift. `getErrorsSince` itself is * deliberately untouched so every existing caller keeps its behavior. */ stderrWindowSince(marker: number): { lines: string[]; startSeq: number; }; /** * Open a per-action error capture ahead of an input batch. Clearing the * boundary list here bounds it to one batch: only the input path consumes * boundaries and MCP serializes tool calls, so no cap is needed. */ beginActionErrorCapture(): ActionErrorCapture; /** * Close a capture and attribute its runtime-error lines to the actions that * produced them. * * Waits on a bounded poll for the expected boundary count, because the TCP * response can arrive before stderr has drained. On timeout it attributes * what is present and reports `sentinelTimedOut`; it never blocks * indefinitely. `drainTimeoutMs` is a parameter so tests need not spend the * full wait. Attached sessions have no captured stderr, so they get empty * buckets immediately and the caller simply omits `errors`. */ collectActionErrors(capture: ActionErrorCapture, expectedSentinels: number, drainTimeoutMs?: number): Promise; private static readonly SCRIPT_ERROR_PATTERNS; private static readonly RETRYABLE_BRIDGE_COMMANDS; /** * Commands exempt from the attached-mode disconnect probe: a teardown guard. * `closeConnection` is itself one of the producers of * `BridgeDisconnectedError` (it rejects any in-flight command with one), and * the command in flight during our own teardown is a `shutdown`. Probing on * that would clear a session already being torn down deliberately, and * probing on a `ping` would recurse into the probe itself. Today both * teardown `shutdown`s and the probe call `sendCommand` directly, so this * set is unreached in production; it is here so routing either through the * reconnect wrapper stays correct. */ private static readonly DISCONNECT_EXEMPT_BRIDGE_COMMANDS; extractRuntimeErrors(lines: string[]): string[]; /** * `sendCommand` plus the transient-drop retry and, in attached mode, the * disconnect-means-session-end probe. * * WIDEST INPUT of the disconnect predicate: `BridgeDisconnectedError` has * seven producers in `sendCommand` — connect failure, socket unavailable, * oversized frame header, framing parse error, socket `'error'`, peer * `'close'`, and `closeConnection`'s in-flight rejection. A per-command * timeout is a plain `Error` and never reaches here, so a wedged-but-alive * game is not mistaken for a dead one. The chain below narrows that set: * spawned sessions keep today's behavior (the exit handler owns them), * `shutdown`/`ping` are exempt, a retryable command spends its one retry * first, and every survivor must still fail a live `ping` before anything is * cleared. */ private sendCommandWithReconnect; sendCommandWithErrors(command: string, params?: Record, timeoutMs?: number): Promise<{ response: string; runtimeErrors: string[]; stderrWindow: string[]; }>; /** * Shared poll loop for `waitForBridge` (spawned) and `waitForBridgeAttached`. * Sends `ping` payloads until the bridge replies with a pong that * `validatePong` accepts, the deadline passes, or `shouldAbort` reports * the spawned process has exited. */ private pollBridge; waitForBridgeAttached(timeoutMs?: number, intervalMs?: number): Promise<{ ready: boolean; error?: string; }>; waitForBridge(timeoutMs?: number, intervalMs?: number): Promise<{ ready: boolean; error?: string; }>; getRecentErrors(count?: number): string[]; } //# sourceMappingURL=godot-runner.d.ts.map