gdharness 0.13.37 → 0.13.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/build/cli.js +401 -135
  2. package/build/index.js +478 -216
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -15917,7 +15917,7 @@ var init_tool_definitions = __esm(() => {
15917
15917
  },
15918
15918
  {
15919
15919
  name: "script_diagnostics",
15920
- description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says which remedy applies, because they differ: a stale analysed type needs editor_launch restart, while a class missing from the cache needs project_import refresh_classes.",
15920
+ description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
15921
15921
  parameters: {
15922
15922
  projectPath: PROJECT_PATH,
15923
15923
  scriptPath: SCRIPT_PATH
@@ -16029,7 +16029,7 @@ var init_tool_definitions = __esm(() => {
16029
16029
  },
16030
16030
  {
16031
16031
  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.",
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. 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
16033
  parameters: {
16034
16034
  severity: {
16035
16035
  type: "string",
@@ -16057,7 +16057,7 @@ var init_tool_definitions = __esm(() => {
16057
16057
  },
16058
16058
  {
16059
16059
  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.',
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. 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
16061
  parameters: {},
16062
16062
  requires: []
16063
16063
  },
@@ -16382,7 +16382,7 @@ var init_tool_definitions = __esm(() => {
16382
16382
  },
16383
16383
  {
16384
16384
  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.",
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. 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.",
16386
16386
  parameters: {
16387
16387
  projectPath: PROJECT_PATH,
16388
16388
  scriptPath: SCRIPT_PATH,
@@ -16407,7 +16407,7 @@ var init_tool_definitions = __esm(() => {
16407
16407
  },
16408
16408
  {
16409
16409
  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.",
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. 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
16411
  parameters: {
16412
16412
  frameId: {
16413
16413
  type: "number",
@@ -31179,15 +31179,77 @@ var init_stdio2 = __esm(() => {
31179
31179
  init_stdio();
31180
31180
  });
31181
31181
 
31182
- // src/bridge-announce.ts
31182
+ // src/breakpoint-note.ts
31183
31183
  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";
31184
+ import { dirname as dirname7, isAbsolute as isAbsolute2, join as join12, relative as relative2 } from "node:path";
31185
+ function breakpointNotePath(projectPath) {
31186
+ return join12(projectPath, ".godot", "gdharness-breakpoints.json");
31187
+ }
31188
+ function writeBreakpointNote(projectPath, held) {
31189
+ const path = breakpointNotePath(projectPath);
31190
+ const root = realPathOr(projectPath);
31191
+ const inside = held.map((one) => ({ scriptPath: realPathOr(one.scriptPath), lines: one.lines })).filter((one) => one.lines.length > 0 && withinProject(root, one.scriptPath));
31192
+ try {
31193
+ if (inside.length === 0) {
31194
+ rmSync6(path, { force: true });
31195
+ return;
31196
+ }
31197
+ const note = {
31198
+ breakpoints: inside.map((one) => ({
31199
+ script: relative2(root, one.scriptPath).replaceAll("\\", "/"),
31200
+ lines: [...one.lines]
31201
+ }))
31202
+ };
31203
+ mkdirSync6(dirname7(path), { recursive: true });
31204
+ writeFileSync8(path, `${JSON.stringify(note, null, 2)}
31205
+ `, "utf8");
31206
+ } catch (error) {
31207
+ console.error(`[SERVER] Could not record the breakpoints at ${path}: ${errorMessage(error)}`);
31208
+ }
31209
+ }
31210
+ function readBreakpointNote(projectPath) {
31211
+ try {
31212
+ const parsed = JSON.parse(readFileSync10(breakpointNotePath(projectPath), "utf8"));
31213
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.breakpoints)) {
31214
+ return [];
31215
+ }
31216
+ const held = [];
31217
+ for (const entry of parsed.breakpoints) {
31218
+ if (typeof entry !== "object" || entry === null) {
31219
+ continue;
31220
+ }
31221
+ const { script, lines: listed } = entry;
31222
+ if (typeof script !== "string" || isAbsolute2(script) || script.split("/").includes("..")) {
31223
+ continue;
31224
+ }
31225
+ const lines = Array.isArray(listed) ? listed.filter((line) => Number.isInteger(line) && line > 0) : [];
31226
+ if (lines.length > 0) {
31227
+ held.push({ scriptPath: realPathOr(join12(projectPath, script)), lines });
31228
+ }
31229
+ }
31230
+ return held;
31231
+ } catch {
31232
+ return [];
31233
+ }
31234
+ }
31235
+ function withinProject(projectPath, scriptPath) {
31236
+ const rel = relative2(projectPath, scriptPath);
31237
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
31238
+ }
31239
+ var init_breakpoint_note = __esm(() => {
31240
+ init_errors();
31241
+ init_paths();
31242
+ });
31243
+
31244
+ // src/bridge-announce.ts
31245
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
31246
+ import { dirname as dirname8, join as join13 } from "node:path";
31185
31247
  function announcementPath(projectPath) {
31186
- return join12(projectPath, ".godot", "gdharness-bridge.json");
31248
+ return join13(projectPath, ".godot", "gdharness-bridge.json");
31187
31249
  }
31188
31250
  function readAnnouncement(path) {
31189
31251
  try {
31190
- const parsed = JSON.parse(readFileSync10(path, "utf8"));
31252
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
31191
31253
  if (typeof parsed !== "object" || parsed === null) {
31192
31254
  return null;
31193
31255
  }
@@ -31206,7 +31268,7 @@ function announcementIsOurs(path) {
31206
31268
  function announceBridge(projectPath, bridge) {
31207
31269
  const path = announcementPath(projectPath);
31208
31270
  try {
31209
- mkdirSync6(dirname7(path), { recursive: true });
31271
+ mkdirSync7(dirname8(path), { recursive: true });
31210
31272
  const announcement = {
31211
31273
  protocol: BRIDGE_ANNOUNCE_PROTOCOL,
31212
31274
  host: bridge.host,
@@ -31215,7 +31277,7 @@ function announceBridge(projectPath, bridge) {
31215
31277
  version: bridge.version,
31216
31278
  startedAt: new Date().toISOString()
31217
31279
  };
31218
- writeFileSync8(path, `${JSON.stringify(announcement, null, 2)}
31280
+ writeFileSync9(path, `${JSON.stringify(announcement, null, 2)}
31219
31281
  `, "utf8");
31220
31282
  return path;
31221
31283
  } catch (error) {
@@ -31228,7 +31290,7 @@ function withdrawBridge(path) {
31228
31290
  return;
31229
31291
  }
31230
31292
  try {
31231
- rmSync6(path, { force: true });
31293
+ rmSync7(path, { force: true });
31232
31294
  } catch (error) {
31233
31295
  console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
31234
31296
  }
@@ -31239,16 +31301,16 @@ var init_bridge_announce = __esm(() => {
31239
31301
  });
31240
31302
 
31241
31303
  // src/config-pin.ts
31242
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
31243
- import { join as join13 } from "node:path";
31304
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
31305
+ import { join as join14 } from "node:path";
31244
31306
  function versionsNamedInConfig(projectPath) {
31245
- const config = join13(projectPath, ".mcp.json");
31307
+ const config = join14(projectPath, ".mcp.json");
31246
31308
  if (!existsSync10(config)) {
31247
31309
  return [];
31248
31310
  }
31249
31311
  let text;
31250
31312
  try {
31251
- text = readFileSync11(config, "utf8");
31313
+ text = readFileSync12(config, "utf8");
31252
31314
  JSON.parse(text);
31253
31315
  } catch {
31254
31316
  return [];
@@ -31417,6 +31479,7 @@ class GodotDAPClient {
31417
31479
  attached = false;
31418
31480
  lastThreadId = 1;
31419
31481
  halt = null;
31482
+ holdKnown = false;
31420
31483
  breakpoints = new Map;
31421
31484
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
31422
31485
  this.port = port;
@@ -31462,11 +31525,7 @@ class GodotDAPClient {
31462
31525
  this.failPendingRequests(error);
31463
31526
  });
31464
31527
  socket.on("close", () => {
31465
- this.connected = false;
31466
- this.initialized = false;
31467
- this.attached = false;
31468
- this.halt = null;
31469
- this.socket = null;
31528
+ this.forgetConnection();
31470
31529
  this.failPendingRequests(new Error("DAP connection closed"));
31471
31530
  });
31472
31531
  resolve();
@@ -31482,10 +31541,7 @@ class GodotDAPClient {
31482
31541
  }
31483
31542
  async abandon() {
31484
31543
  if (!this.socket) {
31485
- this.connected = false;
31486
- this.initialized = false;
31487
- this.attached = false;
31488
- this.halt = null;
31544
+ this.forgetConnection();
31489
31545
  return;
31490
31546
  }
31491
31547
  await new Promise((resolve) => {
@@ -31505,11 +31561,15 @@ class GodotDAPClient {
31505
31561
  resolve();
31506
31562
  }, 500);
31507
31563
  });
31564
+ this.forgetConnection();
31565
+ }
31566
+ forgetConnection() {
31508
31567
  this.socket = null;
31509
31568
  this.connected = false;
31510
31569
  this.initialized = false;
31511
31570
  this.attached = false;
31512
31571
  this.halt = null;
31572
+ this.holdKnown = false;
31513
31573
  }
31514
31574
  async ensureConnected() {
31515
31575
  if (!this.connected) {
@@ -31598,6 +31658,12 @@ class GodotDAPClient {
31598
31658
  description: said(body, "description"),
31599
31659
  text: said(body, "text")
31600
31660
  };
31661
+ this.holdKnown = true;
31662
+ return;
31663
+ }
31664
+ if (eventName === "continued") {
31665
+ this.halt = null;
31666
+ this.holdKnown = true;
31601
31667
  return;
31602
31668
  }
31603
31669
  if (eventName === "terminated" || eventName === "exited") {
@@ -31636,6 +31702,43 @@ class GodotDAPClient {
31636
31702
  await this.sendRequest("attach", {});
31637
31703
  await this.sendRequest("configurationDone", {});
31638
31704
  this.attached = true;
31705
+ if (!this.holdKnown) {
31706
+ await this.learnWhetherHeld();
31707
+ }
31708
+ }
31709
+ async learnWhetherHeld() {
31710
+ try {
31711
+ const threads = await this.sendRequest("threads");
31712
+ const listed = threads["threads"];
31713
+ const first = Array.isArray(listed) ? listed[0]?.["id"] : undefined;
31714
+ if (typeof first !== "number") {
31715
+ return;
31716
+ }
31717
+ this.lastThreadId = first;
31718
+ const response = await this.sendRequest("stackTrace", { threadId: first, startFrame: 0, levels: 1 });
31719
+ if (this.holdKnown) {
31720
+ return;
31721
+ }
31722
+ const frames = response["stackFrames"];
31723
+ if (Array.isArray(frames) && frames.length > 0) {
31724
+ this.halt = {
31725
+ reason: "attached",
31726
+ description: "held when this session attached; the stop that held it was reported to an earlier one",
31727
+ text: ""
31728
+ };
31729
+ this.holdKnown = true;
31730
+ }
31731
+ } catch {}
31732
+ }
31733
+ holdIsKnown() {
31734
+ return this.holdKnown;
31735
+ }
31736
+ learnedRunning() {
31737
+ if (this.holdKnown) {
31738
+ return;
31739
+ }
31740
+ this.halt = null;
31741
+ this.holdKnown = true;
31639
31742
  }
31640
31743
  setOutputSink(sink) {
31641
31744
  this.onOutput = sink;
@@ -31648,38 +31751,70 @@ class GodotDAPClient {
31648
31751
  return lines;
31649
31752
  }
31650
31753
  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
- });
31754
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
31755
+ wanted.add(line);
31756
+ return await this.sendBreakpoints(filePath, wanted);
31661
31757
  }
31662
31758
  async removeBreakpoint(filePath, line) {
31759
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
31760
+ wanted.delete(line);
31761
+ return await this.sendBreakpoints(filePath, wanted);
31762
+ }
31763
+ async sendBreakpoints(filePath, lines) {
31663
31764
  await this.ensureConnected();
31664
31765
  await this.initialize();
31665
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
31666
- fileBreakpoints.delete(line);
31667
- if (fileBreakpoints.size === 0) {
31766
+ const sorted = Array.from(lines).sort((a, b) => a - b);
31767
+ const answer = await this.sendRequest("setBreakpoints", {
31768
+ source: { path: filePath },
31769
+ breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
31770
+ });
31771
+ if (lines.size === 0) {
31668
31772
  this.breakpoints.delete(filePath);
31669
31773
  } else {
31670
- this.breakpoints.set(filePath, fileBreakpoints);
31774
+ this.breakpoints.set(filePath, new Set(lines));
31775
+ }
31776
+ return answer;
31777
+ }
31778
+ async reapplyBreakpoints() {
31779
+ const applied = [];
31780
+ const refused = [];
31781
+ for (const [filePath, lines] of Array.from(this.breakpoints)) {
31782
+ try {
31783
+ await this.sendBreakpoints(filePath, lines);
31784
+ applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
31785
+ } catch (error) {
31786
+ this.breakpoints.delete(filePath);
31787
+ refused.push({
31788
+ scriptPath: filePath,
31789
+ reason: error instanceof Error ? error.message : String(error)
31790
+ });
31791
+ }
31792
+ }
31793
+ return { applied, refused };
31794
+ }
31795
+ breakpointsHeld() {
31796
+ return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
31797
+ scriptPath,
31798
+ lines: Array.from(lines).sort((a, b) => a - b)
31799
+ }));
31800
+ }
31801
+ holdBreakpoints(held) {
31802
+ for (const { scriptPath, lines } of held) {
31803
+ const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
31804
+ for (const line of lines) {
31805
+ fileBreakpoints.add(line);
31806
+ }
31807
+ if (fileBreakpoints.size > 0) {
31808
+ this.breakpoints.set(scriptPath, fileBreakpoints);
31809
+ }
31671
31810
  }
31672
- const remaining = Array.from(fileBreakpoints).sort((a, b) => a - b);
31673
- return await this.sendRequest("setBreakpoints", {
31674
- source: { path: filePath },
31675
- breakpoints: remaining.map((breakpointLine) => ({ line: breakpointLine }))
31676
- });
31677
31811
  }
31678
31812
  async continue(threadId) {
31679
31813
  await this.attach();
31680
31814
  const resolvedThreadId = await this.resolveThreadId(threadId);
31681
31815
  await this.sendRequest("continue", { threadId: resolvedThreadId });
31682
31816
  this.halt = null;
31817
+ this.holdKnown = true;
31683
31818
  }
31684
31819
  async stepOver(threadId) {
31685
31820
  await this.attach();
@@ -31778,11 +31913,7 @@ class GodotDAPClient {
31778
31913
  }
31779
31914
  failOversizedStream(detail) {
31780
31915
  const socket = this.socket;
31781
- this.socket = null;
31782
- this.connected = false;
31783
- this.initialized = false;
31784
- this.attached = false;
31785
- this.halt = null;
31916
+ this.forgetConnection();
31786
31917
  this.reader = new FrameReader;
31787
31918
  socket?.destroy();
31788
31919
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -35688,7 +35819,7 @@ var init_junit = __esm(() => {
35688
35819
  import { realpathSync as realpathSync2 } from "node:fs";
35689
35820
  import { readFile, realpath } from "node:fs/promises";
35690
35821
  import { createConnection as createConnection2 } from "node:net";
35691
- import { dirname as dirname8, resolve as resolve3 } from "node:path";
35822
+ import { dirname as dirname9, resolve as resolve3 } from "node:path";
35692
35823
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
35693
35824
  function diagnosticsKey(uri) {
35694
35825
  let decoded;
@@ -35816,7 +35947,7 @@ class GodotLSPClient {
35816
35947
  if (this.initialized) {
35817
35948
  return;
35818
35949
  }
35819
- const rootPath = this.rootPath ?? dirname8(resolve3(filePath));
35950
+ const rootPath = this.rootPath ?? dirname9(resolve3(filePath));
35820
35951
  await this.initialize(rootPath);
35821
35952
  }
35822
35953
  async sendRequest(method, params) {
@@ -36261,13 +36392,13 @@ var init_process_time = __esm(() => {
36261
36392
  });
36262
36393
 
36263
36394
  // 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";
36395
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync13 } from "node:fs";
36396
+ import { join as join15 } from "node:path";
36266
36397
  function skipped(name) {
36267
36398
  return name.startsWith(".") || SKIPPED.has(name);
36268
36399
  }
36269
36400
  function steppedOver(directory) {
36270
- return existsSync12(join14(directory, ".gdignore"));
36401
+ return existsSync12(join15(directory, ".gdignore"));
36271
36402
  }
36272
36403
  function projectStructure(projectPath) {
36273
36404
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -36280,7 +36411,7 @@ function projectStructure(projectPath) {
36280
36411
  continue;
36281
36412
  }
36282
36413
  if (entry.isDirectory()) {
36283
- visit(join14(directory, entry.name));
36414
+ visit(join15(directory, entry.name));
36284
36415
  } else if (entry.isFile()) {
36285
36416
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
36286
36417
  if (extension === "tscn") {
@@ -36310,14 +36441,14 @@ function scriptsWithoutUid(projectPath) {
36310
36441
  }
36311
36442
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
36312
36443
  if (entry.isDirectory()) {
36313
- visit(join14(directory, entry.name), spelled);
36444
+ visit(join15(directory, entry.name), spelled);
36314
36445
  continue;
36315
36446
  }
36316
36447
  if (!entry.isFile()) {
36317
36448
  continue;
36318
36449
  }
36319
36450
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
36320
- if (CARRIES_A_UID.has(extension) && !existsSync12(join14(directory, `${entry.name}.uid`))) {
36451
+ if (CARRIES_A_UID.has(extension) && !existsSync12(join15(directory, `${entry.name}.uid`))) {
36321
36452
  found.push(spelled);
36322
36453
  }
36323
36454
  }
@@ -36352,7 +36483,7 @@ function searchProject(projectPath, options) {
36352
36483
  if (skipped(entry.name)) {
36353
36484
  continue;
36354
36485
  }
36355
- const entryPath = join14(directory, entry.name);
36486
+ const entryPath = join15(directory, entry.name);
36356
36487
  if (entry.isDirectory()) {
36357
36488
  visit(entryPath);
36358
36489
  continue;
@@ -36363,7 +36494,7 @@ function searchProject(projectPath, options) {
36363
36494
  }
36364
36495
  result.summary.files_searched += 1;
36365
36496
  const matches = [];
36366
- for (const [index, line] of readFileSync12(entryPath, "utf8").split(`
36497
+ for (const [index, line] of readFileSync13(entryPath, "utf8").split(`
36367
36498
  `).entries()) {
36368
36499
  if (full()) {
36369
36500
  break;
@@ -36392,16 +36523,16 @@ var init_project_scan = __esm(() => {
36392
36523
  });
36393
36524
 
36394
36525
  // 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";
36526
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync14, rmSync as rmSync8, writeFileSync as writeFileSync10 } from "node:fs";
36527
+ import { dirname as dirname10, join as join16 } from "node:path";
36397
36528
  function restartNotePath(projectPath) {
36398
- return join15(projectPath, ".godot", "gdharness-restart.json");
36529
+ return join16(projectPath, ".godot", "gdharness-restart.json");
36399
36530
  }
36400
36531
  function noteRestartBegun(note) {
36401
36532
  const path = restartNotePath(note.projectPath);
36402
36533
  try {
36403
- mkdirSync7(dirname9(path), { recursive: true });
36404
- writeFileSync9(path, `${JSON.stringify(note, null, 2)}
36534
+ mkdirSync8(dirname10(path), { recursive: true });
36535
+ writeFileSync10(path, `${JSON.stringify(note, null, 2)}
36405
36536
  `, "utf8");
36406
36537
  } catch (error) {
36407
36538
  console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
@@ -36409,7 +36540,7 @@ function noteRestartBegun(note) {
36409
36540
  }
36410
36541
  function restartOwed(projectPath) {
36411
36542
  try {
36412
- const parsed = JSON.parse(readFileSync13(restartNotePath(projectPath), "utf8"));
36543
+ const parsed = JSON.parse(readFileSync14(restartNotePath(projectPath), "utf8"));
36413
36544
  if (typeof parsed !== "object" || parsed === null) {
36414
36545
  return null;
36415
36546
  }
@@ -36424,7 +36555,7 @@ function restartOwed(projectPath) {
36424
36555
  }
36425
36556
  function restartSettled(projectPath) {
36426
36557
  try {
36427
- rmSync7(restartNotePath(projectPath), { force: true });
36558
+ rmSync8(restartNotePath(projectPath), { force: true });
36428
36559
  } catch (error) {
36429
36560
  console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
36430
36561
  }
@@ -36434,10 +36565,10 @@ var init_restart_note = __esm(() => {
36434
36565
  });
36435
36566
 
36436
36567
  // src/runtime-client.ts
36437
- import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync14, unlinkSync } from "node:fs";
36568
+ import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync15, unlinkSync } from "node:fs";
36438
36569
  import { createConnection as createConnection3 } from "node:net";
36439
36570
  import { tmpdir as tmpdir4 } from "node:os";
36440
- import { join as join16, resolve as resolve4 } from "node:path";
36571
+ import { join as join17, resolve as resolve4 } from "node:path";
36441
36572
  import { setTimeout as delay2 } from "node:timers/promises";
36442
36573
  function runtimeDirectory(variables = process.env) {
36443
36574
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -36445,13 +36576,13 @@ function runtimeDirectory(variables = process.env) {
36445
36576
  return explicit;
36446
36577
  }
36447
36578
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
36448
- return join16(perUser ?? tmpdir4(), "gdharness");
36579
+ return join17(perUser ?? tmpdir4(), "gdharness");
36449
36580
  }
36450
36581
  function runtimeDirectories(variables = process.env) {
36451
36582
  const candidates = [runtimeDirectory(variables)];
36452
36583
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
36453
36584
  for (const base of fallbacks) {
36454
- candidates.push(join16(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36585
+ candidates.push(join17(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36455
36586
  }
36456
36587
  return [...new Set(candidates.map((path) => resolve4(path)))];
36457
36588
  }
@@ -36466,7 +36597,7 @@ function processAlive(pid) {
36466
36597
  function parseAnnouncement(file, pid) {
36467
36598
  let fields;
36468
36599
  try {
36469
- fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36600
+ fields = asParams(JSON.parse(readFileSync15(file, "utf8")));
36470
36601
  } catch {
36471
36602
  return { kind: "rubbish" };
36472
36603
  }
@@ -36507,7 +36638,7 @@ function discoverRuntimes(directories = runtimeDirectories()) {
36507
36638
  function wentAway(file, pid) {
36508
36639
  let project = "";
36509
36640
  try {
36510
- const fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36641
+ const fields = asParams(JSON.parse(readFileSync15(file, "utf8")));
36511
36642
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
36512
36643
  } catch {
36513
36644
  return;
@@ -36536,7 +36667,7 @@ function announcedIn(directory) {
36536
36667
  if (!match) {
36537
36668
  continue;
36538
36669
  }
36539
- const file = join16(directory, entry);
36670
+ const file = join17(directory, entry);
36540
36671
  const pid = Number.parseInt(match[1] ?? "", 10);
36541
36672
  const alive = processAlive(pid);
36542
36673
  const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
@@ -36713,36 +36844,36 @@ var init_runtime_client = __esm(() => {
36713
36844
 
36714
36845
  // src/run-record.ts
36715
36846
  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";
36847
+ import { mkdirSync as mkdirSync9, openSync, readdirSync as readdirSync6, readFileSync as readFileSync16, rmSync as rmSync9, statSync as statSync3, writeFileSync as writeFileSync11 } from "node:fs";
36848
+ import { basename as basename2, join as join18 } from "node:path";
36718
36849
  function runsDirectory() {
36719
- return join17(runtimeDirectory(), "runs");
36850
+ return join18(runtimeDirectory(), "runs");
36720
36851
  }
36721
36852
  function recordPath() {
36722
- return join17(runsDirectory(), "run.json");
36853
+ return join18(runsDirectory(), "run.json");
36723
36854
  }
36724
36855
  function recordPaths() {
36725
- return runtimeDirectories().map((directory) => join17(directory, "runs", "run.json"));
36856
+ return runtimeDirectories().map((directory) => join18(directory, "runs", "run.json"));
36726
36857
  }
36727
36858
  function openTranscript(startedAt) {
36728
- mkdirSync8(runsDirectory(), { recursive: true });
36729
- const path = join17(runsDirectory(), `run-${startedAt}.log`);
36859
+ mkdirSync9(runsDirectory(), { recursive: true });
36860
+ const path = join18(runsDirectory(), `run-${startedAt}.log`);
36730
36861
  return { path, fd: openSync(path, "a") };
36731
36862
  }
36732
36863
  function writeRunRecord(record) {
36733
- mkdirSync8(runsDirectory(), { recursive: true });
36734
- writeFileSync10(recordPath(), JSON.stringify(record, null, 2), "utf8");
36864
+ mkdirSync9(runsDirectory(), { recursive: true });
36865
+ writeFileSync11(recordPath(), JSON.stringify(record, null, 2), "utf8");
36735
36866
  }
36736
36867
  function editorNotePath() {
36737
- return join17(runsDirectory(), "editor-run.json");
36868
+ return join18(runsDirectory(), "editor-run.json");
36738
36869
  }
36739
36870
  function writeEditorRunNote(note) {
36740
- mkdirSync8(runsDirectory(), { recursive: true });
36741
- writeFileSync10(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36871
+ mkdirSync9(runsDirectory(), { recursive: true });
36872
+ writeFileSync11(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36742
36873
  }
36743
36874
  function readEditorRunNote() {
36744
36875
  for (const directory of runtimeDirectories()) {
36745
- const note = editorNoteAt(join17(directory, "runs", "editor-run.json"));
36876
+ const note = editorNoteAt(join18(directory, "runs", "editor-run.json"));
36746
36877
  if (note !== null) {
36747
36878
  return note;
36748
36879
  }
@@ -36752,7 +36883,7 @@ function readEditorRunNote() {
36752
36883
  function editorNoteAt(path) {
36753
36884
  let parsed;
36754
36885
  try {
36755
- parsed = JSON.parse(readFileSync15(path, "utf8"));
36886
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
36756
36887
  } catch {
36757
36888
  return null;
36758
36889
  }
@@ -36775,7 +36906,7 @@ function recordRunEnded(pid, exitCode) {
36775
36906
  return;
36776
36907
  }
36777
36908
  try {
36778
- writeFileSync10(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36909
+ writeFileSync11(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36779
36910
  } catch {}
36780
36911
  }
36781
36912
  function readRunRecord() {
@@ -36790,7 +36921,7 @@ function readRunRecord() {
36790
36921
  function recordAt(path) {
36791
36922
  let parsed;
36792
36923
  try {
36793
- parsed = JSON.parse(readFileSync15(path, "utf8"));
36924
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
36794
36925
  } catch {
36795
36926
  return null;
36796
36927
  }
@@ -36835,7 +36966,7 @@ function runningAs(pid) {
36835
36966
  const began = startedAt(pid, null);
36836
36967
  const when = began === null ? {} : { startedAt: began };
36837
36968
  if (process.platform === "linux") {
36838
- const raw = readFileSync15(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36969
+ const raw = readFileSync16(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36839
36970
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
36840
36971
  }
36841
36972
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -36955,7 +37086,7 @@ function clearRunRecord(ours = () => true) {
36955
37086
  continue;
36956
37087
  }
36957
37088
  try {
36958
- rmSync8(path);
37089
+ rmSync9(path);
36959
37090
  } catch {}
36960
37091
  }
36961
37092
  }
@@ -36979,12 +37110,12 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36979
37110
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36980
37111
  continue;
36981
37112
  }
36982
- const path = join17(directory, entry);
37113
+ const path = join18(directory, entry);
36983
37114
  if (path === keep || ageMs(path, now) < keepMs) {
36984
37115
  continue;
36985
37116
  }
36986
37117
  try {
36987
- rmSync8(path);
37118
+ rmSync9(path);
36988
37119
  } catch {}
36989
37120
  }
36990
37121
  }
@@ -37002,21 +37133,38 @@ import {
37002
37133
  closeSync,
37003
37134
  existsSync as existsSync14,
37004
37135
  fstatSync,
37005
- mkdirSync as mkdirSync9,
37136
+ mkdirSync as mkdirSync10,
37006
37137
  mkdtempSync as mkdtempSync3,
37007
37138
  openSync as openSync2,
37008
37139
  readdirSync as readdirSync7,
37009
- readFileSync as readFileSync16,
37140
+ readFileSync as readFileSync17,
37010
37141
  readSync
37011
37142
  } from "node:fs";
37012
37143
  import { tmpdir as tmpdir5 } from "node:os";
37013
- import { basename as basename3, dirname as dirname10, join as join18, normalize as normalize3 } from "node:path";
37144
+ import { basename as basename3, dirname as dirname11, join as join19, normalize as normalize3, relative as relative3 } from "node:path";
37014
37145
  import { setTimeout as delay3 } from "node:timers/promises";
37015
37146
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37016
37147
  import { promisify as promisify6 } from "node:util";
37017
37148
  function patienceForFrames(frames, atLeast) {
37018
37149
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
37019
37150
  }
37151
+ function projectSpelling(projectPath, absolutePath) {
37152
+ return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
37153
+ }
37154
+ function describeHalt(halt) {
37155
+ switch (halt.reason) {
37156
+ case "breakpoint":
37157
+ return "at a breakpoint";
37158
+ case "step":
37159
+ return "after a step";
37160
+ case "exception":
37161
+ return halt.text === "" ? "on an error" : `on an error: ${halt.text}`;
37162
+ case "attached":
37163
+ return "held when this session attached";
37164
+ default:
37165
+ return `stopped with reason "${halt.reason}"`;
37166
+ }
37167
+ }
37020
37168
  function stillRunning(run) {
37021
37169
  if (run?.exitCode !== null || run.endedUnwatched === true) {
37022
37170
  return false;
@@ -37165,7 +37313,7 @@ function aboveTheRunner(entry) {
37165
37313
  }
37166
37314
  function elsewhereIn(projectPath, asked) {
37167
37315
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
37168
- if (wanted === "" || existsSync14(join18(projectPath, wanted))) {
37316
+ if (wanted === "" || existsSync14(join19(projectPath, wanted))) {
37169
37317
  return "";
37170
37318
  }
37171
37319
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -37179,11 +37327,11 @@ function elsewhereIn(projectPath, asked) {
37179
37327
  return near.length === 0 ? "" : ` The project does have ${there}.`;
37180
37328
  }
37181
37329
  function hasMainScene(projectFile) {
37182
- const scene = parseProjectGodot(readFileSync16(projectFile, "utf8"))["application"]?.["run/main_scene"];
37330
+ const scene = parseProjectGodot(readFileSync17(projectFile, "utf8"))["application"]?.["run/main_scene"];
37183
37331
  return typeof scene === "string" && scene !== "";
37184
37332
  }
37185
37333
  function editorPlaysHeadless(projectFile) {
37186
- const runArgs = parseProjectGodot(readFileSync16(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37334
+ const runArgs = parseProjectGodot(readFileSync17(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37187
37335
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
37188
37336
  }
37189
37337
  function camelCased(params) {
@@ -37199,7 +37347,7 @@ function camelCased(params) {
37199
37347
  class GodotServer {
37200
37348
  mcp;
37201
37349
  locator = new GodotLocator2;
37202
- operationsScript = join18(__dirname2, "godot", "operations", "godot_operations.gd");
37350
+ operationsScript = join19(__dirname2, "godot", "operations", "godot_operations.gd");
37203
37351
  godotBridge;
37204
37352
  tools = buildToolDefinitions();
37205
37353
  activeProcess = null;
@@ -37211,6 +37359,7 @@ class GodotServer {
37211
37359
  lastGodotVersion = null;
37212
37360
  lspClient = null;
37213
37361
  dapClient = null;
37362
+ breakpointsSeededOn = null;
37214
37363
  bridgeStartupError = null;
37215
37364
  bridgeRetry = null;
37216
37365
  successorWatch = null;
@@ -37809,17 +37958,28 @@ class GodotServer {
37809
37958
  if (!located.ok) {
37810
37959
  return this.createErrorResponse(located.reason, PATH_SOLUTIONS);
37811
37960
  }
37812
- return await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
37961
+ const session = this.dapHoldingBreakpointsOf(project.value.path);
37962
+ const answered = await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
37813
37963
  ...args,
37814
37964
  scriptPath: realPathOr(located.absolutePath)
37815
37965
  });
37966
+ if (answered.isError === true) {
37967
+ return answered;
37968
+ }
37969
+ const held = session.breakpointsHeld();
37970
+ writeBreakpointNote(project.value.path, held);
37971
+ return this.jsonTextResponse({
37972
+ ...asParams(JSON.parse(answered.content[0]?.text ?? "{}")),
37973
+ held: this.breakpointsAsProjectSpells(project.value.path, held),
37974
+ 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."
37975
+ });
37816
37976
  }
37817
37977
  case "debug_control":
37818
37978
  case "debug_state": {
37819
37979
  if (tool === "debug_state" && op === "output") {
37820
37980
  return await this.handleDAP("dap_get_output", args);
37821
37981
  }
37822
- const held = await this.debuggedGame();
37982
+ const held = await this.debuggedGame(tool === "debug_control" ? "move" : "read");
37823
37983
  if (!held.ok) {
37824
37984
  return held.response;
37825
37985
  }
@@ -37842,7 +38002,7 @@ class GodotServer {
37842
38002
  if (path === undefined) {
37843
38003
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
37844
38004
  }
37845
- const file = join18(path, "project.godot");
38005
+ const file = join19(path, "project.godot");
37846
38006
  if (!existsSync14(file)) {
37847
38007
  return {
37848
38008
  ok: false,
@@ -37868,7 +38028,7 @@ class GodotServer {
37868
38028
  }
37869
38029
  return { ok: true, value: given };
37870
38030
  }
37871
- async debuggedGame() {
38031
+ async debuggedGame(intent) {
37872
38032
  const game = this.activeProcess;
37873
38033
  if (game !== null && game.exitCode !== null) {
37874
38034
  return {
@@ -37906,6 +38066,19 @@ class GodotServer {
37906
38066
  };
37907
38067
  }
37908
38068
  if (!this.dap().isStopped()) {
38069
+ const hold = await this.holdOf(game);
38070
+ if (hold.heldAt !== null) {
38071
+ if (intent === "move") {
38072
+ return { ok: true, value: game };
38073
+ }
38074
+ return {
38075
+ ok: false,
38076
+ 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}.`, [
38077
+ "debug_control continue lets a held game go, and the next stop is one this session is told about",
38078
+ "editor_output says what it printed on the way to where it is"
38079
+ ])
38080
+ };
38081
+ }
37909
38082
  return {
37910
38083
  ok: false,
37911
38084
  response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
@@ -38043,7 +38216,7 @@ class GodotServer {
38043
38216
  if (!engine.ok) {
38044
38217
  return engine.response;
38045
38218
  }
38046
- const application = parseProjectGodot(readFileSync16(project.value.file, "utf8"))["application"] ?? {};
38219
+ const application = parseProjectGodot(readFileSync17(project.value.file, "utf8"))["application"] ?? {};
38047
38220
  const name = application["config/name"];
38048
38221
  const mainScene = application["run/main_scene"];
38049
38222
  const info = {
@@ -38104,18 +38277,18 @@ class GodotServer {
38104
38277
  if (!engine.ok) {
38105
38278
  return engine.response;
38106
38279
  }
38107
- const folder = dirname10(output.absolutePath);
38280
+ const folder = dirname11(output.absolutePath);
38108
38281
  try {
38109
- mkdirSync9(folder, { recursive: true });
38282
+ mkdirSync10(folder, { recursive: true });
38110
38283
  } catch (error) {
38111
- return this.createErrorResponse(`Cannot create ${dirname10(output.relativePath)} to export into: ${errorMessage(error)}`);
38284
+ return this.createErrorResponse(`Cannot create ${dirname11(output.relativePath)} to export into: ${errorMessage(error)}`);
38112
38285
  }
38113
38286
  const debug = readBoolean(args, "debug") ?? false;
38114
- const exportLogs = mkdtempSync3(join18(tmpdir5(), "gdharness-export-"));
38287
+ const exportLogs = mkdtempSync3(join19(tmpdir5(), "gdharness-export-"));
38115
38288
  const exportArgs = [
38116
38289
  "--headless",
38117
38290
  "--log-file",
38118
- join18(exportLogs, "engine.log"),
38291
+ join19(exportLogs, "engine.log"),
38119
38292
  "--path",
38120
38293
  project.value.path,
38121
38294
  debug ? "--export-debug" : "--export-release",
@@ -38173,7 +38346,7 @@ class GodotServer {
38173
38346
  return contained.response;
38174
38347
  }
38175
38348
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
38176
- if (!existsSync14(join18(project.value.path, runner))) {
38349
+ if (!existsSync14(join19(project.value.path, runner))) {
38177
38350
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
38178
38351
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
38179
38352
  ]);
@@ -38208,7 +38381,7 @@ class GodotServer {
38208
38381
  ];
38209
38382
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
38210
38383
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
38211
- const userData = mkdtempSync3(join18(tmpdir5(), "gdharness-tests-"));
38384
+ const userData = mkdtempSync3(join19(tmpdir5(), "gdharness-tests-"));
38212
38385
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
38213
38386
  const hung = await new Promise((resolve) => {
38214
38387
  const timer = setTimeout(() => {
@@ -38224,14 +38397,14 @@ class GodotServer {
38224
38397
  resolve(false);
38225
38398
  });
38226
38399
  });
38227
- const reportsDir = join18(project.value.path, ".godot", ...ours.split("/"));
38400
+ const reportsDir = join19(project.value.path, ".godot", ...ours.split("/"));
38228
38401
  let report = null;
38229
38402
  let reportProblem = null;
38230
38403
  try {
38231
38404
  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
38405
  const newest = written.at(-1);
38233
38406
  if (newest !== undefined) {
38234
- report = parseJUnit(readFileSync16(join18(reportsDir, newest, "results.xml"), "utf8"));
38407
+ report = parseJUnit(readFileSync17(join19(reportsDir, newest, "results.xml"), "utf8"));
38235
38408
  }
38236
38409
  } catch (error) {
38237
38410
  reportProblem = errorMessage(error);
@@ -38371,7 +38544,7 @@ class GodotServer {
38371
38544
  return null;
38372
38545
  }
38373
38546
  try {
38374
- return readFileSync16(contained.absolutePath, "utf8");
38547
+ return readFileSync17(contained.absolutePath, "utf8");
38375
38548
  } catch {
38376
38549
  return null;
38377
38550
  }
@@ -38413,7 +38586,7 @@ class GodotServer {
38413
38586
  return null;
38414
38587
  }
38415
38588
  try {
38416
- return readFileSync16(contained.absolutePath, "utf8");
38589
+ return readFileSync17(contained.absolutePath, "utf8");
38417
38590
  } catch {
38418
38591
  return null;
38419
38592
  }
@@ -38462,6 +38635,20 @@ class GodotServer {
38462
38635
  }
38463
38636
  return this.dapClient;
38464
38637
  }
38638
+ dapHoldingBreakpointsOf(projectPath) {
38639
+ const client = this.dap();
38640
+ if (this.breakpointsSeededOn !== client) {
38641
+ client.holdBreakpoints(readBreakpointNote(projectPath));
38642
+ this.breakpointsSeededOn = client;
38643
+ }
38644
+ return client;
38645
+ }
38646
+ breakpointsAsProjectSpells(projectPath, held) {
38647
+ return held.map((one) => ({
38648
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
38649
+ lines: one.lines
38650
+ }));
38651
+ }
38465
38652
  writeToTranscript(line) {
38466
38653
  const run = this.activeProcess;
38467
38654
  if (run?.throughEditor !== true || run.transcript === null) {
@@ -38551,8 +38738,12 @@ class GodotServer {
38551
38738
  async handleEditorStatus() {
38552
38739
  const located = await this.locator.find();
38553
38740
  const announced = runtimesAnnounced();
38554
- const games = await Promise.all(announced.running.map(async (endpoint) => {
38555
- const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
38741
+ const pinged = Promise.all(announced.running.map(async (endpoint) => {
38742
+ const held = this.knownHoldOn(endpoint);
38743
+ const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
38744
+ ok: false,
38745
+ 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.`
38746
+ };
38556
38747
  return {
38557
38748
  pid: endpoint.pid,
38558
38749
  port: endpoint.port,
@@ -38561,8 +38752,13 @@ class GodotServer {
38561
38752
  problem: reply.ok ? null : reply.message
38562
38753
  };
38563
38754
  }));
38564
- const playing = await this.editorPlayingState();
38565
- await this.pickUpWhatTheEditorIsPlaying();
38755
+ const asked = (async () => {
38756
+ const playing = await this.editorPlayingState();
38757
+ await this.pickUpWhatTheEditorIsPlaying();
38758
+ const current = this.currentRun();
38759
+ return { playing, hold: current === null ? {} : await this.holdOf(current) };
38760
+ })();
38761
+ const [games, { playing, hold }] = await Promise.all([pinged, asked]);
38566
38762
  return this.jsonTextResponse({
38567
38763
  editor: {
38568
38764
  ...this.getEditorStatusPayload(),
@@ -38576,6 +38772,7 @@ class GodotServer {
38576
38772
  game: {
38577
38773
  processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
38578
38774
  playingInEditor: playing,
38775
+ ...hold,
38579
38776
  runtimeConnected: games.some((game) => game.reachable),
38580
38777
  runtimes: games,
38581
38778
  unreadable: announced.unspoken.map((game) => ({
@@ -38593,7 +38790,7 @@ class GodotServer {
38593
38790
  return null;
38594
38791
  }
38595
38792
  try {
38596
- return settingKeys(readFileSync16(join18(projectPath, "project.godot"), "utf8"));
38793
+ return settingKeys(readFileSync17(join19(projectPath, "project.godot"), "utf8"));
38597
38794
  } catch {
38598
38795
  return null;
38599
38796
  }
@@ -38836,7 +39033,7 @@ class GodotServer {
38836
39033
  return runIsUp(run, editorSays);
38837
39034
  }
38838
39035
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
38839
- if (!existsSync14(join18(projectPath, RUNTIME_AUTOLOAD2.path))) {
39036
+ if (!existsSync14(join19(projectPath, RUNTIME_AUTOLOAD2.path))) {
38840
39037
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
38841
39038
  }
38842
39039
  const endpoint = await announcedSince(projectPath, before, {
@@ -38874,6 +39071,7 @@ class GodotServer {
38874
39071
  "Closing the other editor, or moving its debug adapter port in its editor settings, frees this one"
38875
39072
  ]);
38876
39073
  }
39074
+ const breakpoints = await this.dapHoldingBreakpointsOf(projectPath).reapplyBreakpoints();
38877
39075
  const answer = await this.handleViaBridge("play_scene", scene === null ? {} : { scenePath: `res://${scene}` });
38878
39076
  if (answer.isError === true) {
38879
39077
  return answer;
@@ -38903,6 +39101,11 @@ class GodotServer {
38903
39101
  refreshedClasses,
38904
39102
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
38905
39103
  endedPreviousRun: ended ?? undefined,
39104
+ breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
39105
+ breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
39106
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
39107
+ reason: one.reason
39108
+ })),
38906
39109
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
38907
39110
  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.`)
38908
39111
  });
@@ -39100,7 +39303,7 @@ class GodotServer {
39100
39303
  return;
39101
39304
  }
39102
39305
  try {
39103
- await this.dap().connect();
39306
+ await this.dap().attach();
39104
39307
  } catch (error) {
39105
39308
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
39106
39309
  }
@@ -39120,8 +39323,16 @@ class GodotServer {
39120
39323
  brokeOn: null,
39121
39324
  pickedUpPlaying: true
39122
39325
  };
39326
+ const announced = this.theOneAnnouncedForOurProject();
39327
+ if (announced !== undefined) {
39328
+ picked.announcedPid = announced.pid;
39329
+ }
39123
39330
  this.activeProcess = picked;
39124
39331
  }
39332
+ theOneAnnouncedForOurProject() {
39333
+ const ofProject = this.allAnnouncedForOurProject(runtimesAnnounced().running);
39334
+ return ofProject.length === 1 ? ofProject[0] : undefined;
39335
+ }
39125
39336
  adoptRecordedRun() {
39126
39337
  const record = readRunRecord();
39127
39338
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -39276,7 +39487,7 @@ class GodotServer {
39276
39487
  contains: readNonEmptyString(args, "contains"),
39277
39488
  limit: readPositiveNumber(args, "limit") ?? 200
39278
39489
  });
39279
- const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
39490
+ const hold = await this.holdOf(run);
39280
39491
  const wanted = readBoolean(args, "cpu") ?? false;
39281
39492
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
39282
39493
  const notes = [];
@@ -39313,7 +39524,7 @@ class GodotServer {
39313
39524
  warnings: run.log.count("warning"),
39314
39525
  clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
39315
39526
  consoleLost: run.consoleLost === true ? true : undefined,
39316
- heldAt: halt,
39527
+ ...hold,
39317
39528
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
39318
39529
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
39319
39530
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
@@ -39496,6 +39707,50 @@ class GodotServer {
39496
39707
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
39497
39708
  return Number.isInteger(override) && override > 0 ? override : 1e4;
39498
39709
  }
39710
+ knownHoldOn(endpoint) {
39711
+ const run = this.activeProcess;
39712
+ const session = this.dapClient;
39713
+ if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
39714
+ return null;
39715
+ }
39716
+ const its = run.announcedPid ?? this.theOneAnnouncedForOurProject()?.pid;
39717
+ if (its !== endpoint.pid || !session.holdIsKnown()) {
39718
+ return null;
39719
+ }
39720
+ return session.whereItStopped();
39721
+ }
39722
+ async holdOf(run) {
39723
+ if (!run.throughEditor || !stillRunning(run)) {
39724
+ return { heldAt: null };
39725
+ }
39726
+ const session = this.dapClient;
39727
+ if (session?.holdIsKnown()) {
39728
+ return { heldAt: session.whereItStopped() };
39729
+ }
39730
+ const endpoint = run.announcedPid !== undefined ? runtimesAnnounced().running.find((one) => one.pid === run.announcedPid) : this.theOneAnnouncedForOurProject();
39731
+ if (endpoint === undefined) {
39732
+ return {
39733
+ heldAt: undefined,
39734
+ heldUnknown: true,
39735
+ 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."
39736
+ };
39737
+ }
39738
+ const reply = await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS);
39739
+ if (reply.ok) {
39740
+ session?.learnedRunning();
39741
+ return { heldAt: session?.whereItStopped() ?? null };
39742
+ }
39743
+ if (reply.reason === "busy") {
39744
+ return {
39745
+ heldAt: {
39746
+ reason: "unanswered",
39747
+ 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`,
39748
+ text: ""
39749
+ }
39750
+ };
39751
+ }
39752
+ return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
39753
+ }
39499
39754
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
39500
39755
  const { op: _op, projectPath, ...params } = asParams(args);
39501
39756
  const announced = runtimesAnnounced();
@@ -39503,9 +39758,17 @@ class GodotServer {
39503
39758
  if ("problem" in choice) {
39504
39759
  return this.createErrorResponse(choice.problem);
39505
39760
  }
39761
+ const held = this.knownHoldOn(choice.endpoint);
39762
+ if (held !== null) {
39763
+ 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.`, [
39764
+ "debug_control continue lets it go, after which this call answers",
39765
+ "debug_state stack reads where it is held, and debug_state scopes what it is holding there",
39766
+ "editor_output says heldAt for the run, and editor_run stop ends it"
39767
+ ]);
39768
+ }
39506
39769
  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;
39770
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join19(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39771
+ const screenshotPath = screenshotDir ? join19(screenshotDir, "capture.png") : null;
39509
39772
  try {
39510
39773
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
39511
39774
  if (!reply.ok) {
@@ -39526,7 +39789,7 @@ class GodotServer {
39526
39789
  return {
39527
39790
  content: [
39528
39791
  { type: "text", text: `Screenshot captured: ${dimensions}` },
39529
- { type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
39792
+ { type: "image", data: readFileSync17(screenshotPath).toString("base64"), mimeType: "image/png" }
39530
39793
  ]
39531
39794
  };
39532
39795
  } finally {
@@ -39592,11 +39855,12 @@ async function runGodotServer() {
39592
39855
  const server = new GodotServer;
39593
39856
  await server.run();
39594
39857
  }
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/";
39858
+ 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
39859
  var init_server3 = __esm(() => {
39597
39860
  init_mcp();
39598
39861
  init_stdio2();
39599
39862
  init_types();
39863
+ init_breakpoint_note();
39600
39864
  init_bridge_announce();
39601
39865
  init_class_cache();
39602
39866
  init_config_pin();
@@ -39624,7 +39888,7 @@ var init_server3 = __esm(() => {
39624
39888
  init_tool_definitions();
39625
39889
  init_update_check();
39626
39890
  run6 = promisify6(execFile6);
39627
- __dirname2 = dirname10(fileURLToPath4(import.meta.url));
39891
+ __dirname2 = dirname11(fileURLToPath4(import.meta.url));
39628
39892
  VALUE_NAMES = {
39629
39893
  string: "a string",
39630
39894
  number: "a number",
@@ -39663,7 +39927,7 @@ var init_server3 = __esm(() => {
39663
39927
 
39664
39928
  // src/cli.ts
39665
39929
  import { existsSync as existsSync15 } from "fs";
39666
- import { join as join19, resolve as resolve5 } from "path";
39930
+ import { join as join20, resolve as resolve5 } from "path";
39667
39931
 
39668
39932
  // src/errors.ts
39669
39933
  class Refusal extends Error {
@@ -41231,8 +41495,10 @@ its own, which is the measured cure and needs no change to the declaring script;
41231
41495
  restart\` also does it and costs a window.
41232
41496
 
41233
41497
  The scan writes \`.godot/global_script_class_cache.cfg\` from the list the editor is holding, so
41234
- a class it cannot resolve is dropped out of that file too, and the next fresh engine, CI run or
41235
- clone starts from the narrower one. That is the same class \`unseenByEditor\` names.
41498
+ a class it cannot resolve would go out of that file with it. The answer names that loss under
41499
+ \`cacheLost\` and rebuilds the cache from the declarations on disk, naming what came back under
41500
+ \`cacheRestored\`, so no fresh engine, CI run or clone inherits the short file. That is the same
41501
+ class \`unseenByEditor\` names, and the loss is not a reason to refuse the scan.
41236
41502
 
41237
41503
  A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
41238
41504
  about something the engine compiles, is a different thing and is no longer stale here: each ask
@@ -41351,7 +41617,7 @@ class UsageError extends Refusal {
41351
41617
  function projectArgument(at) {
41352
41618
  const named = args.slice(at).find((value) => !value.startsWith("--"));
41353
41619
  const projectPath = resolve5(named ?? ".");
41354
- if (!existsSync15(join19(projectPath, "project.godot"))) {
41620
+ if (!existsSync15(join20(projectPath, "project.godot"))) {
41355
41621
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
41356
41622
  }
41357
41623
  return projectPath;
@@ -41390,7 +41656,7 @@ async function registerRuntime(godot, projectPath) {
41390
41656
  return;
41391
41657
  }
41392
41658
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
41393
- if (!existsSync15(join19(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41659
+ if (!existsSync15(join20(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41394
41660
  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}`);
41395
41661
  }
41396
41662
  }