gdharness 0.13.38 → 0.13.40

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.
package/build/cli.js CHANGED
@@ -15373,7 +15373,7 @@ function buildToolDefinitions() {
15373
15373
  };
15374
15374
  });
15375
15375
  }
15376
- var PROJECT_INFO_SECTIONS, PROJECT_PATH, RUNNING_PROJECT_PATH, SCENE_PATH, SCRIPT_PATH, RESOURCE_PATH, NODE_PATH, PROPERTIES, XY, ANIMATION_TRACK, SCRIPT_MODIFICATIONS, INPUT_EVENTS, TILESET_SOURCES, TILEMAP_CELLS, COLOUR, TOOL_SPECS, SPECS_BY_NAME;
15376
+ var PROJECT_INFO_SECTIONS, PROJECT_PATH, RUNNING_PROJECT_PATH, RUNNING_PID, SCENE_PATH, SCRIPT_PATH, RESOURCE_PATH, NODE_PATH, PROPERTIES, XY, ANIMATION_TRACK, SCRIPT_MODIFICATIONS, INPUT_EVENTS, TILESET_SOURCES, TILEMAP_CELLS, COLOUR, TOOL_SPECS, SPECS_BY_NAME;
15377
15377
  var init_tool_definitions = __esm(() => {
15378
15378
  PROJECT_INFO_SECTIONS = [
15379
15379
  "autoloads",
@@ -15391,6 +15391,10 @@ var init_tool_definitions = __esm(() => {
15391
15391
  type: "string",
15392
15392
  description: "Which game, when more than one is running: the project directory it was started from. Not needed with one game."
15393
15393
  };
15394
+ RUNNING_PID = {
15395
+ type: "number",
15396
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
15397
+ };
15394
15398
  SCENE_PATH = {
15395
15399
  type: "string",
15396
15400
  description: 'Scene file inside the project, such as "scenes/main.tscn" or "res://scenes/main.tscn".'
@@ -16029,7 +16033,7 @@ var init_tool_definitions = __esm(() => {
16029
16033
  },
16030
16034
  {
16031
16035
  name: "editor_output",
16032
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering.",
16036
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering.",
16033
16037
  parameters: {
16034
16038
  severity: {
16035
16039
  type: "string",
@@ -16057,7 +16061,7 @@ var init_tool_definitions = __esm(() => {
16057
16061
  },
16058
16062
  {
16059
16063
  name: "editor_status",
16060
- description: 'Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means "not reached yet" as often as it means "no editor". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.',
16064
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.",
16061
16065
  parameters: {},
16062
16066
  requires: []
16063
16067
  },
@@ -16114,6 +16118,7 @@ var init_tool_definitions = __esm(() => {
16114
16118
  description: "Questions about the running game: what is written on the screen, the scene tree, the nodes matching a query, where one node is on screen, what one property reads, or the performance metrics. Needs the game running with the runtime addon.",
16115
16119
  parameters: {
16116
16120
  projectPath: RUNNING_PROJECT_PATH,
16121
+ pid: RUNNING_PID,
16117
16122
  nodePath: {
16118
16123
  type: "string",
16119
16124
  ops: ["tree", "text", "find", "rect", "property"],
@@ -16192,6 +16197,7 @@ var init_tool_definitions = __esm(() => {
16192
16197
  description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon.",
16193
16198
  parameters: {
16194
16199
  projectPath: RUNNING_PROJECT_PATH,
16200
+ pid: RUNNING_PID,
16195
16201
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
16196
16202
  property: {
16197
16203
  type: "string",
@@ -16231,6 +16237,7 @@ var init_tool_definitions = __esm(() => {
16231
16237
  description: "A picture of the running game: the whole screen or one viewport, as an image. Needs the game running with a window.",
16232
16238
  parameters: {
16233
16239
  projectPath: RUNNING_PROJECT_PATH,
16240
+ pid: RUNNING_PID,
16234
16241
  viewportPath: {
16235
16242
  type: "string",
16236
16243
  ops: ["viewport"],
@@ -16251,6 +16258,7 @@ var init_tool_definitions = __esm(() => {
16251
16258
  description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
16252
16259
  parameters: {
16253
16260
  projectPath: RUNNING_PROJECT_PATH,
16261
+ pid: RUNNING_PID,
16254
16262
  nodePath: {
16255
16263
  type: "string",
16256
16264
  ops: ["click", "choose"],
@@ -16347,6 +16355,7 @@ var init_tool_definitions = __esm(() => {
16347
16355
  description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, a property reaching a value, or words appearing on a screen. Needs the game running with the runtime addon.",
16348
16356
  parameters: {
16349
16357
  projectPath: RUNNING_PROJECT_PATH,
16358
+ pid: RUNNING_PID,
16350
16359
  frames: {
16351
16360
  type: "number",
16352
16361
  ops: ["frames"],
@@ -16382,7 +16391,7 @@ var init_tool_definitions = __esm(() => {
16382
16391
  },
16383
16392
  {
16384
16393
  name: "debug_breakpoint",
16385
- description: "Sets or removes a breakpoint through the editor's debug adapter. Needs the editor, not a running game: set them first, then editor_run, and the game stops where you asked.",
16394
+ description: "Sets or removes a breakpoint through the editor's debug adapter. Needs the editor, not a running game: set them first, then editor_run, and the game stops where you asked. The editor keeps a breakpoint set this way for one play, so every breakpoint this server holds is sent again before each play editor_run starts, and the start answer lists them under breakpoints. The set is also kept in the project, for the server that comes after a reconnect: a breakpoint stays until it is removed here, whichever server set it. Every answer lists the whole set under held, and the breakpoints the user set in the editor's own gutter under setInEditor; setting one here never takes those away, and removing a line here clears it whoever set it. Godot clears every breakpoint in the editor when a debug session opens unless network/debug_adapter/sync_breakpoints is on, which the addon turns on as it loads; an editor running an older addon is said so under breakpointsAtRisk, here and in editor_status.",
16386
16395
  parameters: {
16387
16396
  projectPath: PROJECT_PATH,
16388
16397
  scriptPath: SCRIPT_PATH,
@@ -16407,7 +16416,7 @@ var init_tool_definitions = __esm(() => {
16407
16416
  },
16408
16417
  {
16409
16418
  name: "debug_state",
16410
- description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far.",
16419
+ description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack.",
16411
16420
  parameters: {
16412
16421
  frameId: {
16413
16422
  type: "number",
@@ -31179,15 +31188,77 @@ var init_stdio2 = __esm(() => {
31179
31188
  init_stdio();
31180
31189
  });
31181
31190
 
31182
- // src/bridge-announce.ts
31191
+ // src/breakpoint-note.ts
31183
31192
  import { mkdirSync as mkdirSync6, readFileSync as readFileSync10, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
31184
- import { dirname as dirname7, join as join12 } from "node:path";
31193
+ import { dirname as dirname7, isAbsolute as isAbsolute2, join as join12, relative as relative2 } from "node:path";
31194
+ function breakpointNotePath(projectPath) {
31195
+ return join12(projectPath, ".godot", "gdharness-breakpoints.json");
31196
+ }
31197
+ function writeBreakpointNote(projectPath, held) {
31198
+ const path = breakpointNotePath(projectPath);
31199
+ const root = realPathOr(projectPath);
31200
+ const inside = held.map((one) => ({ scriptPath: realPathOr(one.scriptPath), lines: one.lines })).filter((one) => one.lines.length > 0 && withinProject(root, one.scriptPath));
31201
+ try {
31202
+ if (inside.length === 0) {
31203
+ rmSync6(path, { force: true });
31204
+ return;
31205
+ }
31206
+ const note = {
31207
+ breakpoints: inside.map((one) => ({
31208
+ script: relative2(root, one.scriptPath).replaceAll("\\", "/"),
31209
+ lines: [...one.lines]
31210
+ }))
31211
+ };
31212
+ mkdirSync6(dirname7(path), { recursive: true });
31213
+ writeFileSync8(path, `${JSON.stringify(note, null, 2)}
31214
+ `, "utf8");
31215
+ } catch (error) {
31216
+ console.error(`[SERVER] Could not record the breakpoints at ${path}: ${errorMessage(error)}`);
31217
+ }
31218
+ }
31219
+ function readBreakpointNote(projectPath) {
31220
+ try {
31221
+ const parsed = JSON.parse(readFileSync10(breakpointNotePath(projectPath), "utf8"));
31222
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.breakpoints)) {
31223
+ return [];
31224
+ }
31225
+ const held = [];
31226
+ for (const entry of parsed.breakpoints) {
31227
+ if (typeof entry !== "object" || entry === null) {
31228
+ continue;
31229
+ }
31230
+ const { script, lines: listed } = entry;
31231
+ if (typeof script !== "string" || isAbsolute2(script) || script.split("/").includes("..")) {
31232
+ continue;
31233
+ }
31234
+ const lines = Array.isArray(listed) ? listed.filter((line) => Number.isInteger(line) && line > 0) : [];
31235
+ if (lines.length > 0) {
31236
+ held.push({ scriptPath: realPathOr(join12(projectPath, script)), lines });
31237
+ }
31238
+ }
31239
+ return held;
31240
+ } catch {
31241
+ return [];
31242
+ }
31243
+ }
31244
+ function withinProject(projectPath, scriptPath) {
31245
+ const rel = relative2(projectPath, scriptPath);
31246
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
31247
+ }
31248
+ var init_breakpoint_note = __esm(() => {
31249
+ init_errors();
31250
+ init_paths();
31251
+ });
31252
+
31253
+ // src/bridge-announce.ts
31254
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
31255
+ import { dirname as dirname8, join as join13 } from "node:path";
31185
31256
  function announcementPath(projectPath) {
31186
- return join12(projectPath, ".godot", "gdharness-bridge.json");
31257
+ return join13(projectPath, ".godot", "gdharness-bridge.json");
31187
31258
  }
31188
31259
  function readAnnouncement(path) {
31189
31260
  try {
31190
- const parsed = JSON.parse(readFileSync10(path, "utf8"));
31261
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
31191
31262
  if (typeof parsed !== "object" || parsed === null) {
31192
31263
  return null;
31193
31264
  }
@@ -31206,7 +31277,7 @@ function announcementIsOurs(path) {
31206
31277
  function announceBridge(projectPath, bridge) {
31207
31278
  const path = announcementPath(projectPath);
31208
31279
  try {
31209
- mkdirSync6(dirname7(path), { recursive: true });
31280
+ mkdirSync7(dirname8(path), { recursive: true });
31210
31281
  const announcement = {
31211
31282
  protocol: BRIDGE_ANNOUNCE_PROTOCOL,
31212
31283
  host: bridge.host,
@@ -31215,7 +31286,7 @@ function announceBridge(projectPath, bridge) {
31215
31286
  version: bridge.version,
31216
31287
  startedAt: new Date().toISOString()
31217
31288
  };
31218
- writeFileSync8(path, `${JSON.stringify(announcement, null, 2)}
31289
+ writeFileSync9(path, `${JSON.stringify(announcement, null, 2)}
31219
31290
  `, "utf8");
31220
31291
  return path;
31221
31292
  } catch (error) {
@@ -31228,7 +31299,7 @@ function withdrawBridge(path) {
31228
31299
  return;
31229
31300
  }
31230
31301
  try {
31231
- rmSync6(path, { force: true });
31302
+ rmSync7(path, { force: true });
31232
31303
  } catch (error) {
31233
31304
  console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
31234
31305
  }
@@ -31239,16 +31310,16 @@ var init_bridge_announce = __esm(() => {
31239
31310
  });
31240
31311
 
31241
31312
  // src/config-pin.ts
31242
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
31243
- import { join as join13 } from "node:path";
31313
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
31314
+ import { join as join14 } from "node:path";
31244
31315
  function versionsNamedInConfig(projectPath) {
31245
- const config = join13(projectPath, ".mcp.json");
31316
+ const config = join14(projectPath, ".mcp.json");
31246
31317
  if (!existsSync10(config)) {
31247
31318
  return [];
31248
31319
  }
31249
31320
  let text;
31250
31321
  try {
31251
- text = readFileSync11(config, "utf8");
31322
+ text = readFileSync12(config, "utf8");
31252
31323
  JSON.parse(text);
31253
31324
  } catch {
31254
31325
  return [];
@@ -31417,7 +31488,10 @@ class GodotDAPClient {
31417
31488
  attached = false;
31418
31489
  lastThreadId = 1;
31419
31490
  halt = null;
31491
+ holdKnown = false;
31420
31492
  breakpoints = new Map;
31493
+ inEditor = new Map;
31494
+ onBreakpointsChanged = null;
31421
31495
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
31422
31496
  this.port = port;
31423
31497
  this.host = host;
@@ -31462,11 +31536,7 @@ class GodotDAPClient {
31462
31536
  this.failPendingRequests(error);
31463
31537
  });
31464
31538
  socket.on("close", () => {
31465
- this.connected = false;
31466
- this.initialized = false;
31467
- this.attached = false;
31468
- this.halt = null;
31469
- this.socket = null;
31539
+ this.forgetConnection();
31470
31540
  this.failPendingRequests(new Error("DAP connection closed"));
31471
31541
  });
31472
31542
  resolve();
@@ -31482,10 +31552,7 @@ class GodotDAPClient {
31482
31552
  }
31483
31553
  async abandon() {
31484
31554
  if (!this.socket) {
31485
- this.connected = false;
31486
- this.initialized = false;
31487
- this.attached = false;
31488
- this.halt = null;
31555
+ this.forgetConnection();
31489
31556
  return;
31490
31557
  }
31491
31558
  await new Promise((resolve) => {
@@ -31505,11 +31572,16 @@ class GodotDAPClient {
31505
31572
  resolve();
31506
31573
  }, 500);
31507
31574
  });
31575
+ this.forgetConnection();
31576
+ }
31577
+ forgetConnection() {
31508
31578
  this.socket = null;
31509
31579
  this.connected = false;
31510
31580
  this.initialized = false;
31511
31581
  this.attached = false;
31512
31582
  this.halt = null;
31583
+ this.holdKnown = false;
31584
+ this.inEditor.clear();
31513
31585
  }
31514
31586
  async ensureConnected() {
31515
31587
  if (!this.connected) {
@@ -31598,6 +31670,37 @@ class GodotDAPClient {
31598
31670
  description: said(body, "description"),
31599
31671
  text: said(body, "text")
31600
31672
  };
31673
+ this.holdKnown = true;
31674
+ return;
31675
+ }
31676
+ if (eventName === "continued") {
31677
+ this.halt = null;
31678
+ this.holdKnown = true;
31679
+ return;
31680
+ }
31681
+ if (eventName === "breakpoint") {
31682
+ const body = event.body;
31683
+ const breakpoint = body?.["breakpoint"];
31684
+ const source = typeof breakpoint === "object" && breakpoint !== null ? breakpoint["source"] : undefined;
31685
+ const path = typeof source === "object" && source !== null ? source["path"] : undefined;
31686
+ const line = typeof breakpoint === "object" && breakpoint !== null ? breakpoint["line"] : undefined;
31687
+ if (typeof path !== "string" || typeof line !== "number") {
31688
+ return;
31689
+ }
31690
+ if (said(body, "reason") === "removed") {
31691
+ this.inEditor.get(path)?.delete(line);
31692
+ const held = this.breakpoints.get(path);
31693
+ if (held?.delete(line) === true) {
31694
+ if (held.size === 0) {
31695
+ this.breakpoints.delete(path);
31696
+ }
31697
+ this.onBreakpointsChanged?.(this.breakpointsHeld());
31698
+ }
31699
+ return;
31700
+ }
31701
+ const known = this.inEditor.get(path) ?? new Set;
31702
+ known.add(line);
31703
+ this.inEditor.set(path, known);
31601
31704
  return;
31602
31705
  }
31603
31706
  if (eventName === "terminated" || eventName === "exited") {
@@ -31636,6 +31739,43 @@ class GodotDAPClient {
31636
31739
  await this.sendRequest("attach", {});
31637
31740
  await this.sendRequest("configurationDone", {});
31638
31741
  this.attached = true;
31742
+ if (!this.holdKnown) {
31743
+ await this.learnWhetherHeld();
31744
+ }
31745
+ }
31746
+ async learnWhetherHeld() {
31747
+ try {
31748
+ const threads = await this.sendRequest("threads");
31749
+ const listed = threads["threads"];
31750
+ const first = Array.isArray(listed) ? listed[0]?.["id"] : undefined;
31751
+ if (typeof first !== "number") {
31752
+ return;
31753
+ }
31754
+ this.lastThreadId = first;
31755
+ const response = await this.sendRequest("stackTrace", { threadId: first, startFrame: 0, levels: 1 });
31756
+ if (this.holdKnown) {
31757
+ return;
31758
+ }
31759
+ const frames = response["stackFrames"];
31760
+ if (Array.isArray(frames) && frames.length > 0) {
31761
+ this.halt = {
31762
+ reason: "attached",
31763
+ description: "held when this session attached; the stop that held it was reported to an earlier one",
31764
+ text: ""
31765
+ };
31766
+ this.holdKnown = true;
31767
+ }
31768
+ } catch {}
31769
+ }
31770
+ holdIsKnown() {
31771
+ return this.holdKnown;
31772
+ }
31773
+ learnedRunning() {
31774
+ if (this.holdKnown) {
31775
+ return;
31776
+ }
31777
+ this.halt = null;
31778
+ this.holdKnown = true;
31639
31779
  }
31640
31780
  setOutputSink(sink) {
31641
31781
  this.onOutput = sink;
@@ -31648,38 +31788,96 @@ class GodotDAPClient {
31648
31788
  return lines;
31649
31789
  }
31650
31790
  async setBreakpoint(filePath, line) {
31651
- await this.ensureConnected();
31652
- await this.initialize();
31653
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
31654
- fileBreakpoints.add(line);
31655
- this.breakpoints.set(filePath, fileBreakpoints);
31656
- const lines = Array.from(fileBreakpoints).sort((a, b) => a - b);
31657
- return await this.sendRequest("setBreakpoints", {
31658
- source: { path: filePath },
31659
- breakpoints: lines.map((breakpointLine) => ({ line: breakpointLine }))
31660
- });
31791
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
31792
+ wanted.add(line);
31793
+ return await this.sendBreakpoints(filePath, wanted);
31661
31794
  }
31662
31795
  async removeBreakpoint(filePath, line) {
31796
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
31797
+ wanted.delete(line);
31798
+ return await this.sendBreakpoints(filePath, wanted, line);
31799
+ }
31800
+ async sendBreakpoints(filePath, mine, clearing) {
31663
31801
  await this.ensureConnected();
31664
31802
  await this.initialize();
31665
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
31666
- fileBreakpoints.delete(line);
31667
- if (fileBreakpoints.size === 0) {
31668
- this.breakpoints.delete(filePath);
31669
- } else {
31670
- this.breakpoints.set(filePath, fileBreakpoints);
31803
+ const lines = new Set([...mine, ...this.inEditor.get(filePath) ?? []]);
31804
+ if (clearing !== undefined) {
31805
+ lines.delete(clearing);
31671
31806
  }
31672
- const remaining = Array.from(fileBreakpoints).sort((a, b) => a - b);
31673
- return await this.sendRequest("setBreakpoints", {
31807
+ const sorted = Array.from(lines).sort((a, b) => a - b);
31808
+ const answer = await this.sendRequest("setBreakpoints", {
31674
31809
  source: { path: filePath },
31675
- breakpoints: remaining.map((breakpointLine) => ({ line: breakpointLine }))
31810
+ breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
31676
31811
  });
31812
+ if (mine.size === 0) {
31813
+ this.breakpoints.delete(filePath);
31814
+ } else {
31815
+ this.breakpoints.set(filePath, new Set(mine));
31816
+ }
31817
+ if (lines.size === 0) {
31818
+ this.inEditor.delete(filePath);
31819
+ } else {
31820
+ this.inEditor.set(filePath, lines);
31821
+ }
31822
+ return answer;
31823
+ }
31824
+ setBreakpointsSink(sink) {
31825
+ this.onBreakpointsChanged = sink;
31826
+ }
31827
+ breakpointsInEditor() {
31828
+ const theirs = [];
31829
+ for (const [scriptPath, lines] of this.inEditor) {
31830
+ const mine = this.breakpoints.get(scriptPath);
31831
+ const rest = Array.from(lines).filter((line) => !mine?.has(line)).sort((a, b) => a - b);
31832
+ if (rest.length > 0) {
31833
+ theirs.push({ scriptPath, lines: rest });
31834
+ }
31835
+ }
31836
+ return theirs;
31837
+ }
31838
+ async reapplyBreakpoints() {
31839
+ const applied = [];
31840
+ const refused = [];
31841
+ for (const [filePath, lines] of Array.from(this.breakpoints)) {
31842
+ try {
31843
+ await this.sendBreakpoints(filePath, lines);
31844
+ applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
31845
+ } catch (error) {
31846
+ this.breakpoints.delete(filePath);
31847
+ refused.push({
31848
+ scriptPath: filePath,
31849
+ reason: error instanceof Error ? error.message : String(error)
31850
+ });
31851
+ }
31852
+ }
31853
+ if (refused.length > 0) {
31854
+ this.onBreakpointsChanged?.(this.breakpointsHeld());
31855
+ }
31856
+ return { applied, refused };
31857
+ }
31858
+ breakpointsHeld() {
31859
+ return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
31860
+ scriptPath,
31861
+ lines: Array.from(lines).sort((a, b) => a - b)
31862
+ }));
31863
+ }
31864
+ holdBreakpoints(held) {
31865
+ for (const { scriptPath, lines } of held) {
31866
+ const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
31867
+ for (const line of lines) {
31868
+ fileBreakpoints.add(line);
31869
+ }
31870
+ if (fileBreakpoints.size > 0) {
31871
+ this.breakpoints.set(scriptPath, fileBreakpoints);
31872
+ }
31873
+ }
31677
31874
  }
31678
31875
  async continue(threadId) {
31679
31876
  await this.attach();
31680
31877
  const resolvedThreadId = await this.resolveThreadId(threadId);
31681
31878
  await this.sendRequest("continue", { threadId: resolvedThreadId });
31682
31879
  this.halt = null;
31880
+ this.holdKnown = true;
31683
31881
  }
31684
31882
  async stepOver(threadId) {
31685
31883
  await this.attach();
@@ -31778,11 +31976,7 @@ class GodotDAPClient {
31778
31976
  }
31779
31977
  failOversizedStream(detail) {
31780
31978
  const socket = this.socket;
31781
- this.socket = null;
31782
- this.connected = false;
31783
- this.initialized = false;
31784
- this.attached = false;
31785
- this.halt = null;
31979
+ this.forgetConnection();
31786
31980
  this.reader = new FrameReader;
31787
31981
  socket?.destroy();
31788
31982
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -31861,14 +32055,10 @@ async function handleDAPTool(client, toolName, args) {
31861
32055
  }
31862
32056
  } catch (error) {
31863
32057
  const message = error instanceof Error ? error.message : String(error);
32058
+ const cure = toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
31864
32059
  return {
31865
32060
  isError: true,
31866
- content: [
31867
- {
31868
- type: "text",
31869
- text: `DAP tool '${toolName}' failed: ${message}. ` + "The debug tools answer for a game the editor is playing: start one with editor_run."
31870
- }
31871
- ]
32061
+ content: [{ type: "text", text: `DAP tool '${toolName}' failed: ${message}. ${cure}` }]
31872
32062
  };
31873
32063
  }
31874
32064
  }
@@ -34945,6 +35135,7 @@ var init_godot_bridge = __esm(() => {
34945
35135
  dapPort: this.connectionInfo?.dapPort,
34946
35136
  debugPort: this.connectionInfo?.debugPort,
34947
35137
  openedByAServer: this.connectionInfo?.openedByAServer,
35138
+ syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
34948
35139
  pendingRequests: this.pendingRequests.size,
34949
35140
  queuedResources: this.resourceQueues.size,
34950
35141
  listeningSince: this.listeningSince ?? undefined
@@ -35087,6 +35278,7 @@ var init_godot_bridge = __esm(() => {
35087
35278
  this.connectionInfo.dapPort = servedPort(message.dap_port);
35088
35279
  this.connectionInfo.debugPort = servedPort(message.debug_port);
35089
35280
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
35281
+ this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
35090
35282
  this.log("info", `Godot ready: ${message.project_path}`);
35091
35283
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
35092
35284
  }
@@ -35688,7 +35880,7 @@ var init_junit = __esm(() => {
35688
35880
  import { realpathSync as realpathSync2 } from "node:fs";
35689
35881
  import { readFile, realpath } from "node:fs/promises";
35690
35882
  import { createConnection as createConnection2 } from "node:net";
35691
- import { dirname as dirname8, resolve as resolve3 } from "node:path";
35883
+ import { dirname as dirname9, resolve as resolve3 } from "node:path";
35692
35884
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
35693
35885
  function diagnosticsKey(uri) {
35694
35886
  let decoded;
@@ -35816,7 +36008,7 @@ class GodotLSPClient {
35816
36008
  if (this.initialized) {
35817
36009
  return;
35818
36010
  }
35819
- const rootPath = this.rootPath ?? dirname8(resolve3(filePath));
36011
+ const rootPath = this.rootPath ?? dirname9(resolve3(filePath));
35820
36012
  await this.initialize(rootPath);
35821
36013
  }
35822
36014
  async sendRequest(method, params) {
@@ -36261,13 +36453,13 @@ var init_process_time = __esm(() => {
36261
36453
  });
36262
36454
 
36263
36455
  // src/project-scan.ts
36264
- import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync12 } from "node:fs";
36265
- import { join as join14 } from "node:path";
36456
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync13 } from "node:fs";
36457
+ import { join as join15 } from "node:path";
36266
36458
  function skipped(name) {
36267
36459
  return name.startsWith(".") || SKIPPED.has(name);
36268
36460
  }
36269
36461
  function steppedOver(directory) {
36270
- return existsSync12(join14(directory, ".gdignore"));
36462
+ return existsSync12(join15(directory, ".gdignore"));
36271
36463
  }
36272
36464
  function projectStructure(projectPath) {
36273
36465
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -36280,7 +36472,7 @@ function projectStructure(projectPath) {
36280
36472
  continue;
36281
36473
  }
36282
36474
  if (entry.isDirectory()) {
36283
- visit(join14(directory, entry.name));
36475
+ visit(join15(directory, entry.name));
36284
36476
  } else if (entry.isFile()) {
36285
36477
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
36286
36478
  if (extension === "tscn") {
@@ -36310,14 +36502,14 @@ function scriptsWithoutUid(projectPath) {
36310
36502
  }
36311
36503
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
36312
36504
  if (entry.isDirectory()) {
36313
- visit(join14(directory, entry.name), spelled);
36505
+ visit(join15(directory, entry.name), spelled);
36314
36506
  continue;
36315
36507
  }
36316
36508
  if (!entry.isFile()) {
36317
36509
  continue;
36318
36510
  }
36319
36511
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
36320
- if (CARRIES_A_UID.has(extension) && !existsSync12(join14(directory, `${entry.name}.uid`))) {
36512
+ if (CARRIES_A_UID.has(extension) && !existsSync12(join15(directory, `${entry.name}.uid`))) {
36321
36513
  found.push(spelled);
36322
36514
  }
36323
36515
  }
@@ -36352,7 +36544,7 @@ function searchProject(projectPath, options) {
36352
36544
  if (skipped(entry.name)) {
36353
36545
  continue;
36354
36546
  }
36355
- const entryPath = join14(directory, entry.name);
36547
+ const entryPath = join15(directory, entry.name);
36356
36548
  if (entry.isDirectory()) {
36357
36549
  visit(entryPath);
36358
36550
  continue;
@@ -36363,7 +36555,7 @@ function searchProject(projectPath, options) {
36363
36555
  }
36364
36556
  result.summary.files_searched += 1;
36365
36557
  const matches = [];
36366
- for (const [index, line] of readFileSync12(entryPath, "utf8").split(`
36558
+ for (const [index, line] of readFileSync13(entryPath, "utf8").split(`
36367
36559
  `).entries()) {
36368
36560
  if (full()) {
36369
36561
  break;
@@ -36392,16 +36584,16 @@ var init_project_scan = __esm(() => {
36392
36584
  });
36393
36585
 
36394
36586
  // src/restart-note.ts
36395
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
36396
- import { dirname as dirname9, join as join15 } from "node:path";
36587
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync14, rmSync as rmSync8, writeFileSync as writeFileSync10 } from "node:fs";
36588
+ import { dirname as dirname10, join as join16 } from "node:path";
36397
36589
  function restartNotePath(projectPath) {
36398
- return join15(projectPath, ".godot", "gdharness-restart.json");
36590
+ return join16(projectPath, ".godot", "gdharness-restart.json");
36399
36591
  }
36400
36592
  function noteRestartBegun(note) {
36401
36593
  const path = restartNotePath(note.projectPath);
36402
36594
  try {
36403
- mkdirSync7(dirname9(path), { recursive: true });
36404
- writeFileSync9(path, `${JSON.stringify(note, null, 2)}
36595
+ mkdirSync8(dirname10(path), { recursive: true });
36596
+ writeFileSync10(path, `${JSON.stringify(note, null, 2)}
36405
36597
  `, "utf8");
36406
36598
  } catch (error) {
36407
36599
  console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
@@ -36409,7 +36601,7 @@ function noteRestartBegun(note) {
36409
36601
  }
36410
36602
  function restartOwed(projectPath) {
36411
36603
  try {
36412
- const parsed = JSON.parse(readFileSync13(restartNotePath(projectPath), "utf8"));
36604
+ const parsed = JSON.parse(readFileSync14(restartNotePath(projectPath), "utf8"));
36413
36605
  if (typeof parsed !== "object" || parsed === null) {
36414
36606
  return null;
36415
36607
  }
@@ -36424,7 +36616,7 @@ function restartOwed(projectPath) {
36424
36616
  }
36425
36617
  function restartSettled(projectPath) {
36426
36618
  try {
36427
- rmSync7(restartNotePath(projectPath), { force: true });
36619
+ rmSync8(restartNotePath(projectPath), { force: true });
36428
36620
  } catch (error) {
36429
36621
  console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
36430
36622
  }
@@ -36434,10 +36626,10 @@ var init_restart_note = __esm(() => {
36434
36626
  });
36435
36627
 
36436
36628
  // src/runtime-client.ts
36437
- import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync14, unlinkSync } from "node:fs";
36629
+ import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync15, unlinkSync } from "node:fs";
36438
36630
  import { createConnection as createConnection3 } from "node:net";
36439
36631
  import { tmpdir as tmpdir4 } from "node:os";
36440
- import { join as join16, resolve as resolve4 } from "node:path";
36632
+ import { join as join17, resolve as resolve4 } from "node:path";
36441
36633
  import { setTimeout as delay2 } from "node:timers/promises";
36442
36634
  function runtimeDirectory(variables = process.env) {
36443
36635
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -36445,13 +36637,13 @@ function runtimeDirectory(variables = process.env) {
36445
36637
  return explicit;
36446
36638
  }
36447
36639
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
36448
- return join16(perUser ?? tmpdir4(), "gdharness");
36640
+ return join17(perUser ?? tmpdir4(), "gdharness");
36449
36641
  }
36450
36642
  function runtimeDirectories(variables = process.env) {
36451
36643
  const candidates = [runtimeDirectory(variables)];
36452
36644
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
36453
36645
  for (const base of fallbacks) {
36454
- candidates.push(join16(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36646
+ candidates.push(join17(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36455
36647
  }
36456
36648
  return [...new Set(candidates.map((path) => resolve4(path)))];
36457
36649
  }
@@ -36466,7 +36658,7 @@ function processAlive(pid) {
36466
36658
  function parseAnnouncement(file, pid) {
36467
36659
  let fields;
36468
36660
  try {
36469
- fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36661
+ fields = asParams(JSON.parse(readFileSync15(file, "utf8")));
36470
36662
  } catch {
36471
36663
  return { kind: "rubbish" };
36472
36664
  }
@@ -36507,7 +36699,7 @@ function discoverRuntimes(directories = runtimeDirectories()) {
36507
36699
  function wentAway(file, pid) {
36508
36700
  let project = "";
36509
36701
  try {
36510
- const fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36702
+ const fields = asParams(JSON.parse(readFileSync15(file, "utf8")));
36511
36703
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
36512
36704
  } catch {
36513
36705
  return;
@@ -36536,7 +36728,7 @@ function announcedIn(directory) {
36536
36728
  if (!match) {
36537
36729
  continue;
36538
36730
  }
36539
- const file = join16(directory, entry);
36731
+ const file = join17(directory, entry);
36540
36732
  const pid = Number.parseInt(match[1] ?? "", 10);
36541
36733
  const alive = processAlive(pid);
36542
36734
  const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
@@ -36577,7 +36769,25 @@ function tooNew(unspoken) {
36577
36769
  const half = unspoken.some((game) => game.protocol > RUNTIME_PROTOCOL) ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
36578
36770
  return `A game is running, in a protocol this server does not speak ${named}. This server speaks ${RUNTIME_PROTOCOL}, so ${half}.`;
36579
36771
  }
36580
- function chooseRuntime(endpoints, projectPath, unspoken = []) {
36772
+ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
36773
+ if (pid !== undefined) {
36774
+ const named = endpoints.find((endpoint) => endpoint.pid === pid);
36775
+ if (named !== undefined) {
36776
+ if (projectPath !== undefined && resolve4(named.project.path) !== resolve4(projectPath)) {
36777
+ return {
36778
+ problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve4(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
36779
+ };
36780
+ }
36781
+ return { endpoint: named };
36782
+ }
36783
+ const tooNewToo = unspoken.find((game) => game.pid === pid);
36784
+ if (tooNewToo !== undefined) {
36785
+ return { problem: tooNew([tooNewToo]) };
36786
+ }
36787
+ return {
36788
+ problem: endpoints.length === 0 ? `No game with the runtime addon is running, so there is none with pid ${pid}. Start one with editor_run, or play the project from the editor with the addon enabled.` : `No running game has pid ${pid}. Running: ${endpoints.map(describe2).join("; ")}. editor_status lists them under runtimes as they come and go.`
36789
+ };
36790
+ }
36581
36791
  if (endpoints.length === 0) {
36582
36792
  if (unspoken.length > 0) {
36583
36793
  return { problem: tooNew(unspoken) };
@@ -36608,14 +36818,14 @@ function chooseRuntime(endpoints, projectPath, unspoken = []) {
36608
36818
  return { endpoint: matching[0] };
36609
36819
  }
36610
36820
  return {
36611
- problem: `Several games are running from ${wanted}: ${matching.map(describe2).join("; ")}. Stop all but one.`
36821
+ problem: `Several games are running from ${wanted}: ${matching.map(describe2).join("; ")}. Pass pid to choose one.`
36612
36822
  };
36613
36823
  }
36614
36824
  if (endpoints.length === 1 && endpoints[0]) {
36615
36825
  return { endpoint: endpoints[0] };
36616
36826
  }
36617
36827
  return {
36618
- problem: `Several games are running: ${endpoints.map(describe2).join("; ")}. Pass projectPath to choose one.`
36828
+ problem: `Several games are running: ${endpoints.map(describe2).join("; ")}. Pass projectPath to choose one, or pid when they are from one project.`
36619
36829
  };
36620
36830
  }
36621
36831
  function runtimeRequest(endpoint, command, params, timeoutMs) {
@@ -36713,36 +36923,36 @@ var init_runtime_client = __esm(() => {
36713
36923
 
36714
36924
  // src/run-record.ts
36715
36925
  import { execFileSync } from "node:child_process";
36716
- import { mkdirSync as mkdirSync8, openSync, readdirSync as readdirSync6, readFileSync as readFileSync15, rmSync as rmSync8, statSync as statSync3, writeFileSync as writeFileSync10 } from "node:fs";
36717
- import { basename as basename2, join as join17 } from "node:path";
36926
+ import { mkdirSync as mkdirSync9, openSync, readdirSync as readdirSync6, readFileSync as readFileSync16, rmSync as rmSync9, statSync as statSync3, writeFileSync as writeFileSync11 } from "node:fs";
36927
+ import { basename as basename2, join as join18 } from "node:path";
36718
36928
  function runsDirectory() {
36719
- return join17(runtimeDirectory(), "runs");
36929
+ return join18(runtimeDirectory(), "runs");
36720
36930
  }
36721
36931
  function recordPath() {
36722
- return join17(runsDirectory(), "run.json");
36932
+ return join18(runsDirectory(), "run.json");
36723
36933
  }
36724
36934
  function recordPaths() {
36725
- return runtimeDirectories().map((directory) => join17(directory, "runs", "run.json"));
36935
+ return runtimeDirectories().map((directory) => join18(directory, "runs", "run.json"));
36726
36936
  }
36727
36937
  function openTranscript(startedAt) {
36728
- mkdirSync8(runsDirectory(), { recursive: true });
36729
- const path = join17(runsDirectory(), `run-${startedAt}.log`);
36938
+ mkdirSync9(runsDirectory(), { recursive: true });
36939
+ const path = join18(runsDirectory(), `run-${startedAt}.log`);
36730
36940
  return { path, fd: openSync(path, "a") };
36731
36941
  }
36732
36942
  function writeRunRecord(record) {
36733
- mkdirSync8(runsDirectory(), { recursive: true });
36734
- writeFileSync10(recordPath(), JSON.stringify(record, null, 2), "utf8");
36943
+ mkdirSync9(runsDirectory(), { recursive: true });
36944
+ writeFileSync11(recordPath(), JSON.stringify(record, null, 2), "utf8");
36735
36945
  }
36736
36946
  function editorNotePath() {
36737
- return join17(runsDirectory(), "editor-run.json");
36947
+ return join18(runsDirectory(), "editor-run.json");
36738
36948
  }
36739
36949
  function writeEditorRunNote(note) {
36740
- mkdirSync8(runsDirectory(), { recursive: true });
36741
- writeFileSync10(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36950
+ mkdirSync9(runsDirectory(), { recursive: true });
36951
+ writeFileSync11(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36742
36952
  }
36743
36953
  function readEditorRunNote() {
36744
36954
  for (const directory of runtimeDirectories()) {
36745
- const note = editorNoteAt(join17(directory, "runs", "editor-run.json"));
36955
+ const note = editorNoteAt(join18(directory, "runs", "editor-run.json"));
36746
36956
  if (note !== null) {
36747
36957
  return note;
36748
36958
  }
@@ -36752,7 +36962,7 @@ function readEditorRunNote() {
36752
36962
  function editorNoteAt(path) {
36753
36963
  let parsed;
36754
36964
  try {
36755
- parsed = JSON.parse(readFileSync15(path, "utf8"));
36965
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
36756
36966
  } catch {
36757
36967
  return null;
36758
36968
  }
@@ -36775,7 +36985,7 @@ function recordRunEnded(pid, exitCode) {
36775
36985
  return;
36776
36986
  }
36777
36987
  try {
36778
- writeFileSync10(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36988
+ writeFileSync11(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36779
36989
  } catch {}
36780
36990
  }
36781
36991
  function readRunRecord() {
@@ -36790,7 +37000,7 @@ function readRunRecord() {
36790
37000
  function recordAt(path) {
36791
37001
  let parsed;
36792
37002
  try {
36793
- parsed = JSON.parse(readFileSync15(path, "utf8"));
37003
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
36794
37004
  } catch {
36795
37005
  return null;
36796
37006
  }
@@ -36835,7 +37045,7 @@ function runningAs(pid) {
36835
37045
  const began = startedAt(pid, null);
36836
37046
  const when = began === null ? {} : { startedAt: began };
36837
37047
  if (process.platform === "linux") {
36838
- const raw = readFileSync15(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
37048
+ const raw = readFileSync16(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36839
37049
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
36840
37050
  }
36841
37051
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -36955,7 +37165,7 @@ function clearRunRecord(ours = () => true) {
36955
37165
  continue;
36956
37166
  }
36957
37167
  try {
36958
- rmSync8(path);
37168
+ rmSync9(path);
36959
37169
  } catch {}
36960
37170
  }
36961
37171
  }
@@ -36979,12 +37189,12 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36979
37189
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36980
37190
  continue;
36981
37191
  }
36982
- const path = join17(directory, entry);
37192
+ const path = join18(directory, entry);
36983
37193
  if (path === keep || ageMs(path, now) < keepMs) {
36984
37194
  continue;
36985
37195
  }
36986
37196
  try {
36987
- rmSync8(path);
37197
+ rmSync9(path);
36988
37198
  } catch {}
36989
37199
  }
36990
37200
  }
@@ -37002,21 +37212,41 @@ import {
37002
37212
  closeSync,
37003
37213
  existsSync as existsSync14,
37004
37214
  fstatSync,
37005
- mkdirSync as mkdirSync9,
37215
+ mkdirSync as mkdirSync10,
37006
37216
  mkdtempSync as mkdtempSync3,
37007
37217
  openSync as openSync2,
37008
37218
  readdirSync as readdirSync7,
37009
- readFileSync as readFileSync16,
37219
+ readFileSync as readFileSync17,
37010
37220
  readSync
37011
37221
  } from "node:fs";
37012
37222
  import { tmpdir as tmpdir5 } from "node:os";
37013
- import { basename as basename3, dirname as dirname10, join as join18, normalize as normalize3 } from "node:path";
37223
+ import { basename as basename3, dirname as dirname11, join as join19, normalize as normalize3, relative as relative3 } from "node:path";
37014
37224
  import { setTimeout as delay3 } from "node:timers/promises";
37015
37225
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37016
37226
  import { promisify as promisify6 } from "node:util";
37017
37227
  function patienceForFrames(frames, atLeast) {
37018
37228
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
37019
37229
  }
37230
+ function projectSpelling(projectPath, absolutePath) {
37231
+ return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
37232
+ }
37233
+ function whichGame(args) {
37234
+ return { projectPath: args["projectPath"], pid: args["pid"] };
37235
+ }
37236
+ function describeHalt(halt) {
37237
+ switch (halt.reason) {
37238
+ case "breakpoint":
37239
+ return "at a breakpoint";
37240
+ case "step":
37241
+ return "after a step";
37242
+ case "exception":
37243
+ return halt.text === "" ? "on an error" : `on an error: ${halt.text}`;
37244
+ case "attached":
37245
+ return "held when this session attached";
37246
+ default:
37247
+ return `stopped with reason "${halt.reason}"`;
37248
+ }
37249
+ }
37020
37250
  function stillRunning(run) {
37021
37251
  if (run?.exitCode !== null || run.endedUnwatched === true) {
37022
37252
  return false;
@@ -37038,6 +37268,19 @@ function runIsUp(run, editorSays) {
37038
37268
  }
37039
37269
  return editorSays ?? stillRunning(run);
37040
37270
  }
37271
+ function endedPreviousRun(ended) {
37272
+ if (ended === null) {
37273
+ return;
37274
+ }
37275
+ return ended.pid ?? true;
37276
+ }
37277
+ function endedToStartThis(ended) {
37278
+ if (ended === null) {
37279
+ return "";
37280
+ }
37281
+ const which = ended.pid === null ? "The game the editor was playing" : `The run that was going, pid ${ended.pid},`;
37282
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
37283
+ }
37041
37284
  function runtimeVerdict(endpoint, after) {
37042
37285
  if (endpoint !== null) {
37043
37286
  return { listening: true, pid: endpoint.pid, port: endpoint.port };
@@ -37165,7 +37408,7 @@ function aboveTheRunner(entry) {
37165
37408
  }
37166
37409
  function elsewhereIn(projectPath, asked) {
37167
37410
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
37168
- if (wanted === "" || existsSync14(join18(projectPath, wanted))) {
37411
+ if (wanted === "" || existsSync14(join19(projectPath, wanted))) {
37169
37412
  return "";
37170
37413
  }
37171
37414
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -37179,11 +37422,11 @@ function elsewhereIn(projectPath, asked) {
37179
37422
  return near.length === 0 ? "" : ` The project does have ${there}.`;
37180
37423
  }
37181
37424
  function hasMainScene(projectFile) {
37182
- const scene = parseProjectGodot(readFileSync16(projectFile, "utf8"))["application"]?.["run/main_scene"];
37425
+ const scene = parseProjectGodot(readFileSync17(projectFile, "utf8"))["application"]?.["run/main_scene"];
37183
37426
  return typeof scene === "string" && scene !== "";
37184
37427
  }
37185
37428
  function editorPlaysHeadless(projectFile) {
37186
- const runArgs = parseProjectGodot(readFileSync16(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37429
+ const runArgs = parseProjectGodot(readFileSync17(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37187
37430
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
37188
37431
  }
37189
37432
  function camelCased(params) {
@@ -37199,7 +37442,7 @@ function camelCased(params) {
37199
37442
  class GodotServer {
37200
37443
  mcp;
37201
37444
  locator = new GodotLocator2;
37202
- operationsScript = join18(__dirname2, "godot", "operations", "godot_operations.gd");
37445
+ operationsScript = join19(__dirname2, "godot", "operations", "godot_operations.gd");
37203
37446
  godotBridge;
37204
37447
  tools = buildToolDefinitions();
37205
37448
  activeProcess = null;
@@ -37211,6 +37454,7 @@ class GodotServer {
37211
37454
  lastGodotVersion = null;
37212
37455
  lspClient = null;
37213
37456
  dapClient = null;
37457
+ breakpointsSeededOn = null;
37214
37458
  bridgeStartupError = null;
37215
37459
  bridgeRetry = null;
37216
37460
  successorWatch = null;
@@ -37735,7 +37979,7 @@ class GodotServer {
37735
37979
  switch (op) {
37736
37980
  case "tree":
37737
37981
  return await this.handleRuntimeCommand("get_tree", {
37738
- projectPath: args["projectPath"],
37982
+ ...whichGame(args),
37739
37983
  root: readNonEmptyString(args, "nodePath") ?? "/root",
37740
37984
  depth: readPositiveNumber(args, "depth") ?? 3,
37741
37985
  include_properties: readBoolean(args, "includeProperties") ?? false
@@ -37744,36 +37988,36 @@ class GodotServer {
37744
37988
  return await this.handleFindRuntimeNodes(args);
37745
37989
  case "text":
37746
37990
  return await this.handleRuntimeCommand("read_text", {
37747
- projectPath: args["projectPath"],
37991
+ ...whichGame(args),
37748
37992
  root: readNonEmptyString(args, "nodePath") ?? "/root",
37749
37993
  include_hidden: readBoolean(args, "includeHidden") ?? false,
37750
37994
  limit: readPositiveNumber(args, "limit") ?? 500
37751
37995
  });
37752
37996
  case "rect":
37753
37997
  return await this.handleRuntimeCommand("get_rect", {
37754
- projectPath: args["projectPath"],
37998
+ ...whichGame(args),
37755
37999
  path: readNonEmptyString(args, "nodePath") ?? ""
37756
38000
  });
37757
38001
  case "property":
37758
38002
  return await this.handleRuntimeCommand("get_property", {
37759
- projectPath: args["projectPath"],
38003
+ ...whichGame(args),
37760
38004
  path: readNonEmptyString(args, "nodePath") ?? "",
37761
38005
  property: readNonEmptyString(args, "property") ?? ""
37762
38006
  });
37763
38007
  default:
37764
38008
  return await this.handleRuntimeCommand("get_metrics", {
37765
- projectPath: args["projectPath"],
38009
+ ...whichGame(args),
37766
38010
  metrics: readArray(args, "metrics") ?? []
37767
38011
  });
37768
38012
  }
37769
38013
  case "runtime_invoke":
37770
38014
  return op === "set" ? await this.handleRuntimeCommand("set_property", {
37771
- projectPath: args["projectPath"],
38015
+ ...whichGame(args),
37772
38016
  path: readNonEmptyString(args, "nodePath") ?? "",
37773
38017
  property: readString(args, "property") ?? "",
37774
38018
  value: args["value"]
37775
38019
  }) : await this.handleRuntimeCommand("call_method", {
37776
- projectPath: args["projectPath"],
38020
+ ...whichGame(args),
37777
38021
  path: readNonEmptyString(args, "nodePath") ?? "",
37778
38022
  method: readString(args, "method") ?? "",
37779
38023
  args: readArray(args, "args") ?? []
@@ -37783,7 +38027,7 @@ class GodotServer {
37783
38027
  case "runtime_input":
37784
38028
  if (op === "click") {
37785
38029
  return await this.handleRuntimeCommand("click", {
37786
- projectPath: args["projectPath"],
38030
+ ...whichGame(args),
37787
38031
  path: readNonEmptyString(args, "nodePath") ?? "",
37788
38032
  button: readString(args, "button") ?? "left",
37789
38033
  double: readBoolean(args, "doubleClick") ?? false
@@ -37791,7 +38035,7 @@ class GodotServer {
37791
38035
  }
37792
38036
  if (op === "choose") {
37793
38037
  return await this.handleRuntimeCommand("choose", {
37794
- projectPath: args["projectPath"],
38038
+ ...whichGame(args),
37795
38039
  path: readNonEmptyString(args, "nodePath") ?? "",
37796
38040
  text: readString(args, "text") ?? "",
37797
38041
  ...args["index"] === undefined ? {} : { index: args["index"] }
@@ -37809,17 +38053,30 @@ class GodotServer {
37809
38053
  if (!located.ok) {
37810
38054
  return this.createErrorResponse(located.reason, PATH_SOLUTIONS);
37811
38055
  }
37812
- return await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
38056
+ const session = this.dapHoldingBreakpointsOf(project.value.path);
38057
+ const answered = await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
37813
38058
  ...args,
37814
38059
  scriptPath: realPathOr(located.absolutePath)
37815
38060
  });
38061
+ if (answered.isError === true) {
38062
+ return answered;
38063
+ }
38064
+ const held = session.breakpointsHeld();
38065
+ writeBreakpointNote(project.value.path, held);
38066
+ return this.jsonTextResponse({
38067
+ ...asParams(JSON.parse(answered.content[0]?.text ?? "{}")),
38068
+ held: this.breakpointsAsProjectSpells(project.value.path, held),
38069
+ setInEditor: this.breakpointsAsProjectSpells(project.value.path, session.breakpointsInEditor()),
38070
+ note: "The editor keeps a breakpoint set this way for one play. Every breakpoint held here is sent again before each play editor_run starts, and kept in the project for the server after a reconnect.",
38071
+ ...this.breakpointsAtRisk()
38072
+ });
37816
38073
  }
37817
38074
  case "debug_control":
37818
38075
  case "debug_state": {
37819
38076
  if (tool === "debug_state" && op === "output") {
37820
38077
  return await this.handleDAP("dap_get_output", args);
37821
38078
  }
37822
- const held = await this.debuggedGame();
38079
+ const held = await this.debuggedGame(tool === "debug_control" ? "move" : "read");
37823
38080
  if (!held.ok) {
37824
38081
  return held.response;
37825
38082
  }
@@ -37842,7 +38099,7 @@ class GodotServer {
37842
38099
  if (path === undefined) {
37843
38100
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
37844
38101
  }
37845
- const file = join18(path, "project.godot");
38102
+ const file = join19(path, "project.godot");
37846
38103
  if (!existsSync14(file)) {
37847
38104
  return {
37848
38105
  ok: false,
@@ -37868,7 +38125,7 @@ class GodotServer {
37868
38125
  }
37869
38126
  return { ok: true, value: given };
37870
38127
  }
37871
- async debuggedGame() {
38128
+ async debuggedGame(intent) {
37872
38129
  const game = this.activeProcess;
37873
38130
  if (game !== null && game.exitCode !== null) {
37874
38131
  return {
@@ -37885,6 +38142,16 @@ class GodotServer {
37885
38142
  response: this.createErrorResponse("No game is running, so there is no debug session to answer for.", ["Start one with editor_run start, which has the editor play it"])
37886
38143
  };
37887
38144
  }
38145
+ const going = await this.runStillGoing(game);
38146
+ if (!going) {
38147
+ return {
38148
+ ok: false,
38149
+ response: this.createErrorResponse("The last run has already ended, so there is no debug session to answer for.", [
38150
+ "editor_output still reads what it printed, until the next run starts",
38151
+ "editor_run start plays another, which the debugger can hold"
38152
+ ])
38153
+ };
38154
+ }
37888
38155
  if (!game.throughEditor) {
37889
38156
  return {
37890
38157
  ok: false,
@@ -37906,6 +38173,19 @@ class GodotServer {
37906
38173
  };
37907
38174
  }
37908
38175
  if (!this.dap().isStopped()) {
38176
+ const hold = await this.holdOf(game, going);
38177
+ if (hold.heldAt !== null) {
38178
+ if (intent === "move") {
38179
+ return { ok: true, value: game };
38180
+ }
38181
+ return {
38182
+ ok: false,
38183
+ response: this.createErrorResponse(hold.heldAt === undefined ? `Whether the game is held cannot be told from here, so there is no stack to read. ${hold.heldNote ?? ""}` : `The game is held, and this server cannot read its stack: ${hold.heldAt.description}.`, [
38184
+ "debug_control continue lets a held game go, and the next stop is one this session is told about",
38185
+ "editor_output says what it printed on the way to where it is"
38186
+ ])
38187
+ };
38188
+ }
37909
38189
  return {
37910
38190
  ok: false,
37911
38191
  response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
@@ -38043,7 +38323,7 @@ class GodotServer {
38043
38323
  if (!engine.ok) {
38044
38324
  return engine.response;
38045
38325
  }
38046
- const application = parseProjectGodot(readFileSync16(project.value.file, "utf8"))["application"] ?? {};
38326
+ const application = parseProjectGodot(readFileSync17(project.value.file, "utf8"))["application"] ?? {};
38047
38327
  const name = application["config/name"];
38048
38328
  const mainScene = application["run/main_scene"];
38049
38329
  const info = {
@@ -38104,18 +38384,18 @@ class GodotServer {
38104
38384
  if (!engine.ok) {
38105
38385
  return engine.response;
38106
38386
  }
38107
- const folder = dirname10(output.absolutePath);
38387
+ const folder = dirname11(output.absolutePath);
38108
38388
  try {
38109
- mkdirSync9(folder, { recursive: true });
38389
+ mkdirSync10(folder, { recursive: true });
38110
38390
  } catch (error) {
38111
- return this.createErrorResponse(`Cannot create ${dirname10(output.relativePath)} to export into: ${errorMessage(error)}`);
38391
+ return this.createErrorResponse(`Cannot create ${dirname11(output.relativePath)} to export into: ${errorMessage(error)}`);
38112
38392
  }
38113
38393
  const debug = readBoolean(args, "debug") ?? false;
38114
- const exportLogs = mkdtempSync3(join18(tmpdir5(), "gdharness-export-"));
38394
+ const exportLogs = mkdtempSync3(join19(tmpdir5(), "gdharness-export-"));
38115
38395
  const exportArgs = [
38116
38396
  "--headless",
38117
38397
  "--log-file",
38118
- join18(exportLogs, "engine.log"),
38398
+ join19(exportLogs, "engine.log"),
38119
38399
  "--path",
38120
38400
  project.value.path,
38121
38401
  debug ? "--export-debug" : "--export-release",
@@ -38173,7 +38453,7 @@ class GodotServer {
38173
38453
  return contained.response;
38174
38454
  }
38175
38455
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
38176
- if (!existsSync14(join18(project.value.path, runner))) {
38456
+ if (!existsSync14(join19(project.value.path, runner))) {
38177
38457
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
38178
38458
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
38179
38459
  ]);
@@ -38208,7 +38488,7 @@ class GodotServer {
38208
38488
  ];
38209
38489
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
38210
38490
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
38211
- const userData = mkdtempSync3(join18(tmpdir5(), "gdharness-tests-"));
38491
+ const userData = mkdtempSync3(join19(tmpdir5(), "gdharness-tests-"));
38212
38492
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
38213
38493
  const hung = await new Promise((resolve) => {
38214
38494
  const timer = setTimeout(() => {
@@ -38224,14 +38504,14 @@ class GodotServer {
38224
38504
  resolve(false);
38225
38505
  });
38226
38506
  });
38227
- const reportsDir = join18(project.value.path, ".godot", ...ours.split("/"));
38507
+ const reportsDir = join19(project.value.path, ".godot", ...ours.split("/"));
38228
38508
  let report = null;
38229
38509
  let reportProblem = null;
38230
38510
  try {
38231
38511
  const written = existsSync14(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
38232
38512
  const newest = written.at(-1);
38233
38513
  if (newest !== undefined) {
38234
- report = parseJUnit(readFileSync16(join18(reportsDir, newest, "results.xml"), "utf8"));
38514
+ report = parseJUnit(readFileSync17(join19(reportsDir, newest, "results.xml"), "utf8"));
38235
38515
  }
38236
38516
  } catch (error) {
38237
38517
  reportProblem = errorMessage(error);
@@ -38371,7 +38651,7 @@ class GodotServer {
38371
38651
  return null;
38372
38652
  }
38373
38653
  try {
38374
- return readFileSync16(contained.absolutePath, "utf8");
38654
+ return readFileSync17(contained.absolutePath, "utf8");
38375
38655
  } catch {
38376
38656
  return null;
38377
38657
  }
@@ -38413,7 +38693,7 @@ class GodotServer {
38413
38693
  return null;
38414
38694
  }
38415
38695
  try {
38416
- return readFileSync16(contained.absolutePath, "utf8");
38696
+ return readFileSync17(contained.absolutePath, "utf8");
38417
38697
  } catch {
38418
38698
  return null;
38419
38699
  }
@@ -38459,9 +38739,38 @@ class GodotServer {
38459
38739
  this.dapClient.setOutputSink((line) => {
38460
38740
  this.writeToTranscript(line);
38461
38741
  });
38742
+ this.dapClient.setBreakpointsSink((held) => {
38743
+ const project = this.godotBridge.getStatus().projectPath ?? this.ownProject;
38744
+ if (project !== null) {
38745
+ writeBreakpointNote(project, held);
38746
+ }
38747
+ });
38462
38748
  }
38463
38749
  return this.dapClient;
38464
38750
  }
38751
+ dapHoldingBreakpointsOf(projectPath) {
38752
+ const client = this.dap();
38753
+ if (this.breakpointsSeededOn !== client) {
38754
+ client.holdBreakpoints(readBreakpointNote(projectPath));
38755
+ this.breakpointsSeededOn = client;
38756
+ }
38757
+ return client;
38758
+ }
38759
+ breakpointsAtRisk() {
38760
+ const status = this.godotBridge.getStatus();
38761
+ if (!status.connected || status.syncsBreakpoints === true) {
38762
+ return {};
38763
+ }
38764
+ return {
38765
+ breakpointsAtRisk: status.syncsBreakpoints === false ? "This editor clears every breakpoint in its script editor whenever a debug session opens on its adapter, which this server does for its first breakpoint or stack read: network/debug_adapter/sync_breakpoints is off. The installed addon turns it on as it loads, so the editor is running an older one; editor_launch restart puts the installed addon in, or turn the setting on in Editor Settings > Network > Debug Adapter." : "Whether this editor keeps its breakpoints when a debug session opens cannot be told: the addon it is running predates the report. Godot clears them unless network/debug_adapter/sync_breakpoints is on, which the installed addon turns on as it loads; editor_launch restart puts the installed addon in."
38766
+ };
38767
+ }
38768
+ breakpointsAsProjectSpells(projectPath, held) {
38769
+ return held.map((one) => ({
38770
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
38771
+ lines: one.lines
38772
+ }));
38773
+ }
38465
38774
  writeToTranscript(line) {
38466
38775
  const run = this.activeProcess;
38467
38776
  if (run?.throughEditor !== true || run.transcript === null) {
@@ -38518,6 +38827,7 @@ class GodotServer {
38518
38827
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
38519
38828
  startupError: this.bridgeStartupError,
38520
38829
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
38830
+ ...this.breakpointsAtRisk(),
38521
38831
  retryingBridge: this.bridgeRetry === null ? undefined : true,
38522
38832
  announcedAt: this.announcedAt ?? undefined,
38523
38833
  projectIs: this.projectHasMovedOn() ?? undefined,
@@ -38551,8 +38861,12 @@ class GodotServer {
38551
38861
  async handleEditorStatus() {
38552
38862
  const located = await this.locator.find();
38553
38863
  const announced = runtimesAnnounced();
38554
- const games = await Promise.all(announced.running.map(async (endpoint) => {
38555
- const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
38864
+ const pinged = Promise.all(announced.running.map(async (endpoint) => {
38865
+ const held = this.knownHoldOn(endpoint);
38866
+ const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
38867
+ ok: false,
38868
+ message: `The game is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is. debug_control continue lets it go.`
38869
+ };
38556
38870
  return {
38557
38871
  pid: endpoint.pid,
38558
38872
  port: endpoint.port,
@@ -38561,8 +38875,18 @@ class GodotServer {
38561
38875
  problem: reply.ok ? null : reply.message
38562
38876
  };
38563
38877
  }));
38564
- const playing = await this.editorPlayingState();
38565
- await this.pickUpWhatTheEditorIsPlaying();
38878
+ const asked = (async () => {
38879
+ const playing = await this.editorPlayingState();
38880
+ await this.pickUpWhatTheEditorIsPlaying();
38881
+ const current = this.currentRun();
38882
+ const active = current === null ? false : await this.runStillGoing(current, playing?.playing ?? null);
38883
+ return {
38884
+ playing,
38885
+ active,
38886
+ hold: current === null ? {} : await this.holdOf(current, active)
38887
+ };
38888
+ })();
38889
+ const [games, { playing, active, hold }] = await Promise.all([pinged, asked]);
38566
38890
  return this.jsonTextResponse({
38567
38891
  editor: {
38568
38892
  ...this.getEditorStatusPayload(),
@@ -38574,8 +38898,9 @@ class GodotServer {
38574
38898
  problem: located.ok ? undefined : GodotLocator2.refusal(located).message
38575
38899
  },
38576
38900
  game: {
38577
- processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
38901
+ processActive: active,
38578
38902
  playingInEditor: playing,
38903
+ ...hold,
38579
38904
  runtimeConnected: games.some((game) => game.reachable),
38580
38905
  runtimes: games,
38581
38906
  unreadable: announced.unspoken.map((game) => ({
@@ -38593,7 +38918,7 @@ class GodotServer {
38593
38918
  return null;
38594
38919
  }
38595
38920
  try {
38596
- return settingKeys(readFileSync16(join18(projectPath, "project.godot"), "utf8"));
38921
+ return settingKeys(readFileSync17(join19(projectPath, "project.godot"), "utf8"));
38597
38922
  } catch {
38598
38923
  return null;
38599
38924
  }
@@ -38786,10 +39111,12 @@ class GodotServer {
38786
39111
  if (op === "check") {
38787
39112
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
38788
39113
  }
39114
+ await this.pickUpWhatTheEditorIsPlaying();
38789
39115
  let ended = null;
38790
- if (stillRunning(this.currentRun())) {
39116
+ const before = this.currentRun();
39117
+ if (before !== null && await this.runStillGoing(before)) {
38791
39118
  this.logDebug("Ending the running game before starting another");
38792
- ended = this.currentRun()?.pid ?? null;
39119
+ ended = { pid: before.pid ?? this.announcedPidOf(before) ?? null };
38793
39120
  await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
38794
39121
  }
38795
39122
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
@@ -38813,8 +39140,8 @@ class GodotServer {
38813
39140
  const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
38814
39141
  this.activeProcess = started;
38815
39142
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
38816
- const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
38817
- const stranded = wereHereFirst.filter((one) => one.pid !== ended && alive(one.pid));
39143
+ const endedForThis = endedToStartThis(ended);
39144
+ const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
38818
39145
  const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
38819
39146
  const alsoRunning = stranded.length === 0 ? "" : ` A game of this project was already running and still is: ${named}. Starting this one did not end it and nothing here can, because this server did not start it. End it yourself, or bring the halves to one version where a protocol is named.`;
38820
39147
  return this.jsonTextResponse({
@@ -38825,23 +39152,38 @@ class GodotServer {
38825
39152
  arguments: cmdArgs,
38826
39153
  transcript: started.transcript,
38827
39154
  refreshedClasses: refreshed.value,
38828
- endedPreviousRun: ended ?? undefined,
39155
+ endedPreviousRun: endedPreviousRun(ended),
38829
39156
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
38830
39157
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
38831
39158
  });
38832
39159
  }
38833
- async gameIsUp() {
38834
- const run = this.currentRun();
38835
- const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
38836
- return runIsUp(run, editorSays);
38837
- }
38838
39160
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
38839
- if (!existsSync14(join18(projectPath, RUNTIME_AUTOLOAD2.path))) {
39161
+ if (!existsSync14(join19(projectPath, RUNTIME_AUTOLOAD2.path))) {
38840
39162
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
38841
39163
  }
39164
+ let asking = null;
39165
+ let editorAskedAt = 0;
39166
+ let editorSaysGoing = true;
38842
39167
  const endpoint = await announcedSince(projectPath, before, {
38843
39168
  budgetMs,
38844
- giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
39169
+ giveUp: () => {
39170
+ if (this.dapClient?.isStopped() === true) {
39171
+ return true;
39172
+ }
39173
+ const run = this.currentRun();
39174
+ if (run === null || !run.throughEditor) {
39175
+ return !stillRunning(run);
39176
+ }
39177
+ if (asking === null && Date.now() - editorAskedAt >= RUN_POLL_MS) {
39178
+ editorAskedAt = Date.now();
39179
+ asking = this.runStillGoing(run).then((going) => {
39180
+ editorSaysGoing = going;
39181
+ }).finally(() => {
39182
+ asking = null;
39183
+ });
39184
+ }
39185
+ return !editorSaysGoing;
39186
+ }
38845
39187
  });
38846
39188
  const going = this.currentRun();
38847
39189
  if (endpoint !== null && going?.throughEditor === true && going.announcedPid === undefined) {
@@ -38851,7 +39193,7 @@ class GodotServer {
38851
39193
  addon: true,
38852
39194
  budgetMs,
38853
39195
  heldAt: this.dapClient?.whereItStopped() ?? null,
38854
- running: await this.gameIsUp(),
39196
+ running: endpoint !== null || going !== null && await this.runStillGoing(going),
38855
39197
  withArgs
38856
39198
  });
38857
39199
  }
@@ -38874,6 +39216,7 @@ class GodotServer {
38874
39216
  "Closing the other editor, or moving its debug adapter port in its editor settings, frees this one"
38875
39217
  ]);
38876
39218
  }
39219
+ const breakpoints = await this.dapHoldingBreakpointsOf(projectPath).reapplyBreakpoints();
38877
39220
  const answer = await this.handleViaBridge("play_scene", scene === null ? {} : { scenePath: `res://${scene}` });
38878
39221
  if (answer.isError === true) {
38879
39222
  return answer;
@@ -38891,7 +39234,8 @@ class GodotServer {
38891
39234
  startedAt,
38892
39235
  exitCode: null,
38893
39236
  throughEditor: true,
38894
- brokeOn: null
39237
+ brokeOn: null,
39238
+ announcedBefore: alreadyPlaying
38895
39239
  };
38896
39240
  this.activeProcess = played;
38897
39241
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
@@ -38902,9 +39246,14 @@ class GodotServer {
38902
39246
  scene: scene === null ? "the main scene" : `res://${scene}`,
38903
39247
  refreshedClasses,
38904
39248
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
38905
- endedPreviousRun: ended ?? undefined,
39249
+ endedPreviousRun: endedPreviousRun(ended),
39250
+ breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
39251
+ breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
39252
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
39253
+ reason: one.reason
39254
+ })),
38906
39255
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
38907
- message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
39256
+ message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + endedToStartThis(ended)
38908
39257
  });
38909
39258
  }
38910
39259
  drainEditorOutput(game) {
@@ -39100,7 +39449,7 @@ class GodotServer {
39100
39449
  return;
39101
39450
  }
39102
39451
  try {
39103
- await this.dap().connect();
39452
+ await this.dap().attach();
39104
39453
  } catch (error) {
39105
39454
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
39106
39455
  }
@@ -39122,6 +39471,18 @@ class GodotServer {
39122
39471
  };
39123
39472
  this.activeProcess = picked;
39124
39473
  }
39474
+ announcedPidOf(run) {
39475
+ if (!run.throughEditor || run.announcedPid !== undefined) {
39476
+ return run.announcedPid;
39477
+ }
39478
+ const before = run.announcedBefore ?? new Set;
39479
+ const fresh = this.allAnnouncedForOurProject(runtimesAnnounced().running).filter((one) => !before.has(one.pid));
39480
+ const theOne = fresh.length === 1 ? fresh[0] : undefined;
39481
+ if (theOne !== undefined) {
39482
+ run.announcedPid = theOne.pid;
39483
+ }
39484
+ return run.announcedPid;
39485
+ }
39125
39486
  adoptRecordedRun() {
39126
39487
  const record = readRunRecord();
39127
39488
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -39250,12 +39611,22 @@ class GodotServer {
39250
39611
  }
39251
39612
  const budgetMs = readPositiveNumber(args, "timeoutMs") ?? WAIT_FOR_RUN_MS;
39252
39613
  const until = Date.now() + budgetMs;
39253
- while (stillRunning(run) && Date.now() < until) {
39614
+ while (await this.runStillGoing(run) && Date.now() < until) {
39254
39615
  this.drainEditorOutput(run);
39255
39616
  this.drainTranscript(run);
39256
39617
  await delay3(RUN_POLL_MS);
39257
39618
  }
39258
- return await this.handleGetDebugOutput(args, stillRunning(run) ? budgetMs : undefined);
39619
+ return await this.handleGetDebugOutput(args, await this.runStillGoing(run) ? budgetMs : undefined);
39620
+ }
39621
+ async runStillGoing(run, editorSays) {
39622
+ if (!run.throughEditor) {
39623
+ return stillRunning(run);
39624
+ }
39625
+ const announced = this.announcedPidOf(run);
39626
+ if (announced !== undefined && !alive(announced)) {
39627
+ return false;
39628
+ }
39629
+ return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
39259
39630
  }
39260
39631
  async handleGetDebugOutput(args, waitedMs) {
39261
39632
  await this.pickUpWhatTheEditorIsPlaying();
@@ -39265,7 +39636,8 @@ class GodotServer {
39265
39636
  }
39266
39637
  this.drainEditorOutput(run);
39267
39638
  this.drainTranscript(run);
39268
- if (run.endedUnwatched !== true && !stillRunning(run)) {
39639
+ const going = await this.runStillGoing(run);
39640
+ if (run.endedUnwatched !== true && !going) {
39269
39641
  run.log.finish();
39270
39642
  run.endedUnwatched = run.exitCode === null;
39271
39643
  }
@@ -39276,12 +39648,13 @@ class GodotServer {
39276
39648
  contains: readNonEmptyString(args, "contains"),
39277
39649
  limit: readPositiveNumber(args, "limit") ?? 200
39278
39650
  });
39279
- const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
39651
+ const hold = await this.holdOf(run, going);
39280
39652
  const wanted = readBoolean(args, "cpu") ?? false;
39281
- const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
39653
+ const processToAsk = run.pid ?? this.announcedPidOf(run) ?? null;
39654
+ const cpuSeconds = wanted && processToAsk !== null && stillRunning(run) ? await cpuSecondsOf(processToAsk) : undefined;
39282
39655
  const notes = [];
39283
39656
  if (wanted && cpuSeconds === undefined && stillRunning(run)) {
39284
- notes.push(run.pid === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, so nothing here has its process id. editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds." : "cpu was asked for and this platform would not say what the run has used.");
39657
+ notes.push(processToAsk === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, and the game announced no runtime, so nothing here has its process id. A game with the runtime addon announces one; editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds either way." : "cpu was asked for and this platform would not say what the run has used.");
39285
39658
  }
39286
39659
  if (waitedMs !== undefined) {
39287
39660
  notes.push(`The wait of ${waitedMs}ms ran out and the run is still going, so this is what it had printed by then rather than everything it will print. editor_run wait again to keep waiting.`);
@@ -39305,15 +39678,15 @@ class GodotServer {
39305
39678
  notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: a transcript that stops growing is a run between prints. running above is asked of the operating system while there is a process to ask about, and for an editor-played run that announced no runtime it is the editor's answer instead, which can lag after the game has gone.`);
39306
39679
  }
39307
39680
  return this.jsonTextResponse({
39308
- running: stillRunning(run),
39681
+ running: going,
39309
39682
  exitCode: run.exitCode,
39310
39683
  through: run.throughEditor ? "editor" : "gdharness",
39311
- pid: run.pid,
39684
+ pid: run.pid ?? this.announcedPidOf(run) ?? null,
39312
39685
  errors: run.log.count("error"),
39313
39686
  warnings: run.log.count("warning"),
39314
39687
  clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
39315
39688
  consoleLost: run.consoleLost === true ? true : undefined,
39316
- heldAt: halt,
39689
+ ...hold,
39317
39690
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
39318
39691
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
39319
39692
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
@@ -39334,18 +39707,19 @@ class GodotServer {
39334
39707
  }
39335
39708
  this.drainEditorOutput(stopped);
39336
39709
  this.drainTranscript(stopped);
39710
+ const wasRunning = await this.runStillGoing(stopped);
39337
39711
  this.logDebug("Stopping the running game");
39338
39712
  await this.endActiveGame("editor_run stop");
39339
39713
  return this.jsonTextResponse({
39340
39714
  stopped: true,
39341
39715
  through: stopped.throughEditor ? "editor" : "gdharness",
39342
- endedPid: stopped.pid,
39343
- exitedBeforeStop: stopped.exitCode !== null,
39716
+ endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
39717
+ exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
39344
39718
  exitCode: stopped.exitCode,
39345
39719
  errors: stopped.log.count("error"),
39346
39720
  warnings: stopped.log.count("warning"),
39347
39721
  clean: stopped.log.count("error") === 0,
39348
- note: stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
39722
+ note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
39349
39723
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
39350
39724
  });
39351
39725
  }
@@ -39496,16 +39870,68 @@ class GodotServer {
39496
39870
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
39497
39871
  return Number.isInteger(override) && override > 0 ? override : 1e4;
39498
39872
  }
39873
+ knownHoldOn(endpoint) {
39874
+ const run = this.activeProcess;
39875
+ const session = this.dapClient;
39876
+ if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
39877
+ return null;
39878
+ }
39879
+ if (this.announcedPidOf(run) !== endpoint.pid || !session.holdIsKnown()) {
39880
+ return null;
39881
+ }
39882
+ return session.whereItStopped();
39883
+ }
39884
+ async holdOf(run, going) {
39885
+ if (!run.throughEditor || !(going ?? await this.runStillGoing(run))) {
39886
+ return { heldAt: null };
39887
+ }
39888
+ const session = this.dapClient;
39889
+ if (session?.holdIsKnown()) {
39890
+ return { heldAt: session.whereItStopped() };
39891
+ }
39892
+ const its = this.announcedPidOf(run);
39893
+ const endpoint = its === undefined ? undefined : runtimesAnnounced().running.find((one) => one.pid === its);
39894
+ if (endpoint === undefined) {
39895
+ return {
39896
+ heldAt: undefined,
39897
+ heldUnknown: true,
39898
+ heldNote: "Whether this run is held at a breakpoint cannot be told from here: this server connected to the debugger after any stop, the adapter reports a stop only to the sessions connected when it happened, and the run has announced no runtime to ask instead. A game that draws nothing and answers nothing is held; debug_control continue lets a held game go."
39899
+ };
39900
+ }
39901
+ const reply = await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS);
39902
+ if (reply.ok) {
39903
+ session?.learnedRunning();
39904
+ return { heldAt: session?.whereItStopped() ?? null };
39905
+ }
39906
+ if (reply.reason === "busy") {
39907
+ return {
39908
+ heldAt: {
39909
+ reason: "unanswered",
39910
+ description: `the game accepted a runtime connection and did not answer a ping within ${HOLD_PING_MS}ms, which is what a game held at a breakpoint does, and what one stuck in a long frame does. This server connected to the debugger after the stop, so the adapter will not show it the stack; debug_control continue lets a held game go`,
39911
+ text: ""
39912
+ }
39913
+ };
39914
+ }
39915
+ return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
39916
+ }
39499
39917
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
39500
- const { op: _op, projectPath, ...params } = asParams(args);
39918
+ const { op: _op, projectPath, pid, ...params } = asParams(args);
39501
39919
  const announced = runtimesAnnounced();
39502
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
39920
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
39503
39921
  if ("problem" in choice) {
39504
39922
  return this.createErrorResponse(choice.problem);
39505
39923
  }
39924
+ const held = this.knownHoldOn(choice.endpoint);
39925
+ if (held !== null) {
39926
+ return this.createErrorResponse(`The game (pid ${choice.endpoint.pid}) is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is.`, [
39927
+ "debug_control continue lets it go, after which this call answers",
39928
+ "debug_state stack reads where it is held, and debug_state scopes what it is holding there",
39929
+ "editor_output says heldAt for the run, and editor_run stop ends it"
39930
+ ]);
39931
+ }
39506
39932
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
39507
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join18(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39508
- const screenshotPath = screenshotDir ? join18(screenshotDir, "capture.png") : null;
39933
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join19(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39934
+ const screenshotPath = screenshotDir ? join19(screenshotDir, "capture.png") : null;
39509
39935
  try {
39510
39936
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
39511
39937
  if (!reply.ok) {
@@ -39526,7 +39952,7 @@ class GodotServer {
39526
39952
  return {
39527
39953
  content: [
39528
39954
  { type: "text", text: `Screenshot captured: ${dimensions}` },
39529
- { type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
39955
+ { type: "image", data: readFileSync17(screenshotPath).toString("base64"), mimeType: "image/png" }
39530
39956
  ]
39531
39957
  };
39532
39958
  } finally {
@@ -39557,7 +39983,7 @@ class GodotServer {
39557
39983
  const includeHidden = readBoolean(args, "includeHidden");
39558
39984
  return await this.handleRuntimeCommand("find_nodes", {
39559
39985
  ...filters,
39560
- projectPath: args["projectPath"],
39986
+ ...whichGame(args),
39561
39987
  root: readNonEmptyString(args, "nodePath") ?? "/root",
39562
39988
  limit: readPositiveNumber(args, "limit") ?? 100,
39563
39989
  ...property === undefined ? {} : { property },
@@ -39568,18 +39994,18 @@ class GodotServer {
39568
39994
  if (op === "frames") {
39569
39995
  const frames = readPositiveNumber(args, "frames") ?? 1;
39570
39996
  const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
39571
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
39997
+ return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
39572
39998
  }
39573
39999
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
39574
40000
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
39575
40001
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
39576
40002
  return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
39577
- projectPath: args["projectPath"],
40003
+ ...whichGame(args),
39578
40004
  path: nodePath,
39579
40005
  signal: readString(args, "signal") ?? "",
39580
40006
  timeout_ms: timeoutMs
39581
40007
  }, patience) : await this.handleRuntimeCommand("wait_until", {
39582
- projectPath: args["projectPath"],
40008
+ ...whichGame(args),
39583
40009
  path: nodePath,
39584
40010
  property: readString(args, "property") ?? "",
39585
40011
  value: args["value"],
@@ -39592,11 +40018,12 @@ async function runGodotServer() {
39592
40018
  const server = new GodotServer;
39593
40019
  await server.run();
39594
40020
  }
39595
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run6, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40021
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run6, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
39596
40022
  var init_server3 = __esm(() => {
39597
40023
  init_mcp();
39598
40024
  init_stdio2();
39599
40025
  init_types();
40026
+ init_breakpoint_note();
39600
40027
  init_bridge_announce();
39601
40028
  init_class_cache();
39602
40029
  init_config_pin();
@@ -39624,7 +40051,7 @@ var init_server3 = __esm(() => {
39624
40051
  init_tool_definitions();
39625
40052
  init_update_check();
39626
40053
  run6 = promisify6(execFile6);
39627
- __dirname2 = dirname10(fileURLToPath4(import.meta.url));
40054
+ __dirname2 = dirname11(fileURLToPath4(import.meta.url));
39628
40055
  VALUE_NAMES = {
39629
40056
  string: "a string",
39630
40057
  number: "a number",
@@ -39663,7 +40090,7 @@ var init_server3 = __esm(() => {
39663
40090
 
39664
40091
  // src/cli.ts
39665
40092
  import { existsSync as existsSync15 } from "fs";
39666
- import { join as join19, resolve as resolve5 } from "path";
40093
+ import { join as join20, resolve as resolve5 } from "path";
39667
40094
 
39668
40095
  // src/errors.ts
39669
40096
  class Refusal extends Error {
@@ -41133,7 +41560,8 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
41133
41560
  ## Start here
41134
41561
 
41135
41562
  - ${projectPathSentence()}. The \`runtime_*\` and \`debug_*\` ones pick between running
41136
- games instead; the others answer about the editor this server is connected to. There is no
41563
+ games instead, by \`projectPath\`, or by \`pid\` when several run from one project, as a bench
41564
+ and its workers do; the others answer about the editor this server is connected to. There is no
41137
41565
  ambient project, and an argument a tool does not declare is refused rather than ignored.
41138
41566
  - \`editor_status\` says whether an editor is connected and whether its addon matches the server.
41139
41567
  \`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
@@ -41205,6 +41633,12 @@ The autoload reaches an export. \`gdharness runtime off\` in the project before
41205
41633
  move. There is no pause and no step out: Godot's debug adapter implements neither, so use a
41206
41634
  breakpoint on the line you want instead.
41207
41635
 
41636
+ A held game draws nothing and answers no \`runtime_*\` call. The server says so at once when it
41637
+ knows, naming why the game is held and \`debug_control continue\` as what lets it go, and
41638
+ \`editor_output\` and \`editor_status\` carry the same under \`heldAt\`. A breakpoint set with
41639
+ \`debug_breakpoint\` holds for every start until \`debug_breakpoint remove\`, across a reconnect,
41640
+ so take it off when the question it was for is answered.
41641
+
41208
41642
  ## Editing
41209
41643
 
41210
41644
  \`scene_tree\` for what a scene holds, rather than what the \`.tscn\` text implies. \`scene_node\`,
@@ -41353,7 +41787,7 @@ class UsageError extends Refusal {
41353
41787
  function projectArgument(at) {
41354
41788
  const named = args.slice(at).find((value) => !value.startsWith("--"));
41355
41789
  const projectPath = resolve5(named ?? ".");
41356
- if (!existsSync15(join19(projectPath, "project.godot"))) {
41790
+ if (!existsSync15(join20(projectPath, "project.godot"))) {
41357
41791
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
41358
41792
  }
41359
41793
  return projectPath;
@@ -41392,7 +41826,7 @@ async function registerRuntime(godot, projectPath) {
41392
41826
  return;
41393
41827
  }
41394
41828
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
41395
- if (!existsSync15(join19(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41829
+ if (!existsSync15(join20(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41396
41830
  console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
41397
41831
  }
41398
41832
  }