gdharness 0.13.38 → 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 +396 -132
  2. package/build/index.js +477 -215
  3. package/package.json +1 -1
package/build/index.js CHANGED
@@ -10175,15 +10175,15 @@ import {
10175
10175
  closeSync,
10176
10176
  existsSync as existsSync9,
10177
10177
  fstatSync,
10178
- mkdirSync as mkdirSync6,
10178
+ mkdirSync as mkdirSync7,
10179
10179
  mkdtempSync as mkdtempSync2,
10180
10180
  openSync as openSync2,
10181
10181
  readdirSync as readdirSync6,
10182
- readFileSync as readFileSync12,
10182
+ readFileSync as readFileSync13,
10183
10183
  readSync
10184
10184
  } from "node:fs";
10185
10185
  import { tmpdir as tmpdir4 } from "node:os";
10186
- import { basename as basename2, dirname as dirname5, join as join12, normalize as normalize2 } from "node:path";
10186
+ import { basename as basename2, dirname as dirname6, join as join13, normalize as normalize2, relative as relative3 } from "node:path";
10187
10187
  import { setTimeout as delay3 } from "node:timers/promises";
10188
10188
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10189
10189
  import { promisify as promisify4 } from "node:util";
@@ -24751,16 +24751,135 @@ class StdioServerTransport {
24751
24751
  }
24752
24752
  }
24753
24753
 
24754
- // src/bridge-announce.ts
24754
+ // src/breakpoint-note.ts
24755
24755
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
24756
- import { dirname, join as join2 } from "node:path";
24756
+ import { dirname, isAbsolute as isAbsolute2, join as join2, relative as relative2 } from "node:path";
24757
+
24758
+ // src/paths.ts
24759
+ import { realpathSync } from "node:fs";
24760
+ import { isAbsolute, relative, resolve, sep } from "node:path";
24761
+ var RESOURCE_SCHEME = "res://";
24762
+ var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
24763
+ function realPathOr(path) {
24764
+ try {
24765
+ return realpathSync.native(path);
24766
+ } catch {
24767
+ return path;
24768
+ }
24769
+ }
24770
+ function isSameDirectory(onePath, otherPath) {
24771
+ return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
24772
+ }
24773
+ function isWithinRoot(rootPath, candidatePath) {
24774
+ const step = relative(resolve(rootPath), resolve(candidatePath));
24775
+ return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
24776
+ }
24777
+ function resolveWithinProject(projectPath, candidatePath) {
24778
+ const trimmed = candidatePath.trim();
24779
+ if (trimmed === "") {
24780
+ return { ok: false, reason: "Path is empty." };
24781
+ }
24782
+ if (trimmed.includes("\x00")) {
24783
+ return { ok: false, reason: "Path contains a null byte." };
24784
+ }
24785
+ const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
24786
+ const scheme = OTHER_SCHEME.exec(withoutScheme);
24787
+ if (scheme) {
24788
+ return {
24789
+ ok: false,
24790
+ reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
24791
+ };
24792
+ }
24793
+ if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
24794
+ return {
24795
+ ok: false,
24796
+ reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
24797
+ };
24798
+ }
24799
+ if (withoutScheme.includes(":")) {
24800
+ return {
24801
+ ok: false,
24802
+ reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
24803
+ };
24804
+ }
24805
+ const root = resolve(projectPath);
24806
+ const absolutePath = resolve(root, withoutScheme);
24807
+ if (!isWithinRoot(root, absolutePath)) {
24808
+ return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
24809
+ }
24810
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
24811
+ if (relativePath === "") {
24812
+ return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
24813
+ }
24814
+ return { ok: true, absolutePath, relativePath };
24815
+ }
24816
+
24817
+ // src/breakpoint-note.ts
24818
+ function breakpointNotePath(projectPath) {
24819
+ return join2(projectPath, ".godot", "gdharness-breakpoints.json");
24820
+ }
24821
+ function writeBreakpointNote(projectPath, held) {
24822
+ const path = breakpointNotePath(projectPath);
24823
+ const root = realPathOr(projectPath);
24824
+ const inside = held.map((one) => ({ scriptPath: realPathOr(one.scriptPath), lines: one.lines })).filter((one) => one.lines.length > 0 && withinProject(root, one.scriptPath));
24825
+ try {
24826
+ if (inside.length === 0) {
24827
+ rmSync(path, { force: true });
24828
+ return;
24829
+ }
24830
+ const note = {
24831
+ breakpoints: inside.map((one) => ({
24832
+ script: relative2(root, one.scriptPath).replaceAll("\\", "/"),
24833
+ lines: [...one.lines]
24834
+ }))
24835
+ };
24836
+ mkdirSync2(dirname(path), { recursive: true });
24837
+ writeFileSync2(path, `${JSON.stringify(note, null, 2)}
24838
+ `, "utf8");
24839
+ } catch (error) {
24840
+ console.error(`[SERVER] Could not record the breakpoints at ${path}: ${errorMessage(error)}`);
24841
+ }
24842
+ }
24843
+ function readBreakpointNote(projectPath) {
24844
+ try {
24845
+ const parsed = JSON.parse(readFileSync3(breakpointNotePath(projectPath), "utf8"));
24846
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.breakpoints)) {
24847
+ return [];
24848
+ }
24849
+ const held = [];
24850
+ for (const entry of parsed.breakpoints) {
24851
+ if (typeof entry !== "object" || entry === null) {
24852
+ continue;
24853
+ }
24854
+ const { script, lines: listed } = entry;
24855
+ if (typeof script !== "string" || isAbsolute2(script) || script.split("/").includes("..")) {
24856
+ continue;
24857
+ }
24858
+ const lines = Array.isArray(listed) ? listed.filter((line) => Number.isInteger(line) && line > 0) : [];
24859
+ if (lines.length > 0) {
24860
+ held.push({ scriptPath: realPathOr(join2(projectPath, script)), lines });
24861
+ }
24862
+ }
24863
+ return held;
24864
+ } catch {
24865
+ return [];
24866
+ }
24867
+ }
24868
+ function withinProject(projectPath, scriptPath) {
24869
+ const rel = relative2(projectPath, scriptPath);
24870
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
24871
+ }
24872
+
24873
+ // src/bridge-announce.ts
24874
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
24875
+ import { dirname as dirname2, join as join3 } from "node:path";
24757
24876
  function announcementPath(projectPath) {
24758
- return join2(projectPath, ".godot", "gdharness-bridge.json");
24877
+ return join3(projectPath, ".godot", "gdharness-bridge.json");
24759
24878
  }
24760
24879
  var BRIDGE_ANNOUNCE_PROTOCOL = 1;
24761
24880
  function readAnnouncement(path) {
24762
24881
  try {
24763
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
24882
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
24764
24883
  if (typeof parsed !== "object" || parsed === null) {
24765
24884
  return null;
24766
24885
  }
@@ -24779,7 +24898,7 @@ function announcementIsOurs(path) {
24779
24898
  function announceBridge(projectPath, bridge) {
24780
24899
  const path = announcementPath(projectPath);
24781
24900
  try {
24782
- mkdirSync2(dirname(path), { recursive: true });
24901
+ mkdirSync3(dirname2(path), { recursive: true });
24783
24902
  const announcement = {
24784
24903
  protocol: BRIDGE_ANNOUNCE_PROTOCOL,
24785
24904
  host: bridge.host,
@@ -24788,7 +24907,7 @@ function announceBridge(projectPath, bridge) {
24788
24907
  version: bridge.version,
24789
24908
  startedAt: new Date().toISOString()
24790
24909
  };
24791
- writeFileSync2(path, `${JSON.stringify(announcement, null, 2)}
24910
+ writeFileSync3(path, `${JSON.stringify(announcement, null, 2)}
24792
24911
  `, "utf8");
24793
24912
  return path;
24794
24913
  } catch (error) {
@@ -24801,31 +24920,31 @@ function withdrawBridge(path) {
24801
24920
  return;
24802
24921
  }
24803
24922
  try {
24804
- rmSync(path, { force: true });
24923
+ rmSync2(path, { force: true });
24805
24924
  } catch (error) {
24806
24925
  console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
24807
24926
  }
24808
24927
  }
24809
24928
 
24810
24929
  // src/class-cache.ts
24811
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
24812
- import { join as join3 } from "node:path";
24930
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync5, statSync } from "node:fs";
24931
+ import { join as join4 } from "node:path";
24813
24932
  var DECLARATION = /^(?:@[a-z_]+(?:\([^)\n]*\))?\s+)*class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m;
24814
24933
  function declaredClasses(projectPath) {
24815
24934
  const declared = new Map;
24816
24935
  const visit = (directory, prefix) => {
24817
- if (existsSync2(join3(directory, ".gdignore"))) {
24936
+ if (existsSync2(join4(directory, ".gdignore"))) {
24818
24937
  return;
24819
24938
  }
24820
24939
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
24821
24940
  if (entry.name.startsWith(".")) {
24822
24941
  continue;
24823
24942
  }
24824
- const path = join3(directory, entry.name);
24943
+ const path = join4(directory, entry.name);
24825
24944
  if (entry.isDirectory()) {
24826
24945
  visit(path, `${prefix}${entry.name}/`);
24827
24946
  } else if (entry.isFile() && entry.name.endsWith(".gd")) {
24828
- const found = DECLARATION.exec(readFileSync4(path, "utf8"));
24947
+ const found = DECLARATION.exec(readFileSync5(path, "utf8"));
24829
24948
  if (found?.[1]) {
24830
24949
  declared.set(found[1], `res://${prefix}${entry.name}`);
24831
24950
  }
@@ -24836,12 +24955,12 @@ function declaredClasses(projectPath) {
24836
24955
  return declared;
24837
24956
  }
24838
24957
  function cachedClasses(projectPath) {
24839
- const cache = join3(projectPath, ".godot", "global_script_class_cache.cfg");
24958
+ const cache = join4(projectPath, ".godot", "global_script_class_cache.cfg");
24840
24959
  if (!existsSync2(cache)) {
24841
24960
  return null;
24842
24961
  }
24843
24962
  const listed = new Map;
24844
- const text = readFileSync4(cache, "utf8");
24963
+ const text = readFileSync5(cache, "utf8");
24845
24964
  for (const entry of text.matchAll(/"class":\s*&"([^"]+)"[\s\S]*?"path":\s*"([^"]+)"/g)) {
24846
24965
  listed.set(entry[1] ?? "", entry[2] ?? "");
24847
24966
  }
@@ -24926,7 +25045,7 @@ function uncachedClassNote(types) {
24926
25045
  return `${types.join(", ")} ${one ? "is declared" : "are declared"} in this project and missing from ` + `.godot/global_script_class_cache.cfg, so a game launched now would not resolve ${one ? "it" : "them"} ` + "either. Try editor_rescan first: it asks the editor already running and starts nothing, and it " + "cleared this in 314ms in a second project with a 31-worker fan-out still importing. It is not " + "always enough, because a file another engine has already imported reads as settled and the " + "walk does not look inside it. project_import refresh_classes rewrites the cache from the " + "declarations on disk and does reach that case, at the cost of a short headless engine, which " + "is worth knowing when something else is already running against this project.";
24927
25046
  }
24928
25047
  function cacheWrittenAt(projectPath) {
24929
- const cache = join3(projectPath, ".godot", "global_script_class_cache.cfg");
25048
+ const cache = join4(projectPath, ".godot", "global_script_class_cache.cfg");
24930
25049
  try {
24931
25050
  return statSync(cache).mtimeMs;
24932
25051
  } catch {
@@ -24950,17 +25069,17 @@ function unseenByEditor(projectPath, editorHolds) {
24950
25069
  }
24951
25070
 
24952
25071
  // src/config-pin.ts
24953
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
24954
- import { join as join4 } from "node:path";
25072
+ import { existsSync as existsSync3, readFileSync as readFileSync6 } from "node:fs";
25073
+ import { join as join5 } from "node:path";
24955
25074
  var PINNED = /\bgdharness@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/g;
24956
25075
  function versionsNamedInConfig(projectPath) {
24957
- const config = join4(projectPath, ".mcp.json");
25076
+ const config = join5(projectPath, ".mcp.json");
24958
25077
  if (!existsSync3(config)) {
24959
25078
  return [];
24960
25079
  }
24961
25080
  let text;
24962
25081
  try {
24963
- text = readFileSync5(config, "utf8");
25082
+ text = readFileSync6(config, "utf8");
24964
25083
  JSON.parse(text);
24965
25084
  } catch {
24966
25085
  return [];
@@ -25127,6 +25246,7 @@ class GodotDAPClient {
25127
25246
  attached = false;
25128
25247
  lastThreadId = 1;
25129
25248
  halt = null;
25249
+ holdKnown = false;
25130
25250
  breakpoints = new Map;
25131
25251
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
25132
25252
  this.port = port;
@@ -25172,11 +25292,7 @@ class GodotDAPClient {
25172
25292
  this.failPendingRequests(error);
25173
25293
  });
25174
25294
  socket.on("close", () => {
25175
- this.connected = false;
25176
- this.initialized = false;
25177
- this.attached = false;
25178
- this.halt = null;
25179
- this.socket = null;
25295
+ this.forgetConnection();
25180
25296
  this.failPendingRequests(new Error("DAP connection closed"));
25181
25297
  });
25182
25298
  resolve();
@@ -25192,10 +25308,7 @@ class GodotDAPClient {
25192
25308
  }
25193
25309
  async abandon() {
25194
25310
  if (!this.socket) {
25195
- this.connected = false;
25196
- this.initialized = false;
25197
- this.attached = false;
25198
- this.halt = null;
25311
+ this.forgetConnection();
25199
25312
  return;
25200
25313
  }
25201
25314
  await new Promise((resolve) => {
@@ -25215,11 +25328,15 @@ class GodotDAPClient {
25215
25328
  resolve();
25216
25329
  }, 500);
25217
25330
  });
25331
+ this.forgetConnection();
25332
+ }
25333
+ forgetConnection() {
25218
25334
  this.socket = null;
25219
25335
  this.connected = false;
25220
25336
  this.initialized = false;
25221
25337
  this.attached = false;
25222
25338
  this.halt = null;
25339
+ this.holdKnown = false;
25223
25340
  }
25224
25341
  async ensureConnected() {
25225
25342
  if (!this.connected) {
@@ -25308,6 +25425,12 @@ class GodotDAPClient {
25308
25425
  description: said(body, "description"),
25309
25426
  text: said(body, "text")
25310
25427
  };
25428
+ this.holdKnown = true;
25429
+ return;
25430
+ }
25431
+ if (eventName === "continued") {
25432
+ this.halt = null;
25433
+ this.holdKnown = true;
25311
25434
  return;
25312
25435
  }
25313
25436
  if (eventName === "terminated" || eventName === "exited") {
@@ -25346,6 +25469,43 @@ class GodotDAPClient {
25346
25469
  await this.sendRequest("attach", {});
25347
25470
  await this.sendRequest("configurationDone", {});
25348
25471
  this.attached = true;
25472
+ if (!this.holdKnown) {
25473
+ await this.learnWhetherHeld();
25474
+ }
25475
+ }
25476
+ async learnWhetherHeld() {
25477
+ try {
25478
+ const threads = await this.sendRequest("threads");
25479
+ const listed = threads["threads"];
25480
+ const first = Array.isArray(listed) ? listed[0]?.["id"] : undefined;
25481
+ if (typeof first !== "number") {
25482
+ return;
25483
+ }
25484
+ this.lastThreadId = first;
25485
+ const response = await this.sendRequest("stackTrace", { threadId: first, startFrame: 0, levels: 1 });
25486
+ if (this.holdKnown) {
25487
+ return;
25488
+ }
25489
+ const frames = response["stackFrames"];
25490
+ if (Array.isArray(frames) && frames.length > 0) {
25491
+ this.halt = {
25492
+ reason: "attached",
25493
+ description: "held when this session attached; the stop that held it was reported to an earlier one",
25494
+ text: ""
25495
+ };
25496
+ this.holdKnown = true;
25497
+ }
25498
+ } catch {}
25499
+ }
25500
+ holdIsKnown() {
25501
+ return this.holdKnown;
25502
+ }
25503
+ learnedRunning() {
25504
+ if (this.holdKnown) {
25505
+ return;
25506
+ }
25507
+ this.halt = null;
25508
+ this.holdKnown = true;
25349
25509
  }
25350
25510
  setOutputSink(sink) {
25351
25511
  this.onOutput = sink;
@@ -25358,38 +25518,70 @@ class GodotDAPClient {
25358
25518
  return lines;
25359
25519
  }
25360
25520
  async setBreakpoint(filePath, line) {
25361
- await this.ensureConnected();
25362
- await this.initialize();
25363
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
25364
- fileBreakpoints.add(line);
25365
- this.breakpoints.set(filePath, fileBreakpoints);
25366
- const lines = Array.from(fileBreakpoints).sort((a, b) => a - b);
25367
- return await this.sendRequest("setBreakpoints", {
25368
- source: { path: filePath },
25369
- breakpoints: lines.map((breakpointLine) => ({ line: breakpointLine }))
25370
- });
25521
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
25522
+ wanted.add(line);
25523
+ return await this.sendBreakpoints(filePath, wanted);
25371
25524
  }
25372
25525
  async removeBreakpoint(filePath, line) {
25526
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
25527
+ wanted.delete(line);
25528
+ return await this.sendBreakpoints(filePath, wanted);
25529
+ }
25530
+ async sendBreakpoints(filePath, lines) {
25373
25531
  await this.ensureConnected();
25374
25532
  await this.initialize();
25375
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
25376
- fileBreakpoints.delete(line);
25377
- if (fileBreakpoints.size === 0) {
25533
+ const sorted = Array.from(lines).sort((a, b) => a - b);
25534
+ const answer = await this.sendRequest("setBreakpoints", {
25535
+ source: { path: filePath },
25536
+ breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
25537
+ });
25538
+ if (lines.size === 0) {
25378
25539
  this.breakpoints.delete(filePath);
25379
25540
  } else {
25380
- this.breakpoints.set(filePath, fileBreakpoints);
25541
+ this.breakpoints.set(filePath, new Set(lines));
25542
+ }
25543
+ return answer;
25544
+ }
25545
+ async reapplyBreakpoints() {
25546
+ const applied = [];
25547
+ const refused = [];
25548
+ for (const [filePath, lines] of Array.from(this.breakpoints)) {
25549
+ try {
25550
+ await this.sendBreakpoints(filePath, lines);
25551
+ applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
25552
+ } catch (error) {
25553
+ this.breakpoints.delete(filePath);
25554
+ refused.push({
25555
+ scriptPath: filePath,
25556
+ reason: error instanceof Error ? error.message : String(error)
25557
+ });
25558
+ }
25559
+ }
25560
+ return { applied, refused };
25561
+ }
25562
+ breakpointsHeld() {
25563
+ return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
25564
+ scriptPath,
25565
+ lines: Array.from(lines).sort((a, b) => a - b)
25566
+ }));
25567
+ }
25568
+ holdBreakpoints(held) {
25569
+ for (const { scriptPath, lines } of held) {
25570
+ const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
25571
+ for (const line of lines) {
25572
+ fileBreakpoints.add(line);
25573
+ }
25574
+ if (fileBreakpoints.size > 0) {
25575
+ this.breakpoints.set(scriptPath, fileBreakpoints);
25576
+ }
25381
25577
  }
25382
- const remaining = Array.from(fileBreakpoints).sort((a, b) => a - b);
25383
- return await this.sendRequest("setBreakpoints", {
25384
- source: { path: filePath },
25385
- breakpoints: remaining.map((breakpointLine) => ({ line: breakpointLine }))
25386
- });
25387
25578
  }
25388
25579
  async continue(threadId) {
25389
25580
  await this.attach();
25390
25581
  const resolvedThreadId = await this.resolveThreadId(threadId);
25391
25582
  await this.sendRequest("continue", { threadId: resolvedThreadId });
25392
25583
  this.halt = null;
25584
+ this.holdKnown = true;
25393
25585
  }
25394
25586
  async stepOver(threadId) {
25395
25587
  await this.attach();
@@ -25488,11 +25680,7 @@ class GodotDAPClient {
25488
25680
  }
25489
25681
  failOversizedStream(detail) {
25490
25682
  const socket = this.socket;
25491
- this.socket = null;
25492
- this.connected = false;
25493
- this.initialized = false;
25494
- this.attached = false;
25495
- this.halt = null;
25683
+ this.forgetConnection();
25496
25684
  this.reader = new FrameReader;
25497
25685
  socket?.destroy();
25498
25686
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -25715,65 +25903,6 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
25715
25903
  var import_websocket = __toESM(require_websocket(), 1);
25716
25904
  var import_websocket_server = __toESM(require_websocket_server(), 1);
25717
25905
 
25718
- // src/paths.ts
25719
- import { realpathSync } from "node:fs";
25720
- import { isAbsolute, relative, resolve, sep } from "node:path";
25721
- var RESOURCE_SCHEME = "res://";
25722
- var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
25723
- function realPathOr(path) {
25724
- try {
25725
- return realpathSync.native(path);
25726
- } catch {
25727
- return path;
25728
- }
25729
- }
25730
- function isSameDirectory(onePath, otherPath) {
25731
- return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
25732
- }
25733
- function isWithinRoot(rootPath, candidatePath) {
25734
- const step = relative(resolve(rootPath), resolve(candidatePath));
25735
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
25736
- }
25737
- function resolveWithinProject(projectPath, candidatePath) {
25738
- const trimmed = candidatePath.trim();
25739
- if (trimmed === "") {
25740
- return { ok: false, reason: "Path is empty." };
25741
- }
25742
- if (trimmed.includes("\x00")) {
25743
- return { ok: false, reason: "Path contains a null byte." };
25744
- }
25745
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
25746
- const scheme = OTHER_SCHEME.exec(withoutScheme);
25747
- if (scheme) {
25748
- return {
25749
- ok: false,
25750
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
25751
- };
25752
- }
25753
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
25754
- return {
25755
- ok: false,
25756
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
25757
- };
25758
- }
25759
- if (withoutScheme.includes(":")) {
25760
- return {
25761
- ok: false,
25762
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
25763
- };
25764
- }
25765
- const root = resolve(projectPath);
25766
- const absolutePath = resolve(root, withoutScheme);
25767
- if (!isWithinRoot(root, absolutePath)) {
25768
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
25769
- }
25770
- const relativePath = relative(root, absolutePath).split(sep).join("/");
25771
- if (relativePath === "") {
25772
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
25773
- }
25774
- return { ok: true, absolutePath, relativePath };
25775
- }
25776
-
25777
25906
  // src/godot-bridge.ts
25778
25907
  var DEFAULT_PORT = 6505;
25779
25908
  var DEFAULT_HOST = "127.0.0.1";
@@ -26265,7 +26394,7 @@ import { promisify } from "node:util";
26265
26394
  // src/detection.ts
26266
26395
  import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
26267
26396
  import { homedir as homedir2 } from "node:os";
26268
- import { join as join5 } from "node:path";
26397
+ import { join as join6 } from "node:path";
26269
26398
  function resolveHomeDirectory() {
26270
26399
  try {
26271
26400
  return homedir2();
@@ -26289,7 +26418,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
26289
26418
  if (!pattern.test(name)) {
26290
26419
  continue;
26291
26420
  }
26292
- const fullPath = join5(directory, name);
26421
+ const fullPath = join6(directory, name);
26293
26422
  try {
26294
26423
  const stat = statSync2(fullPath);
26295
26424
  if (stat.isFile()) {
@@ -26298,7 +26427,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
26298
26427
  } catch {}
26299
26428
  }
26300
26429
  matches.sort((a, b) => b.mtime - a.mtime);
26301
- return matches.map((m) => join5(directory, m.name));
26430
+ return matches.map((m) => join6(directory, m.name));
26302
26431
  }
26303
26432
  function conventionalPaths(platform, home) {
26304
26433
  const paths = ["godot"];
@@ -26467,13 +26596,13 @@ class GodotLocator {
26467
26596
 
26468
26597
  // src/headless.ts
26469
26598
  import { execFile as execFile2 } from "node:child_process";
26470
- import { mkdtempSync, writeFileSync as writeFileSync3 } from "node:fs";
26599
+ import { mkdtempSync, writeFileSync as writeFileSync4 } from "node:fs";
26471
26600
  import { tmpdir as tmpdir2 } from "node:os";
26472
- import { join as join6 } from "node:path";
26601
+ import { join as join7 } from "node:path";
26473
26602
  import { promisify as promisify2 } from "node:util";
26474
26603
 
26475
26604
  // src/scratch.ts
26476
- import { rmSync as rmSync2 } from "node:fs";
26605
+ import { rmSync as rmSync3 } from "node:fs";
26477
26606
  var SWEPT = { recursive: true, force: true, maxRetries: 20, retryDelay: 250 };
26478
26607
  function discardWith(remove, paths) {
26479
26608
  const left = [];
@@ -26491,7 +26620,7 @@ function discardWith(remove, paths) {
26491
26620
  }
26492
26621
  function discard(...paths) {
26493
26622
  return discardWith((path) => {
26494
- rmSync2(path, SWEPT);
26623
+ rmSync3(path, SWEPT);
26495
26624
  }, paths);
26496
26625
  }
26497
26626
 
@@ -26535,12 +26664,12 @@ function reason(stdout, stderr) {
26535
26664
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
26536
26665
  }
26537
26666
  async function runImport(godotPath, projectPath) {
26538
- const logDir = mkdtempSync(join6(tmpdir2(), "gdharness-import-"));
26667
+ const logDir = mkdtempSync(join7(tmpdir2(), "gdharness-import-"));
26539
26668
  try {
26540
26669
  const { stderr } = await run2(godotPath, [
26541
26670
  "--headless",
26542
26671
  "--log-file",
26543
- join6(logDir, "engine.log"),
26672
+ join7(logDir, "engine.log"),
26544
26673
  "--path",
26545
26674
  projectPath,
26546
26675
  "--import"
@@ -26565,13 +26694,13 @@ async function runImport(godotPath, projectPath) {
26565
26694
  }
26566
26695
  }
26567
26696
  async function runOperation(engine, operation, params, projectPath) {
26568
- const paramsDir = mkdtempSync(join6(tmpdir2(), "gdharness-params-"));
26569
- const paramsFile = join6(paramsDir, `${operation}.json`);
26570
- writeFileSync3(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26697
+ const paramsDir = mkdtempSync(join7(tmpdir2(), "gdharness-params-"));
26698
+ const paramsFile = join7(paramsDir, `${operation}.json`);
26699
+ writeFileSync4(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26571
26700
  const args = [
26572
26701
  "--headless",
26573
26702
  "--log-file",
26574
- join6(paramsDir, "engine.log"),
26703
+ join7(paramsDir, "engine.log"),
26575
26704
  "--path",
26576
26705
  projectPath,
26577
26706
  "--script",
@@ -27000,7 +27129,7 @@ function orphansPrinted(printed) {
27000
27129
  import { realpathSync as realpathSync2 } from "node:fs";
27001
27130
  import { readFile, realpath } from "node:fs/promises";
27002
27131
  import { createConnection as createConnection2 } from "node:net";
27003
- import { dirname as dirname2, resolve as resolve2 } from "node:path";
27132
+ import { dirname as dirname3, resolve as resolve2 } from "node:path";
27004
27133
  import { fileURLToPath, pathToFileURL } from "node:url";
27005
27134
  var DEFAULT_LSP_PORT = 6005;
27006
27135
  var DIAGNOSTICS_TIMEOUT_MS = 5000;
@@ -27130,7 +27259,7 @@ class GodotLSPClient {
27130
27259
  if (this.initialized) {
27131
27260
  return;
27132
27261
  }
27133
- const rootPath = this.rootPath ?? dirname2(resolve2(filePath));
27262
+ const rootPath = this.rootPath ?? dirname3(resolve2(filePath));
27134
27263
  await this.initialize(rootPath);
27135
27264
  }
27136
27265
  async sendRequest(method, params) {
@@ -27566,14 +27695,14 @@ function secondsFromClock(printed) {
27566
27695
  }
27567
27696
 
27568
27697
  // src/project-scan.ts
27569
- import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "node:fs";
27570
- import { join as join7 } from "node:path";
27698
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync7 } from "node:fs";
27699
+ import { join as join8 } from "node:path";
27571
27700
  var SKIPPED = new Set(["node_modules"]);
27572
27701
  function skipped(name) {
27573
27702
  return name.startsWith(".") || SKIPPED.has(name);
27574
27703
  }
27575
27704
  function steppedOver(directory) {
27576
- return existsSync6(join7(directory, ".gdignore"));
27705
+ return existsSync6(join8(directory, ".gdignore"));
27577
27706
  }
27578
27707
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
27579
27708
  function projectStructure(projectPath) {
@@ -27587,7 +27716,7 @@ function projectStructure(projectPath) {
27587
27716
  continue;
27588
27717
  }
27589
27718
  if (entry.isDirectory()) {
27590
- visit(join7(directory, entry.name));
27719
+ visit(join8(directory, entry.name));
27591
27720
  } else if (entry.isFile()) {
27592
27721
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
27593
27722
  if (extension === "tscn") {
@@ -27618,14 +27747,14 @@ function scriptsWithoutUid(projectPath) {
27618
27747
  }
27619
27748
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
27620
27749
  if (entry.isDirectory()) {
27621
- visit(join7(directory, entry.name), spelled);
27750
+ visit(join8(directory, entry.name), spelled);
27622
27751
  continue;
27623
27752
  }
27624
27753
  if (!entry.isFile()) {
27625
27754
  continue;
27626
27755
  }
27627
27756
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
27628
- if (CARRIES_A_UID.has(extension) && !existsSync6(join7(directory, `${entry.name}.uid`))) {
27757
+ if (CARRIES_A_UID.has(extension) && !existsSync6(join8(directory, `${entry.name}.uid`))) {
27629
27758
  found.push(spelled);
27630
27759
  }
27631
27760
  }
@@ -27660,7 +27789,7 @@ function searchProject(projectPath, options) {
27660
27789
  if (skipped(entry.name)) {
27661
27790
  continue;
27662
27791
  }
27663
- const entryPath = join7(directory, entry.name);
27792
+ const entryPath = join8(directory, entry.name);
27664
27793
  if (entry.isDirectory()) {
27665
27794
  visit(entryPath);
27666
27795
  continue;
@@ -27671,7 +27800,7 @@ function searchProject(projectPath, options) {
27671
27800
  }
27672
27801
  result.summary.files_searched += 1;
27673
27802
  const matches = [];
27674
- for (const [index, line] of readFileSync6(entryPath, "utf8").split(`
27803
+ for (const [index, line] of readFileSync7(entryPath, "utf8").split(`
27675
27804
  `).entries()) {
27676
27805
  if (full()) {
27677
27806
  break;
@@ -27694,7 +27823,7 @@ function searchProject(projectPath, options) {
27694
27823
  }
27695
27824
 
27696
27825
  // src/resources.ts
27697
- import { readFileSync as readFileSync7 } from "node:fs";
27826
+ import { readFileSync as readFileSync8 } from "node:fs";
27698
27827
  import { extname, resolve as resolve3 } from "node:path";
27699
27828
  var STATIC_RESOURCES = [
27700
27829
  {
@@ -27889,7 +28018,7 @@ function readResourceText(uri, getProjectPath) {
27889
28018
  const parsedUri = parseGodotUri(uri);
27890
28019
  if (parsedUri.kind === "project-info") {
27891
28020
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
27892
- const rawProject = readFileSync7(projectFilePath, "utf-8");
28021
+ const rawProject = readFileSync8(projectFilePath, "utf-8");
27893
28022
  const parsedProject = parseProjectGodot(rawProject);
27894
28023
  return {
27895
28024
  mimeType: "application/json",
@@ -27898,7 +28027,7 @@ function readResourceText(uri, getProjectPath) {
27898
28027
  }
27899
28028
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
27900
28029
  ensureAllowedExtension(parsedUri.kind, filePath);
27901
- const text = readFileSync7(filePath, "utf-8");
28030
+ const text = readFileSync8(filePath, "utf-8");
27902
28031
  return {
27903
28032
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
27904
28033
  text
@@ -27935,16 +28064,16 @@ function setupResourceHandlers(mcp, getProjectPath) {
27935
28064
  }
27936
28065
 
27937
28066
  // src/restart-note.ts
27938
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27939
- import { dirname as dirname3, join as join8 } from "node:path";
28067
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync9, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
28068
+ import { dirname as dirname4, join as join9 } from "node:path";
27940
28069
  function restartNotePath(projectPath) {
27941
- return join8(projectPath, ".godot", "gdharness-restart.json");
28070
+ return join9(projectPath, ".godot", "gdharness-restart.json");
27942
28071
  }
27943
28072
  function noteRestartBegun(note) {
27944
28073
  const path = restartNotePath(note.projectPath);
27945
28074
  try {
27946
- mkdirSync3(dirname3(path), { recursive: true });
27947
- writeFileSync4(path, `${JSON.stringify(note, null, 2)}
28075
+ mkdirSync4(dirname4(path), { recursive: true });
28076
+ writeFileSync5(path, `${JSON.stringify(note, null, 2)}
27948
28077
  `, "utf8");
27949
28078
  } catch (error) {
27950
28079
  console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
@@ -27952,7 +28081,7 @@ function noteRestartBegun(note) {
27952
28081
  }
27953
28082
  function restartOwed(projectPath) {
27954
28083
  try {
27955
- const parsed = JSON.parse(readFileSync8(restartNotePath(projectPath), "utf8"));
28084
+ const parsed = JSON.parse(readFileSync9(restartNotePath(projectPath), "utf8"));
27956
28085
  if (typeof parsed !== "object" || parsed === null) {
27957
28086
  return null;
27958
28087
  }
@@ -27967,7 +28096,7 @@ function restartOwed(projectPath) {
27967
28096
  }
27968
28097
  function restartSettled(projectPath) {
27969
28098
  try {
27970
- rmSync3(restartNotePath(projectPath), { force: true });
28099
+ rmSync4(restartNotePath(projectPath), { force: true });
27971
28100
  } catch (error) {
27972
28101
  console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
27973
28102
  }
@@ -27975,14 +28104,14 @@ function restartSettled(projectPath) {
27975
28104
 
27976
28105
  // src/run-record.ts
27977
28106
  import { execFileSync } from "node:child_process";
27978
- import { mkdirSync as mkdirSync4, openSync, readdirSync as readdirSync5, readFileSync as readFileSync10, rmSync as rmSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
27979
- import { basename, join as join10 } from "node:path";
28107
+ import { mkdirSync as mkdirSync5, openSync, readdirSync as readdirSync5, readFileSync as readFileSync11, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync6 } from "node:fs";
28108
+ import { basename, join as join11 } from "node:path";
27980
28109
 
27981
28110
  // src/runtime-client.ts
27982
- import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync9, unlinkSync } from "node:fs";
28111
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync10, unlinkSync } from "node:fs";
27983
28112
  import { createConnection as createConnection3 } from "node:net";
27984
28113
  import { tmpdir as tmpdir3 } from "node:os";
27985
- import { join as join9, resolve as resolve4 } from "node:path";
28114
+ import { join as join10, resolve as resolve4 } from "node:path";
27986
28115
  import { setTimeout as delay2 } from "node:timers/promises";
27987
28116
 
27988
28117
  // src/tool-args.ts
@@ -28050,13 +28179,13 @@ function runtimeDirectory(variables = process.env) {
28050
28179
  return explicit;
28051
28180
  }
28052
28181
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
28053
- return join9(perUser ?? tmpdir3(), "gdharness");
28182
+ return join10(perUser ?? tmpdir3(), "gdharness");
28054
28183
  }
28055
28184
  function runtimeDirectories(variables = process.env) {
28056
28185
  const candidates = [runtimeDirectory(variables)];
28057
28186
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
28058
28187
  for (const base of fallbacks) {
28059
- candidates.push(join9(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
28188
+ candidates.push(join10(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
28060
28189
  }
28061
28190
  return [...new Set(candidates.map((path) => resolve4(path)))];
28062
28191
  }
@@ -28071,7 +28200,7 @@ function processAlive(pid) {
28071
28200
  function parseAnnouncement(file, pid) {
28072
28201
  let fields;
28073
28202
  try {
28074
- fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
28203
+ fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
28075
28204
  } catch {
28076
28205
  return { kind: "rubbish" };
28077
28206
  }
@@ -28115,7 +28244,7 @@ var gone = [];
28115
28244
  function wentAway(file, pid) {
28116
28245
  let project = "";
28117
28246
  try {
28118
- const fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
28247
+ const fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
28119
28248
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
28120
28249
  } catch {
28121
28250
  return;
@@ -28144,7 +28273,7 @@ function announcedIn(directory) {
28144
28273
  if (!match) {
28145
28274
  continue;
28146
28275
  }
28147
- const file = join9(directory, entry);
28276
+ const file = join10(directory, entry);
28148
28277
  const pid = Number.parseInt(match[1] ?? "", 10);
28149
28278
  const alive = processAlive(pid);
28150
28279
  const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
@@ -28319,33 +28448,33 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
28319
28448
 
28320
28449
  // src/run-record.ts
28321
28450
  function runsDirectory() {
28322
- return join10(runtimeDirectory(), "runs");
28451
+ return join11(runtimeDirectory(), "runs");
28323
28452
  }
28324
28453
  function recordPath() {
28325
- return join10(runsDirectory(), "run.json");
28454
+ return join11(runsDirectory(), "run.json");
28326
28455
  }
28327
28456
  function recordPaths() {
28328
- return runtimeDirectories().map((directory) => join10(directory, "runs", "run.json"));
28457
+ return runtimeDirectories().map((directory) => join11(directory, "runs", "run.json"));
28329
28458
  }
28330
28459
  function openTranscript(startedAt) {
28331
- mkdirSync4(runsDirectory(), { recursive: true });
28332
- const path = join10(runsDirectory(), `run-${startedAt}.log`);
28460
+ mkdirSync5(runsDirectory(), { recursive: true });
28461
+ const path = join11(runsDirectory(), `run-${startedAt}.log`);
28333
28462
  return { path, fd: openSync(path, "a") };
28334
28463
  }
28335
28464
  function writeRunRecord(record) {
28336
- mkdirSync4(runsDirectory(), { recursive: true });
28337
- writeFileSync5(recordPath(), JSON.stringify(record, null, 2), "utf8");
28465
+ mkdirSync5(runsDirectory(), { recursive: true });
28466
+ writeFileSync6(recordPath(), JSON.stringify(record, null, 2), "utf8");
28338
28467
  }
28339
28468
  function editorNotePath() {
28340
- return join10(runsDirectory(), "editor-run.json");
28469
+ return join11(runsDirectory(), "editor-run.json");
28341
28470
  }
28342
28471
  function writeEditorRunNote(note) {
28343
- mkdirSync4(runsDirectory(), { recursive: true });
28344
- writeFileSync5(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28472
+ mkdirSync5(runsDirectory(), { recursive: true });
28473
+ writeFileSync6(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28345
28474
  }
28346
28475
  function readEditorRunNote() {
28347
28476
  for (const directory of runtimeDirectories()) {
28348
- const note = editorNoteAt(join10(directory, "runs", "editor-run.json"));
28477
+ const note = editorNoteAt(join11(directory, "runs", "editor-run.json"));
28349
28478
  if (note !== null) {
28350
28479
  return note;
28351
28480
  }
@@ -28355,7 +28484,7 @@ function readEditorRunNote() {
28355
28484
  function editorNoteAt(path) {
28356
28485
  let parsed;
28357
28486
  try {
28358
- parsed = JSON.parse(readFileSync10(path, "utf8"));
28487
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
28359
28488
  } catch {
28360
28489
  return null;
28361
28490
  }
@@ -28378,7 +28507,7 @@ function recordRunEnded(pid, exitCode) {
28378
28507
  return;
28379
28508
  }
28380
28509
  try {
28381
- writeFileSync5(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28510
+ writeFileSync6(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28382
28511
  } catch {}
28383
28512
  }
28384
28513
  function readRunRecord() {
@@ -28393,7 +28522,7 @@ function readRunRecord() {
28393
28522
  function recordAt(path) {
28394
28523
  let parsed;
28395
28524
  try {
28396
- parsed = JSON.parse(readFileSync10(path, "utf8"));
28525
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
28397
28526
  } catch {
28398
28527
  return null;
28399
28528
  }
@@ -28438,7 +28567,7 @@ function runningAs(pid) {
28438
28567
  const began = startedAt(pid, null);
28439
28568
  const when = began === null ? {} : { startedAt: began };
28440
28569
  if (process.platform === "linux") {
28441
- const raw = readFileSync10(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28570
+ const raw = readFileSync11(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28442
28571
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
28443
28572
  }
28444
28573
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -28560,7 +28689,7 @@ function clearRunRecord(ours = () => true) {
28560
28689
  continue;
28561
28690
  }
28562
28691
  try {
28563
- rmSync4(path);
28692
+ rmSync5(path);
28564
28693
  } catch {}
28565
28694
  }
28566
28695
  }
@@ -28584,19 +28713,19 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
28584
28713
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
28585
28714
  continue;
28586
28715
  }
28587
- const path = join10(directory, entry);
28716
+ const path = join11(directory, entry);
28588
28717
  if (path === keep || ageMs(path, now) < keepMs) {
28589
28718
  continue;
28590
28719
  }
28591
28720
  try {
28592
- rmSync4(path);
28721
+ rmSync5(path);
28593
28722
  } catch {}
28594
28723
  }
28595
28724
  }
28596
28725
 
28597
28726
  // src/setup.ts
28598
- import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "node:fs";
28599
- import { dirname as dirname4, join as join11 } from "node:path";
28727
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
28728
+ import { dirname as dirname5, join as join12 } from "node:path";
28600
28729
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
28601
28730
  var RUNTIME_AUTOLOAD = {
28602
28731
  name: "GdharnessRuntime",
@@ -28604,11 +28733,11 @@ var RUNTIME_AUTOLOAD = {
28604
28733
  };
28605
28734
  var VERSION_MARKER = ".gdharness-version";
28606
28735
  function installedAddonVersion(projectPath) {
28607
- const marker = join11(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28736
+ const marker = join12(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28608
28737
  if (!existsSync8(marker)) {
28609
28738
  return null;
28610
28739
  }
28611
- const said = readFileSync11(marker, "utf8").trim();
28740
+ const said = readFileSync12(marker, "utf8").trim();
28612
28741
  return said === "" ? null : said;
28613
28742
  }
28614
28743
 
@@ -29274,7 +29403,7 @@ var TOOL_SPECS = [
29274
29403
  },
29275
29404
  {
29276
29405
  name: "editor_output",
29277
- 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.",
29406
+ 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.",
29278
29407
  parameters: {
29279
29408
  severity: {
29280
29409
  type: "string",
@@ -29302,7 +29431,7 @@ var TOOL_SPECS = [
29302
29431
  },
29303
29432
  {
29304
29433
  name: "editor_status",
29305
- 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.',
29434
+ 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.",
29306
29435
  parameters: {},
29307
29436
  requires: []
29308
29437
  },
@@ -29627,7 +29756,7 @@ var TOOL_SPECS = [
29627
29756
  },
29628
29757
  {
29629
29758
  name: "debug_breakpoint",
29630
- 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.",
29759
+ 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.",
29631
29760
  parameters: {
29632
29761
  projectPath: PROJECT_PATH,
29633
29762
  scriptPath: SCRIPT_PATH,
@@ -29652,7 +29781,7 @@ var TOOL_SPECS = [
29652
29781
  },
29653
29782
  {
29654
29783
  name: "debug_state",
29655
- 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.",
29784
+ 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.",
29656
29785
  parameters: {
29657
29786
  frameId: {
29658
29787
  type: "number",
@@ -29719,12 +29848,30 @@ function buildToolDefinitions() {
29719
29848
  var UPDATE_NOTICE_EVERY = 500;
29720
29849
  var FEEDBACK_NOTICE_EVERY = 250;
29721
29850
  var run4 = promisify4(execFile4);
29722
- var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
29851
+ var __dirname2 = dirname6(fileURLToPath2(import.meta.url));
29723
29852
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
29853
+ var HOLD_PING_MS = 2000;
29724
29854
  var SLOWEST_FRAME_RATE = 20;
29725
29855
  function patienceForFrames(frames, atLeast) {
29726
29856
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
29727
29857
  }
29858
+ function projectSpelling(projectPath, absolutePath) {
29859
+ return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
29860
+ }
29861
+ function describeHalt(halt) {
29862
+ switch (halt.reason) {
29863
+ case "breakpoint":
29864
+ return "at a breakpoint";
29865
+ case "step":
29866
+ return "after a step";
29867
+ case "exception":
29868
+ return halt.text === "" ? "on an error" : `on an error: ${halt.text}`;
29869
+ case "attached":
29870
+ return "held when this session attached";
29871
+ default:
29872
+ return `stopped with reason "${halt.reason}"`;
29873
+ }
29874
+ }
29728
29875
  function stillRunning(run) {
29729
29876
  if (run?.exitCode !== null || run.endedUnwatched === true) {
29730
29877
  return false;
@@ -29915,7 +30062,7 @@ function aboveTheRunner(entry) {
29915
30062
  }
29916
30063
  function elsewhereIn(projectPath, asked) {
29917
30064
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
29918
- if (wanted === "" || existsSync9(join12(projectPath, wanted))) {
30065
+ if (wanted === "" || existsSync9(join13(projectPath, wanted))) {
29919
30066
  return "";
29920
30067
  }
29921
30068
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -29929,11 +30076,11 @@ function elsewhereIn(projectPath, asked) {
29929
30076
  return near.length === 0 ? "" : ` The project does have ${there}.`;
29930
30077
  }
29931
30078
  function hasMainScene(projectFile) {
29932
- const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
30079
+ const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
29933
30080
  return typeof scene === "string" && scene !== "";
29934
30081
  }
29935
30082
  function editorPlaysHeadless(projectFile) {
29936
- const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
30083
+ const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29937
30084
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
29938
30085
  }
29939
30086
  function camelCased(params) {
@@ -29949,7 +30096,7 @@ function camelCased(params) {
29949
30096
  class GodotServer {
29950
30097
  mcp;
29951
30098
  locator = new GodotLocator;
29952
- operationsScript = join12(__dirname2, "godot", "operations", "godot_operations.gd");
30099
+ operationsScript = join13(__dirname2, "godot", "operations", "godot_operations.gd");
29953
30100
  godotBridge;
29954
30101
  tools = buildToolDefinitions();
29955
30102
  activeProcess = null;
@@ -29961,6 +30108,7 @@ class GodotServer {
29961
30108
  lastGodotVersion = null;
29962
30109
  lspClient = null;
29963
30110
  dapClient = null;
30111
+ breakpointsSeededOn = null;
29964
30112
  bridgeStartupError = null;
29965
30113
  bridgeRetry = null;
29966
30114
  successorWatch = null;
@@ -30559,17 +30707,28 @@ class GodotServer {
30559
30707
  if (!located.ok) {
30560
30708
  return this.createErrorResponse(located.reason, PATH_SOLUTIONS);
30561
30709
  }
30562
- return await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
30710
+ const session = this.dapHoldingBreakpointsOf(project.value.path);
30711
+ const answered = await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
30563
30712
  ...args,
30564
30713
  scriptPath: realPathOr(located.absolutePath)
30565
30714
  });
30715
+ if (answered.isError === true) {
30716
+ return answered;
30717
+ }
30718
+ const held = session.breakpointsHeld();
30719
+ writeBreakpointNote(project.value.path, held);
30720
+ return this.jsonTextResponse({
30721
+ ...asParams(JSON.parse(answered.content[0]?.text ?? "{}")),
30722
+ held: this.breakpointsAsProjectSpells(project.value.path, held),
30723
+ 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."
30724
+ });
30566
30725
  }
30567
30726
  case "debug_control":
30568
30727
  case "debug_state": {
30569
30728
  if (tool === "debug_state" && op === "output") {
30570
30729
  return await this.handleDAP("dap_get_output", args);
30571
30730
  }
30572
- const held = await this.debuggedGame();
30731
+ const held = await this.debuggedGame(tool === "debug_control" ? "move" : "read");
30573
30732
  if (!held.ok) {
30574
30733
  return held.response;
30575
30734
  }
@@ -30592,7 +30751,7 @@ class GodotServer {
30592
30751
  if (path === undefined) {
30593
30752
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
30594
30753
  }
30595
- const file = join12(path, "project.godot");
30754
+ const file = join13(path, "project.godot");
30596
30755
  if (!existsSync9(file)) {
30597
30756
  return {
30598
30757
  ok: false,
@@ -30618,7 +30777,7 @@ class GodotServer {
30618
30777
  }
30619
30778
  return { ok: true, value: given };
30620
30779
  }
30621
- async debuggedGame() {
30780
+ async debuggedGame(intent) {
30622
30781
  const game = this.activeProcess;
30623
30782
  if (game !== null && game.exitCode !== null) {
30624
30783
  return {
@@ -30656,6 +30815,19 @@ class GodotServer {
30656
30815
  };
30657
30816
  }
30658
30817
  if (!this.dap().isStopped()) {
30818
+ const hold = await this.holdOf(game);
30819
+ if (hold.heldAt !== null) {
30820
+ if (intent === "move") {
30821
+ return { ok: true, value: game };
30822
+ }
30823
+ return {
30824
+ ok: false,
30825
+ 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}.`, [
30826
+ "debug_control continue lets a held game go, and the next stop is one this session is told about",
30827
+ "editor_output says what it printed on the way to where it is"
30828
+ ])
30829
+ };
30830
+ }
30659
30831
  return {
30660
30832
  ok: false,
30661
30833
  response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
@@ -30793,7 +30965,7 @@ class GodotServer {
30793
30965
  if (!engine.ok) {
30794
30966
  return engine.response;
30795
30967
  }
30796
- const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
30968
+ const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
30797
30969
  const name = application["config/name"];
30798
30970
  const mainScene = application["run/main_scene"];
30799
30971
  const info = {
@@ -30854,18 +31026,18 @@ class GodotServer {
30854
31026
  if (!engine.ok) {
30855
31027
  return engine.response;
30856
31028
  }
30857
- const folder = dirname5(output.absolutePath);
31029
+ const folder = dirname6(output.absolutePath);
30858
31030
  try {
30859
- mkdirSync6(folder, { recursive: true });
31031
+ mkdirSync7(folder, { recursive: true });
30860
31032
  } catch (error) {
30861
- return this.createErrorResponse(`Cannot create ${dirname5(output.relativePath)} to export into: ${errorMessage(error)}`);
31033
+ return this.createErrorResponse(`Cannot create ${dirname6(output.relativePath)} to export into: ${errorMessage(error)}`);
30862
31034
  }
30863
31035
  const debug = readBoolean(args, "debug") ?? false;
30864
- const exportLogs = mkdtempSync2(join12(tmpdir4(), "gdharness-export-"));
31036
+ const exportLogs = mkdtempSync2(join13(tmpdir4(), "gdharness-export-"));
30865
31037
  const exportArgs = [
30866
31038
  "--headless",
30867
31039
  "--log-file",
30868
- join12(exportLogs, "engine.log"),
31040
+ join13(exportLogs, "engine.log"),
30869
31041
  "--path",
30870
31042
  project.value.path,
30871
31043
  debug ? "--export-debug" : "--export-release",
@@ -30923,7 +31095,7 @@ class GodotServer {
30923
31095
  return contained.response;
30924
31096
  }
30925
31097
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
30926
- if (!existsSync9(join12(project.value.path, runner))) {
31098
+ if (!existsSync9(join13(project.value.path, runner))) {
30927
31099
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
30928
31100
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
30929
31101
  ]);
@@ -30958,7 +31130,7 @@ class GodotServer {
30958
31130
  ];
30959
31131
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
30960
31132
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
30961
- const userData = mkdtempSync2(join12(tmpdir4(), "gdharness-tests-"));
31133
+ const userData = mkdtempSync2(join13(tmpdir4(), "gdharness-tests-"));
30962
31134
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
30963
31135
  const hung = await new Promise((resolve) => {
30964
31136
  const timer = setTimeout(() => {
@@ -30974,14 +31146,14 @@ class GodotServer {
30974
31146
  resolve(false);
30975
31147
  });
30976
31148
  });
30977
- const reportsDir = join12(project.value.path, ".godot", ...ours.split("/"));
31149
+ const reportsDir = join13(project.value.path, ".godot", ...ours.split("/"));
30978
31150
  let report = null;
30979
31151
  let reportProblem = null;
30980
31152
  try {
30981
31153
  const written = existsSync9(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
30982
31154
  const newest = written.at(-1);
30983
31155
  if (newest !== undefined) {
30984
- report = parseJUnit(readFileSync12(join12(reportsDir, newest, "results.xml"), "utf8"));
31156
+ report = parseJUnit(readFileSync13(join13(reportsDir, newest, "results.xml"), "utf8"));
30985
31157
  }
30986
31158
  } catch (error) {
30987
31159
  reportProblem = errorMessage(error);
@@ -31121,7 +31293,7 @@ class GodotServer {
31121
31293
  return null;
31122
31294
  }
31123
31295
  try {
31124
- return readFileSync12(contained.absolutePath, "utf8");
31296
+ return readFileSync13(contained.absolutePath, "utf8");
31125
31297
  } catch {
31126
31298
  return null;
31127
31299
  }
@@ -31163,7 +31335,7 @@ class GodotServer {
31163
31335
  return null;
31164
31336
  }
31165
31337
  try {
31166
- return readFileSync12(contained.absolutePath, "utf8");
31338
+ return readFileSync13(contained.absolutePath, "utf8");
31167
31339
  } catch {
31168
31340
  return null;
31169
31341
  }
@@ -31212,6 +31384,20 @@ class GodotServer {
31212
31384
  }
31213
31385
  return this.dapClient;
31214
31386
  }
31387
+ dapHoldingBreakpointsOf(projectPath) {
31388
+ const client = this.dap();
31389
+ if (this.breakpointsSeededOn !== client) {
31390
+ client.holdBreakpoints(readBreakpointNote(projectPath));
31391
+ this.breakpointsSeededOn = client;
31392
+ }
31393
+ return client;
31394
+ }
31395
+ breakpointsAsProjectSpells(projectPath, held) {
31396
+ return held.map((one) => ({
31397
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
31398
+ lines: one.lines
31399
+ }));
31400
+ }
31215
31401
  writeToTranscript(line) {
31216
31402
  const run = this.activeProcess;
31217
31403
  if (run?.throughEditor !== true || run.transcript === null) {
@@ -31301,8 +31487,12 @@ class GodotServer {
31301
31487
  async handleEditorStatus() {
31302
31488
  const located = await this.locator.find();
31303
31489
  const announced = runtimesAnnounced();
31304
- const games = await Promise.all(announced.running.map(async (endpoint) => {
31305
- const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
31490
+ const pinged = Promise.all(announced.running.map(async (endpoint) => {
31491
+ const held = this.knownHoldOn(endpoint);
31492
+ const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
31493
+ ok: false,
31494
+ 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.`
31495
+ };
31306
31496
  return {
31307
31497
  pid: endpoint.pid,
31308
31498
  port: endpoint.port,
@@ -31311,8 +31501,13 @@ class GodotServer {
31311
31501
  problem: reply.ok ? null : reply.message
31312
31502
  };
31313
31503
  }));
31314
- const playing = await this.editorPlayingState();
31315
- await this.pickUpWhatTheEditorIsPlaying();
31504
+ const asked = (async () => {
31505
+ const playing = await this.editorPlayingState();
31506
+ await this.pickUpWhatTheEditorIsPlaying();
31507
+ const current = this.currentRun();
31508
+ return { playing, hold: current === null ? {} : await this.holdOf(current) };
31509
+ })();
31510
+ const [games, { playing, hold }] = await Promise.all([pinged, asked]);
31316
31511
  return this.jsonTextResponse({
31317
31512
  editor: {
31318
31513
  ...this.getEditorStatusPayload(),
@@ -31326,6 +31521,7 @@ class GodotServer {
31326
31521
  game: {
31327
31522
  processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
31328
31523
  playingInEditor: playing,
31524
+ ...hold,
31329
31525
  runtimeConnected: games.some((game) => game.reachable),
31330
31526
  runtimes: games,
31331
31527
  unreadable: announced.unspoken.map((game) => ({
@@ -31343,7 +31539,7 @@ class GodotServer {
31343
31539
  return null;
31344
31540
  }
31345
31541
  try {
31346
- return settingKeys(readFileSync12(join12(projectPath, "project.godot"), "utf8"));
31542
+ return settingKeys(readFileSync13(join13(projectPath, "project.godot"), "utf8"));
31347
31543
  } catch {
31348
31544
  return null;
31349
31545
  }
@@ -31586,7 +31782,7 @@ class GodotServer {
31586
31782
  return runIsUp(run, editorSays);
31587
31783
  }
31588
31784
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
31589
- if (!existsSync9(join12(projectPath, RUNTIME_AUTOLOAD.path))) {
31785
+ if (!existsSync9(join13(projectPath, RUNTIME_AUTOLOAD.path))) {
31590
31786
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
31591
31787
  }
31592
31788
  const endpoint = await announcedSince(projectPath, before, {
@@ -31624,6 +31820,7 @@ class GodotServer {
31624
31820
  "Closing the other editor, or moving its debug adapter port in its editor settings, frees this one"
31625
31821
  ]);
31626
31822
  }
31823
+ const breakpoints = await this.dapHoldingBreakpointsOf(projectPath).reapplyBreakpoints();
31627
31824
  const answer = await this.handleViaBridge("play_scene", scene === null ? {} : { scenePath: `res://${scene}` });
31628
31825
  if (answer.isError === true) {
31629
31826
  return answer;
@@ -31653,6 +31850,11 @@ class GodotServer {
31653
31850
  refreshedClasses,
31654
31851
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
31655
31852
  endedPreviousRun: ended ?? undefined,
31853
+ breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
31854
+ breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
31855
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
31856
+ reason: one.reason
31857
+ })),
31656
31858
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
31657
31859
  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.`)
31658
31860
  });
@@ -31850,7 +32052,7 @@ class GodotServer {
31850
32052
  return;
31851
32053
  }
31852
32054
  try {
31853
- await this.dap().connect();
32055
+ await this.dap().attach();
31854
32056
  } catch (error) {
31855
32057
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
31856
32058
  }
@@ -31870,8 +32072,16 @@ class GodotServer {
31870
32072
  brokeOn: null,
31871
32073
  pickedUpPlaying: true
31872
32074
  };
32075
+ const announced = this.theOneAnnouncedForOurProject();
32076
+ if (announced !== undefined) {
32077
+ picked.announcedPid = announced.pid;
32078
+ }
31873
32079
  this.activeProcess = picked;
31874
32080
  }
32081
+ theOneAnnouncedForOurProject() {
32082
+ const ofProject = this.allAnnouncedForOurProject(runtimesAnnounced().running);
32083
+ return ofProject.length === 1 ? ofProject[0] : undefined;
32084
+ }
31875
32085
  adoptRecordedRun() {
31876
32086
  const record = readRunRecord();
31877
32087
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -32026,7 +32236,7 @@ class GodotServer {
32026
32236
  contains: readNonEmptyString(args, "contains"),
32027
32237
  limit: readPositiveNumber(args, "limit") ?? 200
32028
32238
  });
32029
- const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
32239
+ const hold = await this.holdOf(run);
32030
32240
  const wanted = readBoolean(args, "cpu") ?? false;
32031
32241
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
32032
32242
  const notes = [];
@@ -32063,7 +32273,7 @@ class GodotServer {
32063
32273
  warnings: run.log.count("warning"),
32064
32274
  clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
32065
32275
  consoleLost: run.consoleLost === true ? true : undefined,
32066
- heldAt: halt,
32276
+ ...hold,
32067
32277
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
32068
32278
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
32069
32279
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
@@ -32246,6 +32456,50 @@ class GodotServer {
32246
32456
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
32247
32457
  return Number.isInteger(override) && override > 0 ? override : 1e4;
32248
32458
  }
32459
+ knownHoldOn(endpoint) {
32460
+ const run = this.activeProcess;
32461
+ const session = this.dapClient;
32462
+ if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
32463
+ return null;
32464
+ }
32465
+ const its = run.announcedPid ?? this.theOneAnnouncedForOurProject()?.pid;
32466
+ if (its !== endpoint.pid || !session.holdIsKnown()) {
32467
+ return null;
32468
+ }
32469
+ return session.whereItStopped();
32470
+ }
32471
+ async holdOf(run) {
32472
+ if (!run.throughEditor || !stillRunning(run)) {
32473
+ return { heldAt: null };
32474
+ }
32475
+ const session = this.dapClient;
32476
+ if (session?.holdIsKnown()) {
32477
+ return { heldAt: session.whereItStopped() };
32478
+ }
32479
+ const endpoint = run.announcedPid !== undefined ? runtimesAnnounced().running.find((one) => one.pid === run.announcedPid) : this.theOneAnnouncedForOurProject();
32480
+ if (endpoint === undefined) {
32481
+ return {
32482
+ heldAt: undefined,
32483
+ heldUnknown: true,
32484
+ 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."
32485
+ };
32486
+ }
32487
+ const reply = await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS);
32488
+ if (reply.ok) {
32489
+ session?.learnedRunning();
32490
+ return { heldAt: session?.whereItStopped() ?? null };
32491
+ }
32492
+ if (reply.reason === "busy") {
32493
+ return {
32494
+ heldAt: {
32495
+ reason: "unanswered",
32496
+ 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`,
32497
+ text: ""
32498
+ }
32499
+ };
32500
+ }
32501
+ return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
32502
+ }
32249
32503
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
32250
32504
  const { op: _op, projectPath, ...params } = asParams(args);
32251
32505
  const announced = runtimesAnnounced();
@@ -32253,9 +32507,17 @@ class GodotServer {
32253
32507
  if ("problem" in choice) {
32254
32508
  return this.createErrorResponse(choice.problem);
32255
32509
  }
32510
+ const held = this.knownHoldOn(choice.endpoint);
32511
+ if (held !== null) {
32512
+ 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.`, [
32513
+ "debug_control continue lets it go, after which this call answers",
32514
+ "debug_state stack reads where it is held, and debug_state scopes what it is holding there",
32515
+ "editor_output says heldAt for the run, and editor_run stop ends it"
32516
+ ]);
32517
+ }
32256
32518
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
32257
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join12(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
32258
- const screenshotPath = screenshotDir ? join12(screenshotDir, "capture.png") : null;
32519
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join13(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
32520
+ const screenshotPath = screenshotDir ? join13(screenshotDir, "capture.png") : null;
32259
32521
  try {
32260
32522
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
32261
32523
  if (!reply.ok) {
@@ -32276,7 +32538,7 @@ class GodotServer {
32276
32538
  return {
32277
32539
  content: [
32278
32540
  { type: "text", text: `Screenshot captured: ${dimensions}` },
32279
- { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
32541
+ { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
32280
32542
  ]
32281
32543
  };
32282
32544
  } finally {