gdharness 0.13.38 → 0.13.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/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,7 +25246,10 @@ class GodotDAPClient {
25127
25246
  attached = false;
25128
25247
  lastThreadId = 1;
25129
25248
  halt = null;
25249
+ holdKnown = false;
25130
25250
  breakpoints = new Map;
25251
+ inEditor = new Map;
25252
+ onBreakpointsChanged = null;
25131
25253
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
25132
25254
  this.port = port;
25133
25255
  this.host = host;
@@ -25172,11 +25294,7 @@ class GodotDAPClient {
25172
25294
  this.failPendingRequests(error);
25173
25295
  });
25174
25296
  socket.on("close", () => {
25175
- this.connected = false;
25176
- this.initialized = false;
25177
- this.attached = false;
25178
- this.halt = null;
25179
- this.socket = null;
25297
+ this.forgetConnection();
25180
25298
  this.failPendingRequests(new Error("DAP connection closed"));
25181
25299
  });
25182
25300
  resolve();
@@ -25192,10 +25310,7 @@ class GodotDAPClient {
25192
25310
  }
25193
25311
  async abandon() {
25194
25312
  if (!this.socket) {
25195
- this.connected = false;
25196
- this.initialized = false;
25197
- this.attached = false;
25198
- this.halt = null;
25313
+ this.forgetConnection();
25199
25314
  return;
25200
25315
  }
25201
25316
  await new Promise((resolve) => {
@@ -25215,11 +25330,16 @@ class GodotDAPClient {
25215
25330
  resolve();
25216
25331
  }, 500);
25217
25332
  });
25333
+ this.forgetConnection();
25334
+ }
25335
+ forgetConnection() {
25218
25336
  this.socket = null;
25219
25337
  this.connected = false;
25220
25338
  this.initialized = false;
25221
25339
  this.attached = false;
25222
25340
  this.halt = null;
25341
+ this.holdKnown = false;
25342
+ this.inEditor.clear();
25223
25343
  }
25224
25344
  async ensureConnected() {
25225
25345
  if (!this.connected) {
@@ -25308,6 +25428,37 @@ class GodotDAPClient {
25308
25428
  description: said(body, "description"),
25309
25429
  text: said(body, "text")
25310
25430
  };
25431
+ this.holdKnown = true;
25432
+ return;
25433
+ }
25434
+ if (eventName === "continued") {
25435
+ this.halt = null;
25436
+ this.holdKnown = true;
25437
+ return;
25438
+ }
25439
+ if (eventName === "breakpoint") {
25440
+ const body = event.body;
25441
+ const breakpoint = body?.["breakpoint"];
25442
+ const source = typeof breakpoint === "object" && breakpoint !== null ? breakpoint["source"] : undefined;
25443
+ const path = typeof source === "object" && source !== null ? source["path"] : undefined;
25444
+ const line = typeof breakpoint === "object" && breakpoint !== null ? breakpoint["line"] : undefined;
25445
+ if (typeof path !== "string" || typeof line !== "number") {
25446
+ return;
25447
+ }
25448
+ if (said(body, "reason") === "removed") {
25449
+ this.inEditor.get(path)?.delete(line);
25450
+ const held = this.breakpoints.get(path);
25451
+ if (held?.delete(line) === true) {
25452
+ if (held.size === 0) {
25453
+ this.breakpoints.delete(path);
25454
+ }
25455
+ this.onBreakpointsChanged?.(this.breakpointsHeld());
25456
+ }
25457
+ return;
25458
+ }
25459
+ const known = this.inEditor.get(path) ?? new Set;
25460
+ known.add(line);
25461
+ this.inEditor.set(path, known);
25311
25462
  return;
25312
25463
  }
25313
25464
  if (eventName === "terminated" || eventName === "exited") {
@@ -25346,6 +25497,43 @@ class GodotDAPClient {
25346
25497
  await this.sendRequest("attach", {});
25347
25498
  await this.sendRequest("configurationDone", {});
25348
25499
  this.attached = true;
25500
+ if (!this.holdKnown) {
25501
+ await this.learnWhetherHeld();
25502
+ }
25503
+ }
25504
+ async learnWhetherHeld() {
25505
+ try {
25506
+ const threads = await this.sendRequest("threads");
25507
+ const listed = threads["threads"];
25508
+ const first = Array.isArray(listed) ? listed[0]?.["id"] : undefined;
25509
+ if (typeof first !== "number") {
25510
+ return;
25511
+ }
25512
+ this.lastThreadId = first;
25513
+ const response = await this.sendRequest("stackTrace", { threadId: first, startFrame: 0, levels: 1 });
25514
+ if (this.holdKnown) {
25515
+ return;
25516
+ }
25517
+ const frames = response["stackFrames"];
25518
+ if (Array.isArray(frames) && frames.length > 0) {
25519
+ this.halt = {
25520
+ reason: "attached",
25521
+ description: "held when this session attached; the stop that held it was reported to an earlier one",
25522
+ text: ""
25523
+ };
25524
+ this.holdKnown = true;
25525
+ }
25526
+ } catch {}
25527
+ }
25528
+ holdIsKnown() {
25529
+ return this.holdKnown;
25530
+ }
25531
+ learnedRunning() {
25532
+ if (this.holdKnown) {
25533
+ return;
25534
+ }
25535
+ this.halt = null;
25536
+ this.holdKnown = true;
25349
25537
  }
25350
25538
  setOutputSink(sink) {
25351
25539
  this.onOutput = sink;
@@ -25358,38 +25546,96 @@ class GodotDAPClient {
25358
25546
  return lines;
25359
25547
  }
25360
25548
  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
- });
25549
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
25550
+ wanted.add(line);
25551
+ return await this.sendBreakpoints(filePath, wanted);
25371
25552
  }
25372
25553
  async removeBreakpoint(filePath, line) {
25554
+ const wanted = new Set(this.breakpoints.get(filePath) ?? []);
25555
+ wanted.delete(line);
25556
+ return await this.sendBreakpoints(filePath, wanted, line);
25557
+ }
25558
+ async sendBreakpoints(filePath, mine, clearing) {
25373
25559
  await this.ensureConnected();
25374
25560
  await this.initialize();
25375
- const fileBreakpoints = this.breakpoints.get(filePath) ?? new Set;
25376
- fileBreakpoints.delete(line);
25377
- if (fileBreakpoints.size === 0) {
25378
- this.breakpoints.delete(filePath);
25379
- } else {
25380
- this.breakpoints.set(filePath, fileBreakpoints);
25561
+ const lines = new Set([...mine, ...this.inEditor.get(filePath) ?? []]);
25562
+ if (clearing !== undefined) {
25563
+ lines.delete(clearing);
25381
25564
  }
25382
- const remaining = Array.from(fileBreakpoints).sort((a, b) => a - b);
25383
- return await this.sendRequest("setBreakpoints", {
25565
+ const sorted = Array.from(lines).sort((a, b) => a - b);
25566
+ const answer = await this.sendRequest("setBreakpoints", {
25384
25567
  source: { path: filePath },
25385
- breakpoints: remaining.map((breakpointLine) => ({ line: breakpointLine }))
25568
+ breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
25386
25569
  });
25570
+ if (mine.size === 0) {
25571
+ this.breakpoints.delete(filePath);
25572
+ } else {
25573
+ this.breakpoints.set(filePath, new Set(mine));
25574
+ }
25575
+ if (lines.size === 0) {
25576
+ this.inEditor.delete(filePath);
25577
+ } else {
25578
+ this.inEditor.set(filePath, lines);
25579
+ }
25580
+ return answer;
25581
+ }
25582
+ setBreakpointsSink(sink) {
25583
+ this.onBreakpointsChanged = sink;
25584
+ }
25585
+ breakpointsInEditor() {
25586
+ const theirs = [];
25587
+ for (const [scriptPath, lines] of this.inEditor) {
25588
+ const mine = this.breakpoints.get(scriptPath);
25589
+ const rest = Array.from(lines).filter((line) => !mine?.has(line)).sort((a, b) => a - b);
25590
+ if (rest.length > 0) {
25591
+ theirs.push({ scriptPath, lines: rest });
25592
+ }
25593
+ }
25594
+ return theirs;
25595
+ }
25596
+ async reapplyBreakpoints() {
25597
+ const applied = [];
25598
+ const refused = [];
25599
+ for (const [filePath, lines] of Array.from(this.breakpoints)) {
25600
+ try {
25601
+ await this.sendBreakpoints(filePath, lines);
25602
+ applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
25603
+ } catch (error) {
25604
+ this.breakpoints.delete(filePath);
25605
+ refused.push({
25606
+ scriptPath: filePath,
25607
+ reason: error instanceof Error ? error.message : String(error)
25608
+ });
25609
+ }
25610
+ }
25611
+ if (refused.length > 0) {
25612
+ this.onBreakpointsChanged?.(this.breakpointsHeld());
25613
+ }
25614
+ return { applied, refused };
25615
+ }
25616
+ breakpointsHeld() {
25617
+ return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
25618
+ scriptPath,
25619
+ lines: Array.from(lines).sort((a, b) => a - b)
25620
+ }));
25621
+ }
25622
+ holdBreakpoints(held) {
25623
+ for (const { scriptPath, lines } of held) {
25624
+ const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
25625
+ for (const line of lines) {
25626
+ fileBreakpoints.add(line);
25627
+ }
25628
+ if (fileBreakpoints.size > 0) {
25629
+ this.breakpoints.set(scriptPath, fileBreakpoints);
25630
+ }
25631
+ }
25387
25632
  }
25388
25633
  async continue(threadId) {
25389
25634
  await this.attach();
25390
25635
  const resolvedThreadId = await this.resolveThreadId(threadId);
25391
25636
  await this.sendRequest("continue", { threadId: resolvedThreadId });
25392
25637
  this.halt = null;
25638
+ this.holdKnown = true;
25393
25639
  }
25394
25640
  async stepOver(threadId) {
25395
25641
  await this.attach();
@@ -25488,11 +25734,7 @@ class GodotDAPClient {
25488
25734
  }
25489
25735
  failOversizedStream(detail) {
25490
25736
  const socket = this.socket;
25491
- this.socket = null;
25492
- this.connected = false;
25493
- this.initialized = false;
25494
- this.attached = false;
25495
- this.halt = null;
25737
+ this.forgetConnection();
25496
25738
  this.reader = new FrameReader;
25497
25739
  socket?.destroy();
25498
25740
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -25571,14 +25813,10 @@ async function handleDAPTool(client, toolName, args) {
25571
25813
  }
25572
25814
  } catch (error) {
25573
25815
  const message = error instanceof Error ? error.message : String(error);
25816
+ const cure = toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
25574
25817
  return {
25575
25818
  isError: true,
25576
- content: [
25577
- {
25578
- type: "text",
25579
- text: `DAP tool '${toolName}' failed: ${message}. ` + "The debug tools answer for a game the editor is playing: start one with editor_run."
25580
- }
25581
- ]
25819
+ content: [{ type: "text", text: `DAP tool '${toolName}' failed: ${message}. ${cure}` }]
25582
25820
  };
25583
25821
  }
25584
25822
  }
@@ -25715,65 +25953,6 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
25715
25953
  var import_websocket = __toESM(require_websocket(), 1);
25716
25954
  var import_websocket_server = __toESM(require_websocket_server(), 1);
25717
25955
 
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
25956
  // src/godot-bridge.ts
25778
25957
  var DEFAULT_PORT = 6505;
25779
25958
  var DEFAULT_HOST = "127.0.0.1";
@@ -25942,6 +26121,7 @@ class GodotBridge extends EventEmitter {
25942
26121
  dapPort: this.connectionInfo?.dapPort,
25943
26122
  debugPort: this.connectionInfo?.debugPort,
25944
26123
  openedByAServer: this.connectionInfo?.openedByAServer,
26124
+ syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
25945
26125
  pendingRequests: this.pendingRequests.size,
25946
26126
  queuedResources: this.resourceQueues.size,
25947
26127
  listeningSince: this.listeningSince ?? undefined
@@ -26084,6 +26264,7 @@ class GodotBridge extends EventEmitter {
26084
26264
  this.connectionInfo.dapPort = servedPort(message.dap_port);
26085
26265
  this.connectionInfo.debugPort = servedPort(message.debug_port);
26086
26266
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
26267
+ this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
26087
26268
  this.log("info", `Godot ready: ${message.project_path}`);
26088
26269
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
26089
26270
  }
@@ -26265,7 +26446,7 @@ import { promisify } from "node:util";
26265
26446
  // src/detection.ts
26266
26447
  import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
26267
26448
  import { homedir as homedir2 } from "node:os";
26268
- import { join as join5 } from "node:path";
26449
+ import { join as join6 } from "node:path";
26269
26450
  function resolveHomeDirectory() {
26270
26451
  try {
26271
26452
  return homedir2();
@@ -26289,7 +26470,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
26289
26470
  if (!pattern.test(name)) {
26290
26471
  continue;
26291
26472
  }
26292
- const fullPath = join5(directory, name);
26473
+ const fullPath = join6(directory, name);
26293
26474
  try {
26294
26475
  const stat = statSync2(fullPath);
26295
26476
  if (stat.isFile()) {
@@ -26298,7 +26479,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
26298
26479
  } catch {}
26299
26480
  }
26300
26481
  matches.sort((a, b) => b.mtime - a.mtime);
26301
- return matches.map((m) => join5(directory, m.name));
26482
+ return matches.map((m) => join6(directory, m.name));
26302
26483
  }
26303
26484
  function conventionalPaths(platform, home) {
26304
26485
  const paths = ["godot"];
@@ -26467,13 +26648,13 @@ class GodotLocator {
26467
26648
 
26468
26649
  // src/headless.ts
26469
26650
  import { execFile as execFile2 } from "node:child_process";
26470
- import { mkdtempSync, writeFileSync as writeFileSync3 } from "node:fs";
26651
+ import { mkdtempSync, writeFileSync as writeFileSync4 } from "node:fs";
26471
26652
  import { tmpdir as tmpdir2 } from "node:os";
26472
- import { join as join6 } from "node:path";
26653
+ import { join as join7 } from "node:path";
26473
26654
  import { promisify as promisify2 } from "node:util";
26474
26655
 
26475
26656
  // src/scratch.ts
26476
- import { rmSync as rmSync2 } from "node:fs";
26657
+ import { rmSync as rmSync3 } from "node:fs";
26477
26658
  var SWEPT = { recursive: true, force: true, maxRetries: 20, retryDelay: 250 };
26478
26659
  function discardWith(remove, paths) {
26479
26660
  const left = [];
@@ -26491,7 +26672,7 @@ function discardWith(remove, paths) {
26491
26672
  }
26492
26673
  function discard(...paths) {
26493
26674
  return discardWith((path) => {
26494
- rmSync2(path, SWEPT);
26675
+ rmSync3(path, SWEPT);
26495
26676
  }, paths);
26496
26677
  }
26497
26678
 
@@ -26535,12 +26716,12 @@ function reason(stdout, stderr) {
26535
26716
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
26536
26717
  }
26537
26718
  async function runImport(godotPath, projectPath) {
26538
- const logDir = mkdtempSync(join6(tmpdir2(), "gdharness-import-"));
26719
+ const logDir = mkdtempSync(join7(tmpdir2(), "gdharness-import-"));
26539
26720
  try {
26540
26721
  const { stderr } = await run2(godotPath, [
26541
26722
  "--headless",
26542
26723
  "--log-file",
26543
- join6(logDir, "engine.log"),
26724
+ join7(logDir, "engine.log"),
26544
26725
  "--path",
26545
26726
  projectPath,
26546
26727
  "--import"
@@ -26565,13 +26746,13 @@ async function runImport(godotPath, projectPath) {
26565
26746
  }
26566
26747
  }
26567
26748
  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");
26749
+ const paramsDir = mkdtempSync(join7(tmpdir2(), "gdharness-params-"));
26750
+ const paramsFile = join7(paramsDir, `${operation}.json`);
26751
+ writeFileSync4(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26571
26752
  const args = [
26572
26753
  "--headless",
26573
26754
  "--log-file",
26574
- join6(paramsDir, "engine.log"),
26755
+ join7(paramsDir, "engine.log"),
26575
26756
  "--path",
26576
26757
  projectPath,
26577
26758
  "--script",
@@ -27000,7 +27181,7 @@ function orphansPrinted(printed) {
27000
27181
  import { realpathSync as realpathSync2 } from "node:fs";
27001
27182
  import { readFile, realpath } from "node:fs/promises";
27002
27183
  import { createConnection as createConnection2 } from "node:net";
27003
- import { dirname as dirname2, resolve as resolve2 } from "node:path";
27184
+ import { dirname as dirname3, resolve as resolve2 } from "node:path";
27004
27185
  import { fileURLToPath, pathToFileURL } from "node:url";
27005
27186
  var DEFAULT_LSP_PORT = 6005;
27006
27187
  var DIAGNOSTICS_TIMEOUT_MS = 5000;
@@ -27130,7 +27311,7 @@ class GodotLSPClient {
27130
27311
  if (this.initialized) {
27131
27312
  return;
27132
27313
  }
27133
- const rootPath = this.rootPath ?? dirname2(resolve2(filePath));
27314
+ const rootPath = this.rootPath ?? dirname3(resolve2(filePath));
27134
27315
  await this.initialize(rootPath);
27135
27316
  }
27136
27317
  async sendRequest(method, params) {
@@ -27566,14 +27747,14 @@ function secondsFromClock(printed) {
27566
27747
  }
27567
27748
 
27568
27749
  // 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";
27750
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync7 } from "node:fs";
27751
+ import { join as join8 } from "node:path";
27571
27752
  var SKIPPED = new Set(["node_modules"]);
27572
27753
  function skipped(name) {
27573
27754
  return name.startsWith(".") || SKIPPED.has(name);
27574
27755
  }
27575
27756
  function steppedOver(directory) {
27576
- return existsSync6(join7(directory, ".gdignore"));
27757
+ return existsSync6(join8(directory, ".gdignore"));
27577
27758
  }
27578
27759
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
27579
27760
  function projectStructure(projectPath) {
@@ -27587,7 +27768,7 @@ function projectStructure(projectPath) {
27587
27768
  continue;
27588
27769
  }
27589
27770
  if (entry.isDirectory()) {
27590
- visit(join7(directory, entry.name));
27771
+ visit(join8(directory, entry.name));
27591
27772
  } else if (entry.isFile()) {
27592
27773
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
27593
27774
  if (extension === "tscn") {
@@ -27618,14 +27799,14 @@ function scriptsWithoutUid(projectPath) {
27618
27799
  }
27619
27800
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
27620
27801
  if (entry.isDirectory()) {
27621
- visit(join7(directory, entry.name), spelled);
27802
+ visit(join8(directory, entry.name), spelled);
27622
27803
  continue;
27623
27804
  }
27624
27805
  if (!entry.isFile()) {
27625
27806
  continue;
27626
27807
  }
27627
27808
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
27628
- if (CARRIES_A_UID.has(extension) && !existsSync6(join7(directory, `${entry.name}.uid`))) {
27809
+ if (CARRIES_A_UID.has(extension) && !existsSync6(join8(directory, `${entry.name}.uid`))) {
27629
27810
  found.push(spelled);
27630
27811
  }
27631
27812
  }
@@ -27660,7 +27841,7 @@ function searchProject(projectPath, options) {
27660
27841
  if (skipped(entry.name)) {
27661
27842
  continue;
27662
27843
  }
27663
- const entryPath = join7(directory, entry.name);
27844
+ const entryPath = join8(directory, entry.name);
27664
27845
  if (entry.isDirectory()) {
27665
27846
  visit(entryPath);
27666
27847
  continue;
@@ -27671,7 +27852,7 @@ function searchProject(projectPath, options) {
27671
27852
  }
27672
27853
  result.summary.files_searched += 1;
27673
27854
  const matches = [];
27674
- for (const [index, line] of readFileSync6(entryPath, "utf8").split(`
27855
+ for (const [index, line] of readFileSync7(entryPath, "utf8").split(`
27675
27856
  `).entries()) {
27676
27857
  if (full()) {
27677
27858
  break;
@@ -27694,7 +27875,7 @@ function searchProject(projectPath, options) {
27694
27875
  }
27695
27876
 
27696
27877
  // src/resources.ts
27697
- import { readFileSync as readFileSync7 } from "node:fs";
27878
+ import { readFileSync as readFileSync8 } from "node:fs";
27698
27879
  import { extname, resolve as resolve3 } from "node:path";
27699
27880
  var STATIC_RESOURCES = [
27700
27881
  {
@@ -27889,7 +28070,7 @@ function readResourceText(uri, getProjectPath) {
27889
28070
  const parsedUri = parseGodotUri(uri);
27890
28071
  if (parsedUri.kind === "project-info") {
27891
28072
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
27892
- const rawProject = readFileSync7(projectFilePath, "utf-8");
28073
+ const rawProject = readFileSync8(projectFilePath, "utf-8");
27893
28074
  const parsedProject = parseProjectGodot(rawProject);
27894
28075
  return {
27895
28076
  mimeType: "application/json",
@@ -27898,7 +28079,7 @@ function readResourceText(uri, getProjectPath) {
27898
28079
  }
27899
28080
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
27900
28081
  ensureAllowedExtension(parsedUri.kind, filePath);
27901
- const text = readFileSync7(filePath, "utf-8");
28082
+ const text = readFileSync8(filePath, "utf-8");
27902
28083
  return {
27903
28084
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
27904
28085
  text
@@ -27935,16 +28116,16 @@ function setupResourceHandlers(mcp, getProjectPath) {
27935
28116
  }
27936
28117
 
27937
28118
  // 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";
28119
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync9, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
28120
+ import { dirname as dirname4, join as join9 } from "node:path";
27940
28121
  function restartNotePath(projectPath) {
27941
- return join8(projectPath, ".godot", "gdharness-restart.json");
28122
+ return join9(projectPath, ".godot", "gdharness-restart.json");
27942
28123
  }
27943
28124
  function noteRestartBegun(note) {
27944
28125
  const path = restartNotePath(note.projectPath);
27945
28126
  try {
27946
- mkdirSync3(dirname3(path), { recursive: true });
27947
- writeFileSync4(path, `${JSON.stringify(note, null, 2)}
28127
+ mkdirSync4(dirname4(path), { recursive: true });
28128
+ writeFileSync5(path, `${JSON.stringify(note, null, 2)}
27948
28129
  `, "utf8");
27949
28130
  } catch (error) {
27950
28131
  console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
@@ -27952,7 +28133,7 @@ function noteRestartBegun(note) {
27952
28133
  }
27953
28134
  function restartOwed(projectPath) {
27954
28135
  try {
27955
- const parsed = JSON.parse(readFileSync8(restartNotePath(projectPath), "utf8"));
28136
+ const parsed = JSON.parse(readFileSync9(restartNotePath(projectPath), "utf8"));
27956
28137
  if (typeof parsed !== "object" || parsed === null) {
27957
28138
  return null;
27958
28139
  }
@@ -27967,7 +28148,7 @@ function restartOwed(projectPath) {
27967
28148
  }
27968
28149
  function restartSettled(projectPath) {
27969
28150
  try {
27970
- rmSync3(restartNotePath(projectPath), { force: true });
28151
+ rmSync4(restartNotePath(projectPath), { force: true });
27971
28152
  } catch (error) {
27972
28153
  console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
27973
28154
  }
@@ -27975,14 +28156,14 @@ function restartSettled(projectPath) {
27975
28156
 
27976
28157
  // src/run-record.ts
27977
28158
  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";
28159
+ import { mkdirSync as mkdirSync5, openSync, readdirSync as readdirSync5, readFileSync as readFileSync11, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync6 } from "node:fs";
28160
+ import { basename, join as join11 } from "node:path";
27980
28161
 
27981
28162
  // src/runtime-client.ts
27982
- import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync9, unlinkSync } from "node:fs";
28163
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync10, unlinkSync } from "node:fs";
27983
28164
  import { createConnection as createConnection3 } from "node:net";
27984
28165
  import { tmpdir as tmpdir3 } from "node:os";
27985
- import { join as join9, resolve as resolve4 } from "node:path";
28166
+ import { join as join10, resolve as resolve4 } from "node:path";
27986
28167
  import { setTimeout as delay2 } from "node:timers/promises";
27987
28168
 
27988
28169
  // src/tool-args.ts
@@ -28050,13 +28231,13 @@ function runtimeDirectory(variables = process.env) {
28050
28231
  return explicit;
28051
28232
  }
28052
28233
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
28053
- return join9(perUser ?? tmpdir3(), "gdharness");
28234
+ return join10(perUser ?? tmpdir3(), "gdharness");
28054
28235
  }
28055
28236
  function runtimeDirectories(variables = process.env) {
28056
28237
  const candidates = [runtimeDirectory(variables)];
28057
28238
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
28058
28239
  for (const base of fallbacks) {
28059
- candidates.push(join9(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
28240
+ candidates.push(join10(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
28060
28241
  }
28061
28242
  return [...new Set(candidates.map((path) => resolve4(path)))];
28062
28243
  }
@@ -28071,7 +28252,7 @@ function processAlive(pid) {
28071
28252
  function parseAnnouncement(file, pid) {
28072
28253
  let fields;
28073
28254
  try {
28074
- fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
28255
+ fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
28075
28256
  } catch {
28076
28257
  return { kind: "rubbish" };
28077
28258
  }
@@ -28115,7 +28296,7 @@ var gone = [];
28115
28296
  function wentAway(file, pid) {
28116
28297
  let project = "";
28117
28298
  try {
28118
- const fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
28299
+ const fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
28119
28300
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
28120
28301
  } catch {
28121
28302
  return;
@@ -28144,7 +28325,7 @@ function announcedIn(directory) {
28144
28325
  if (!match) {
28145
28326
  continue;
28146
28327
  }
28147
- const file = join9(directory, entry);
28328
+ const file = join10(directory, entry);
28148
28329
  const pid = Number.parseInt(match[1] ?? "", 10);
28149
28330
  const alive = processAlive(pid);
28150
28331
  const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
@@ -28187,7 +28368,25 @@ function tooNew(unspoken) {
28187
28368
  const half = unspoken.some((game) => game.protocol > RUNTIME_PROTOCOL) ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
28188
28369
  return `A game is running, in a protocol this server does not speak ${named}. This server speaks ${RUNTIME_PROTOCOL}, so ${half}.`;
28189
28370
  }
28190
- function chooseRuntime(endpoints, projectPath, unspoken = []) {
28371
+ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
28372
+ if (pid !== undefined) {
28373
+ const named = endpoints.find((endpoint) => endpoint.pid === pid);
28374
+ if (named !== undefined) {
28375
+ if (projectPath !== undefined && resolve4(named.project.path) !== resolve4(projectPath)) {
28376
+ return {
28377
+ problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve4(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
28378
+ };
28379
+ }
28380
+ return { endpoint: named };
28381
+ }
28382
+ const tooNewToo = unspoken.find((game) => game.pid === pid);
28383
+ if (tooNewToo !== undefined) {
28384
+ return { problem: tooNew([tooNewToo]) };
28385
+ }
28386
+ return {
28387
+ problem: endpoints.length === 0 ? `No game with the runtime addon is running, so there is none with pid ${pid}. Start one with editor_run, or play the project from the editor with the addon enabled.` : `No running game has pid ${pid}. Running: ${endpoints.map(describe2).join("; ")}. editor_status lists them under runtimes as they come and go.`
28388
+ };
28389
+ }
28191
28390
  if (endpoints.length === 0) {
28192
28391
  if (unspoken.length > 0) {
28193
28392
  return { problem: tooNew(unspoken) };
@@ -28218,14 +28417,14 @@ function chooseRuntime(endpoints, projectPath, unspoken = []) {
28218
28417
  return { endpoint: matching[0] };
28219
28418
  }
28220
28419
  return {
28221
- problem: `Several games are running from ${wanted}: ${matching.map(describe2).join("; ")}. Stop all but one.`
28420
+ problem: `Several games are running from ${wanted}: ${matching.map(describe2).join("; ")}. Pass pid to choose one.`
28222
28421
  };
28223
28422
  }
28224
28423
  if (endpoints.length === 1 && endpoints[0]) {
28225
28424
  return { endpoint: endpoints[0] };
28226
28425
  }
28227
28426
  return {
28228
- problem: `Several games are running: ${endpoints.map(describe2).join("; ")}. Pass projectPath to choose one.`
28427
+ problem: `Several games are running: ${endpoints.map(describe2).join("; ")}. Pass projectPath to choose one, or pid when they are from one project.`
28229
28428
  };
28230
28429
  }
28231
28430
  var nextRequestId = 1;
@@ -28319,33 +28518,33 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
28319
28518
 
28320
28519
  // src/run-record.ts
28321
28520
  function runsDirectory() {
28322
- return join10(runtimeDirectory(), "runs");
28521
+ return join11(runtimeDirectory(), "runs");
28323
28522
  }
28324
28523
  function recordPath() {
28325
- return join10(runsDirectory(), "run.json");
28524
+ return join11(runsDirectory(), "run.json");
28326
28525
  }
28327
28526
  function recordPaths() {
28328
- return runtimeDirectories().map((directory) => join10(directory, "runs", "run.json"));
28527
+ return runtimeDirectories().map((directory) => join11(directory, "runs", "run.json"));
28329
28528
  }
28330
28529
  function openTranscript(startedAt) {
28331
- mkdirSync4(runsDirectory(), { recursive: true });
28332
- const path = join10(runsDirectory(), `run-${startedAt}.log`);
28530
+ mkdirSync5(runsDirectory(), { recursive: true });
28531
+ const path = join11(runsDirectory(), `run-${startedAt}.log`);
28333
28532
  return { path, fd: openSync(path, "a") };
28334
28533
  }
28335
28534
  function writeRunRecord(record) {
28336
- mkdirSync4(runsDirectory(), { recursive: true });
28337
- writeFileSync5(recordPath(), JSON.stringify(record, null, 2), "utf8");
28535
+ mkdirSync5(runsDirectory(), { recursive: true });
28536
+ writeFileSync6(recordPath(), JSON.stringify(record, null, 2), "utf8");
28338
28537
  }
28339
28538
  function editorNotePath() {
28340
- return join10(runsDirectory(), "editor-run.json");
28539
+ return join11(runsDirectory(), "editor-run.json");
28341
28540
  }
28342
28541
  function writeEditorRunNote(note) {
28343
- mkdirSync4(runsDirectory(), { recursive: true });
28344
- writeFileSync5(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28542
+ mkdirSync5(runsDirectory(), { recursive: true });
28543
+ writeFileSync6(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28345
28544
  }
28346
28545
  function readEditorRunNote() {
28347
28546
  for (const directory of runtimeDirectories()) {
28348
- const note = editorNoteAt(join10(directory, "runs", "editor-run.json"));
28547
+ const note = editorNoteAt(join11(directory, "runs", "editor-run.json"));
28349
28548
  if (note !== null) {
28350
28549
  return note;
28351
28550
  }
@@ -28355,7 +28554,7 @@ function readEditorRunNote() {
28355
28554
  function editorNoteAt(path) {
28356
28555
  let parsed;
28357
28556
  try {
28358
- parsed = JSON.parse(readFileSync10(path, "utf8"));
28557
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
28359
28558
  } catch {
28360
28559
  return null;
28361
28560
  }
@@ -28378,7 +28577,7 @@ function recordRunEnded(pid, exitCode) {
28378
28577
  return;
28379
28578
  }
28380
28579
  try {
28381
- writeFileSync5(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28580
+ writeFileSync6(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28382
28581
  } catch {}
28383
28582
  }
28384
28583
  function readRunRecord() {
@@ -28393,7 +28592,7 @@ function readRunRecord() {
28393
28592
  function recordAt(path) {
28394
28593
  let parsed;
28395
28594
  try {
28396
- parsed = JSON.parse(readFileSync10(path, "utf8"));
28595
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
28397
28596
  } catch {
28398
28597
  return null;
28399
28598
  }
@@ -28438,7 +28637,7 @@ function runningAs(pid) {
28438
28637
  const began = startedAt(pid, null);
28439
28638
  const when = began === null ? {} : { startedAt: began };
28440
28639
  if (process.platform === "linux") {
28441
- const raw = readFileSync10(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28640
+ const raw = readFileSync11(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28442
28641
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
28443
28642
  }
28444
28643
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -28560,7 +28759,7 @@ function clearRunRecord(ours = () => true) {
28560
28759
  continue;
28561
28760
  }
28562
28761
  try {
28563
- rmSync4(path);
28762
+ rmSync5(path);
28564
28763
  } catch {}
28565
28764
  }
28566
28765
  }
@@ -28584,19 +28783,19 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
28584
28783
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
28585
28784
  continue;
28586
28785
  }
28587
- const path = join10(directory, entry);
28786
+ const path = join11(directory, entry);
28588
28787
  if (path === keep || ageMs(path, now) < keepMs) {
28589
28788
  continue;
28590
28789
  }
28591
28790
  try {
28592
- rmSync4(path);
28791
+ rmSync5(path);
28593
28792
  } catch {}
28594
28793
  }
28595
28794
  }
28596
28795
 
28597
28796
  // 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";
28797
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
28798
+ import { dirname as dirname5, join as join12 } from "node:path";
28600
28799
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
28601
28800
  var RUNTIME_AUTOLOAD = {
28602
28801
  name: "GdharnessRuntime",
@@ -28604,11 +28803,11 @@ var RUNTIME_AUTOLOAD = {
28604
28803
  };
28605
28804
  var VERSION_MARKER = ".gdharness-version";
28606
28805
  function installedAddonVersion(projectPath) {
28607
- const marker = join11(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28806
+ const marker = join12(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28608
28807
  if (!existsSync8(marker)) {
28609
28808
  return null;
28610
28809
  }
28611
- const said = readFileSync11(marker, "utf8").trim();
28810
+ const said = readFileSync12(marker, "utf8").trim();
28612
28811
  return said === "" ? null : said;
28613
28812
  }
28614
28813
 
@@ -28636,6 +28835,10 @@ var RUNNING_PROJECT_PATH = {
28636
28835
  type: "string",
28637
28836
  description: "Which game, when more than one is running: the project directory it was started from. Not needed with one game."
28638
28837
  };
28838
+ var RUNNING_PID = {
28839
+ type: "number",
28840
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
28841
+ };
28639
28842
  var SCENE_PATH = {
28640
28843
  type: "string",
28641
28844
  description: 'Scene file inside the project, such as "scenes/main.tscn" or "res://scenes/main.tscn".'
@@ -29274,7 +29477,7 @@ var TOOL_SPECS = [
29274
29477
  },
29275
29478
  {
29276
29479
  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.",
29480
+ 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
29481
  parameters: {
29279
29482
  severity: {
29280
29483
  type: "string",
@@ -29302,7 +29505,7 @@ var TOOL_SPECS = [
29302
29505
  },
29303
29506
  {
29304
29507
  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.',
29508
+ 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
29509
  parameters: {},
29307
29510
  requires: []
29308
29511
  },
@@ -29359,6 +29562,7 @@ var TOOL_SPECS = [
29359
29562
  description: "Questions about the running game: what is written on the screen, the scene tree, the nodes matching a query, where one node is on screen, what one property reads, or the performance metrics. Needs the game running with the runtime addon.",
29360
29563
  parameters: {
29361
29564
  projectPath: RUNNING_PROJECT_PATH,
29565
+ pid: RUNNING_PID,
29362
29566
  nodePath: {
29363
29567
  type: "string",
29364
29568
  ops: ["tree", "text", "find", "rect", "property"],
@@ -29437,6 +29641,7 @@ var TOOL_SPECS = [
29437
29641
  description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon.",
29438
29642
  parameters: {
29439
29643
  projectPath: RUNNING_PROJECT_PATH,
29644
+ pid: RUNNING_PID,
29440
29645
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
29441
29646
  property: {
29442
29647
  type: "string",
@@ -29476,6 +29681,7 @@ var TOOL_SPECS = [
29476
29681
  description: "A picture of the running game: the whole screen or one viewport, as an image. Needs the game running with a window.",
29477
29682
  parameters: {
29478
29683
  projectPath: RUNNING_PROJECT_PATH,
29684
+ pid: RUNNING_PID,
29479
29685
  viewportPath: {
29480
29686
  type: "string",
29481
29687
  ops: ["viewport"],
@@ -29496,6 +29702,7 @@ var TOOL_SPECS = [
29496
29702
  description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
29497
29703
  parameters: {
29498
29704
  projectPath: RUNNING_PROJECT_PATH,
29705
+ pid: RUNNING_PID,
29499
29706
  nodePath: {
29500
29707
  type: "string",
29501
29708
  ops: ["click", "choose"],
@@ -29592,6 +29799,7 @@ var TOOL_SPECS = [
29592
29799
  description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, a property reaching a value, or words appearing on a screen. Needs the game running with the runtime addon.",
29593
29800
  parameters: {
29594
29801
  projectPath: RUNNING_PROJECT_PATH,
29802
+ pid: RUNNING_PID,
29595
29803
  frames: {
29596
29804
  type: "number",
29597
29805
  ops: ["frames"],
@@ -29627,7 +29835,7 @@ var TOOL_SPECS = [
29627
29835
  },
29628
29836
  {
29629
29837
  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.",
29838
+ description: "Sets or removes a breakpoint through the editor's debug adapter. Needs the editor, not a running game: set them first, then editor_run, and the game stops where you asked. The editor keeps a breakpoint set this way for one play, so every breakpoint this server holds is sent again before each play editor_run starts, and the start answer lists them under breakpoints. The set is also kept in the project, for the server that comes after a reconnect: a breakpoint stays until it is removed here, whichever server set it. Every answer lists the whole set under held, and the breakpoints the user set in the editor's own gutter under setInEditor; setting one here never takes those away, and removing a line here clears it whoever set it. Godot clears every breakpoint in the editor when a debug session opens unless network/debug_adapter/sync_breakpoints is on, which the addon turns on as it loads; an editor running an older addon is said so under breakpointsAtRisk, here and in editor_status.",
29631
29839
  parameters: {
29632
29840
  projectPath: PROJECT_PATH,
29633
29841
  scriptPath: SCRIPT_PATH,
@@ -29652,7 +29860,7 @@ var TOOL_SPECS = [
29652
29860
  },
29653
29861
  {
29654
29862
  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.",
29863
+ 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
29864
  parameters: {
29657
29865
  frameId: {
29658
29866
  type: "number",
@@ -29719,12 +29927,33 @@ function buildToolDefinitions() {
29719
29927
  var UPDATE_NOTICE_EVERY = 500;
29720
29928
  var FEEDBACK_NOTICE_EVERY = 250;
29721
29929
  var run4 = promisify4(execFile4);
29722
- var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
29930
+ var __dirname2 = dirname6(fileURLToPath2(import.meta.url));
29723
29931
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
29932
+ var HOLD_PING_MS = 2000;
29724
29933
  var SLOWEST_FRAME_RATE = 20;
29725
29934
  function patienceForFrames(frames, atLeast) {
29726
29935
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
29727
29936
  }
29937
+ function projectSpelling(projectPath, absolutePath) {
29938
+ return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
29939
+ }
29940
+ function whichGame(args) {
29941
+ return { projectPath: args["projectPath"], pid: args["pid"] };
29942
+ }
29943
+ function describeHalt(halt) {
29944
+ switch (halt.reason) {
29945
+ case "breakpoint":
29946
+ return "at a breakpoint";
29947
+ case "step":
29948
+ return "after a step";
29949
+ case "exception":
29950
+ return halt.text === "" ? "on an error" : `on an error: ${halt.text}`;
29951
+ case "attached":
29952
+ return "held when this session attached";
29953
+ default:
29954
+ return `stopped with reason "${halt.reason}"`;
29955
+ }
29956
+ }
29728
29957
  function stillRunning(run) {
29729
29958
  if (run?.exitCode !== null || run.endedUnwatched === true) {
29730
29959
  return false;
@@ -29746,6 +29975,19 @@ function runIsUp(run, editorSays) {
29746
29975
  }
29747
29976
  return editorSays ?? stillRunning(run);
29748
29977
  }
29978
+ function endedPreviousRun(ended) {
29979
+ if (ended === null) {
29980
+ return;
29981
+ }
29982
+ return ended.pid ?? true;
29983
+ }
29984
+ function endedToStartThis(ended) {
29985
+ if (ended === null) {
29986
+ return "";
29987
+ }
29988
+ const which = ended.pid === null ? "The game the editor was playing" : `The run that was going, pid ${ended.pid},`;
29989
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
29990
+ }
29749
29991
  function runtimeVerdict(endpoint, after) {
29750
29992
  if (endpoint !== null) {
29751
29993
  return { listening: true, pid: endpoint.pid, port: endpoint.port };
@@ -29915,7 +30157,7 @@ function aboveTheRunner(entry) {
29915
30157
  }
29916
30158
  function elsewhereIn(projectPath, asked) {
29917
30159
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
29918
- if (wanted === "" || existsSync9(join12(projectPath, wanted))) {
30160
+ if (wanted === "" || existsSync9(join13(projectPath, wanted))) {
29919
30161
  return "";
29920
30162
  }
29921
30163
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -29929,11 +30171,11 @@ function elsewhereIn(projectPath, asked) {
29929
30171
  return near.length === 0 ? "" : ` The project does have ${there}.`;
29930
30172
  }
29931
30173
  function hasMainScene(projectFile) {
29932
- const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
30174
+ const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
29933
30175
  return typeof scene === "string" && scene !== "";
29934
30176
  }
29935
30177
  function editorPlaysHeadless(projectFile) {
29936
- const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
30178
+ const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29937
30179
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
29938
30180
  }
29939
30181
  function camelCased(params) {
@@ -29949,7 +30191,7 @@ function camelCased(params) {
29949
30191
  class GodotServer {
29950
30192
  mcp;
29951
30193
  locator = new GodotLocator;
29952
- operationsScript = join12(__dirname2, "godot", "operations", "godot_operations.gd");
30194
+ operationsScript = join13(__dirname2, "godot", "operations", "godot_operations.gd");
29953
30195
  godotBridge;
29954
30196
  tools = buildToolDefinitions();
29955
30197
  activeProcess = null;
@@ -29961,6 +30203,7 @@ class GodotServer {
29961
30203
  lastGodotVersion = null;
29962
30204
  lspClient = null;
29963
30205
  dapClient = null;
30206
+ breakpointsSeededOn = null;
29964
30207
  bridgeStartupError = null;
29965
30208
  bridgeRetry = null;
29966
30209
  successorWatch = null;
@@ -30485,7 +30728,7 @@ class GodotServer {
30485
30728
  switch (op) {
30486
30729
  case "tree":
30487
30730
  return await this.handleRuntimeCommand("get_tree", {
30488
- projectPath: args["projectPath"],
30731
+ ...whichGame(args),
30489
30732
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30490
30733
  depth: readPositiveNumber(args, "depth") ?? 3,
30491
30734
  include_properties: readBoolean(args, "includeProperties") ?? false
@@ -30494,36 +30737,36 @@ class GodotServer {
30494
30737
  return await this.handleFindRuntimeNodes(args);
30495
30738
  case "text":
30496
30739
  return await this.handleRuntimeCommand("read_text", {
30497
- projectPath: args["projectPath"],
30740
+ ...whichGame(args),
30498
30741
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30499
30742
  include_hidden: readBoolean(args, "includeHidden") ?? false,
30500
30743
  limit: readPositiveNumber(args, "limit") ?? 500
30501
30744
  });
30502
30745
  case "rect":
30503
30746
  return await this.handleRuntimeCommand("get_rect", {
30504
- projectPath: args["projectPath"],
30747
+ ...whichGame(args),
30505
30748
  path: readNonEmptyString(args, "nodePath") ?? ""
30506
30749
  });
30507
30750
  case "property":
30508
30751
  return await this.handleRuntimeCommand("get_property", {
30509
- projectPath: args["projectPath"],
30752
+ ...whichGame(args),
30510
30753
  path: readNonEmptyString(args, "nodePath") ?? "",
30511
30754
  property: readNonEmptyString(args, "property") ?? ""
30512
30755
  });
30513
30756
  default:
30514
30757
  return await this.handleRuntimeCommand("get_metrics", {
30515
- projectPath: args["projectPath"],
30758
+ ...whichGame(args),
30516
30759
  metrics: readArray(args, "metrics") ?? []
30517
30760
  });
30518
30761
  }
30519
30762
  case "runtime_invoke":
30520
30763
  return op === "set" ? await this.handleRuntimeCommand("set_property", {
30521
- projectPath: args["projectPath"],
30764
+ ...whichGame(args),
30522
30765
  path: readNonEmptyString(args, "nodePath") ?? "",
30523
30766
  property: readString(args, "property") ?? "",
30524
30767
  value: args["value"]
30525
30768
  }) : await this.handleRuntimeCommand("call_method", {
30526
- projectPath: args["projectPath"],
30769
+ ...whichGame(args),
30527
30770
  path: readNonEmptyString(args, "nodePath") ?? "",
30528
30771
  method: readString(args, "method") ?? "",
30529
30772
  args: readArray(args, "args") ?? []
@@ -30533,7 +30776,7 @@ class GodotServer {
30533
30776
  case "runtime_input":
30534
30777
  if (op === "click") {
30535
30778
  return await this.handleRuntimeCommand("click", {
30536
- projectPath: args["projectPath"],
30779
+ ...whichGame(args),
30537
30780
  path: readNonEmptyString(args, "nodePath") ?? "",
30538
30781
  button: readString(args, "button") ?? "left",
30539
30782
  double: readBoolean(args, "doubleClick") ?? false
@@ -30541,7 +30784,7 @@ class GodotServer {
30541
30784
  }
30542
30785
  if (op === "choose") {
30543
30786
  return await this.handleRuntimeCommand("choose", {
30544
- projectPath: args["projectPath"],
30787
+ ...whichGame(args),
30545
30788
  path: readNonEmptyString(args, "nodePath") ?? "",
30546
30789
  text: readString(args, "text") ?? "",
30547
30790
  ...args["index"] === undefined ? {} : { index: args["index"] }
@@ -30559,17 +30802,30 @@ class GodotServer {
30559
30802
  if (!located.ok) {
30560
30803
  return this.createErrorResponse(located.reason, PATH_SOLUTIONS);
30561
30804
  }
30562
- return await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
30805
+ const session = this.dapHoldingBreakpointsOf(project.value.path);
30806
+ const answered = await this.handleDAP(op === "set" ? "dap_set_breakpoint" : "dap_remove_breakpoint", {
30563
30807
  ...args,
30564
30808
  scriptPath: realPathOr(located.absolutePath)
30565
30809
  });
30810
+ if (answered.isError === true) {
30811
+ return answered;
30812
+ }
30813
+ const held = session.breakpointsHeld();
30814
+ writeBreakpointNote(project.value.path, held);
30815
+ return this.jsonTextResponse({
30816
+ ...asParams(JSON.parse(answered.content[0]?.text ?? "{}")),
30817
+ held: this.breakpointsAsProjectSpells(project.value.path, held),
30818
+ setInEditor: this.breakpointsAsProjectSpells(project.value.path, session.breakpointsInEditor()),
30819
+ 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.",
30820
+ ...this.breakpointsAtRisk()
30821
+ });
30566
30822
  }
30567
30823
  case "debug_control":
30568
30824
  case "debug_state": {
30569
30825
  if (tool === "debug_state" && op === "output") {
30570
30826
  return await this.handleDAP("dap_get_output", args);
30571
30827
  }
30572
- const held = await this.debuggedGame();
30828
+ const held = await this.debuggedGame(tool === "debug_control" ? "move" : "read");
30573
30829
  if (!held.ok) {
30574
30830
  return held.response;
30575
30831
  }
@@ -30592,7 +30848,7 @@ class GodotServer {
30592
30848
  if (path === undefined) {
30593
30849
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
30594
30850
  }
30595
- const file = join12(path, "project.godot");
30851
+ const file = join13(path, "project.godot");
30596
30852
  if (!existsSync9(file)) {
30597
30853
  return {
30598
30854
  ok: false,
@@ -30618,7 +30874,7 @@ class GodotServer {
30618
30874
  }
30619
30875
  return { ok: true, value: given };
30620
30876
  }
30621
- async debuggedGame() {
30877
+ async debuggedGame(intent) {
30622
30878
  const game = this.activeProcess;
30623
30879
  if (game !== null && game.exitCode !== null) {
30624
30880
  return {
@@ -30635,6 +30891,16 @@ class GodotServer {
30635
30891
  response: this.createErrorResponse("No game is running, so there is no debug session to answer for.", ["Start one with editor_run start, which has the editor play it"])
30636
30892
  };
30637
30893
  }
30894
+ const going = await this.runStillGoing(game);
30895
+ if (!going) {
30896
+ return {
30897
+ ok: false,
30898
+ response: this.createErrorResponse("The last run has already ended, so there is no debug session to answer for.", [
30899
+ "editor_output still reads what it printed, until the next run starts",
30900
+ "editor_run start plays another, which the debugger can hold"
30901
+ ])
30902
+ };
30903
+ }
30638
30904
  if (!game.throughEditor) {
30639
30905
  return {
30640
30906
  ok: false,
@@ -30656,6 +30922,19 @@ class GodotServer {
30656
30922
  };
30657
30923
  }
30658
30924
  if (!this.dap().isStopped()) {
30925
+ const hold = await this.holdOf(game, going);
30926
+ if (hold.heldAt !== null) {
30927
+ if (intent === "move") {
30928
+ return { ok: true, value: game };
30929
+ }
30930
+ return {
30931
+ ok: false,
30932
+ 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}.`, [
30933
+ "debug_control continue lets a held game go, and the next stop is one this session is told about",
30934
+ "editor_output says what it printed on the way to where it is"
30935
+ ])
30936
+ };
30937
+ }
30659
30938
  return {
30660
30939
  ok: false,
30661
30940
  response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
@@ -30793,7 +31072,7 @@ class GodotServer {
30793
31072
  if (!engine.ok) {
30794
31073
  return engine.response;
30795
31074
  }
30796
- const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
31075
+ const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
30797
31076
  const name = application["config/name"];
30798
31077
  const mainScene = application["run/main_scene"];
30799
31078
  const info = {
@@ -30854,18 +31133,18 @@ class GodotServer {
30854
31133
  if (!engine.ok) {
30855
31134
  return engine.response;
30856
31135
  }
30857
- const folder = dirname5(output.absolutePath);
31136
+ const folder = dirname6(output.absolutePath);
30858
31137
  try {
30859
- mkdirSync6(folder, { recursive: true });
31138
+ mkdirSync7(folder, { recursive: true });
30860
31139
  } catch (error) {
30861
- return this.createErrorResponse(`Cannot create ${dirname5(output.relativePath)} to export into: ${errorMessage(error)}`);
31140
+ return this.createErrorResponse(`Cannot create ${dirname6(output.relativePath)} to export into: ${errorMessage(error)}`);
30862
31141
  }
30863
31142
  const debug = readBoolean(args, "debug") ?? false;
30864
- const exportLogs = mkdtempSync2(join12(tmpdir4(), "gdharness-export-"));
31143
+ const exportLogs = mkdtempSync2(join13(tmpdir4(), "gdharness-export-"));
30865
31144
  const exportArgs = [
30866
31145
  "--headless",
30867
31146
  "--log-file",
30868
- join12(exportLogs, "engine.log"),
31147
+ join13(exportLogs, "engine.log"),
30869
31148
  "--path",
30870
31149
  project.value.path,
30871
31150
  debug ? "--export-debug" : "--export-release",
@@ -30923,7 +31202,7 @@ class GodotServer {
30923
31202
  return contained.response;
30924
31203
  }
30925
31204
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
30926
- if (!existsSync9(join12(project.value.path, runner))) {
31205
+ if (!existsSync9(join13(project.value.path, runner))) {
30927
31206
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
30928
31207
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
30929
31208
  ]);
@@ -30958,7 +31237,7 @@ class GodotServer {
30958
31237
  ];
30959
31238
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
30960
31239
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
30961
- const userData = mkdtempSync2(join12(tmpdir4(), "gdharness-tests-"));
31240
+ const userData = mkdtempSync2(join13(tmpdir4(), "gdharness-tests-"));
30962
31241
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
30963
31242
  const hung = await new Promise((resolve) => {
30964
31243
  const timer = setTimeout(() => {
@@ -30974,14 +31253,14 @@ class GodotServer {
30974
31253
  resolve(false);
30975
31254
  });
30976
31255
  });
30977
- const reportsDir = join12(project.value.path, ".godot", ...ours.split("/"));
31256
+ const reportsDir = join13(project.value.path, ".godot", ...ours.split("/"));
30978
31257
  let report = null;
30979
31258
  let reportProblem = null;
30980
31259
  try {
30981
31260
  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
31261
  const newest = written.at(-1);
30983
31262
  if (newest !== undefined) {
30984
- report = parseJUnit(readFileSync12(join12(reportsDir, newest, "results.xml"), "utf8"));
31263
+ report = parseJUnit(readFileSync13(join13(reportsDir, newest, "results.xml"), "utf8"));
30985
31264
  }
30986
31265
  } catch (error) {
30987
31266
  reportProblem = errorMessage(error);
@@ -31121,7 +31400,7 @@ class GodotServer {
31121
31400
  return null;
31122
31401
  }
31123
31402
  try {
31124
- return readFileSync12(contained.absolutePath, "utf8");
31403
+ return readFileSync13(contained.absolutePath, "utf8");
31125
31404
  } catch {
31126
31405
  return null;
31127
31406
  }
@@ -31163,7 +31442,7 @@ class GodotServer {
31163
31442
  return null;
31164
31443
  }
31165
31444
  try {
31166
- return readFileSync12(contained.absolutePath, "utf8");
31445
+ return readFileSync13(contained.absolutePath, "utf8");
31167
31446
  } catch {
31168
31447
  return null;
31169
31448
  }
@@ -31209,9 +31488,38 @@ class GodotServer {
31209
31488
  this.dapClient.setOutputSink((line) => {
31210
31489
  this.writeToTranscript(line);
31211
31490
  });
31491
+ this.dapClient.setBreakpointsSink((held) => {
31492
+ const project = this.godotBridge.getStatus().projectPath ?? this.ownProject;
31493
+ if (project !== null) {
31494
+ writeBreakpointNote(project, held);
31495
+ }
31496
+ });
31212
31497
  }
31213
31498
  return this.dapClient;
31214
31499
  }
31500
+ dapHoldingBreakpointsOf(projectPath) {
31501
+ const client = this.dap();
31502
+ if (this.breakpointsSeededOn !== client) {
31503
+ client.holdBreakpoints(readBreakpointNote(projectPath));
31504
+ this.breakpointsSeededOn = client;
31505
+ }
31506
+ return client;
31507
+ }
31508
+ breakpointsAtRisk() {
31509
+ const status = this.godotBridge.getStatus();
31510
+ if (!status.connected || status.syncsBreakpoints === true) {
31511
+ return {};
31512
+ }
31513
+ return {
31514
+ breakpointsAtRisk: status.syncsBreakpoints === false ? "This editor clears every breakpoint in its script editor whenever a debug session opens on its adapter, which this server does for its first breakpoint or stack read: network/debug_adapter/sync_breakpoints is off. The installed addon turns it on as it loads, so the editor is running an older one; editor_launch restart puts the installed addon in, or turn the setting on in Editor Settings > Network > Debug Adapter." : "Whether this editor keeps its breakpoints when a debug session opens cannot be told: the addon it is running predates the report. Godot clears them unless network/debug_adapter/sync_breakpoints is on, which the installed addon turns on as it loads; editor_launch restart puts the installed addon in."
31515
+ };
31516
+ }
31517
+ breakpointsAsProjectSpells(projectPath, held) {
31518
+ return held.map((one) => ({
31519
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
31520
+ lines: one.lines
31521
+ }));
31522
+ }
31215
31523
  writeToTranscript(line) {
31216
31524
  const run = this.activeProcess;
31217
31525
  if (run?.throughEditor !== true || run.transcript === null) {
@@ -31268,6 +31576,7 @@ class GodotServer {
31268
31576
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
31269
31577
  startupError: this.bridgeStartupError,
31270
31578
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
31579
+ ...this.breakpointsAtRisk(),
31271
31580
  retryingBridge: this.bridgeRetry === null ? undefined : true,
31272
31581
  announcedAt: this.announcedAt ?? undefined,
31273
31582
  projectIs: this.projectHasMovedOn() ?? undefined,
@@ -31301,8 +31610,12 @@ class GodotServer {
31301
31610
  async handleEditorStatus() {
31302
31611
  const located = await this.locator.find();
31303
31612
  const announced = runtimesAnnounced();
31304
- const games = await Promise.all(announced.running.map(async (endpoint) => {
31305
- const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
31613
+ const pinged = Promise.all(announced.running.map(async (endpoint) => {
31614
+ const held = this.knownHoldOn(endpoint);
31615
+ const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
31616
+ ok: false,
31617
+ 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.`
31618
+ };
31306
31619
  return {
31307
31620
  pid: endpoint.pid,
31308
31621
  port: endpoint.port,
@@ -31311,8 +31624,18 @@ class GodotServer {
31311
31624
  problem: reply.ok ? null : reply.message
31312
31625
  };
31313
31626
  }));
31314
- const playing = await this.editorPlayingState();
31315
- await this.pickUpWhatTheEditorIsPlaying();
31627
+ const asked = (async () => {
31628
+ const playing = await this.editorPlayingState();
31629
+ await this.pickUpWhatTheEditorIsPlaying();
31630
+ const current = this.currentRun();
31631
+ const active = current === null ? false : await this.runStillGoing(current, playing?.playing ?? null);
31632
+ return {
31633
+ playing,
31634
+ active,
31635
+ hold: current === null ? {} : await this.holdOf(current, active)
31636
+ };
31637
+ })();
31638
+ const [games, { playing, active, hold }] = await Promise.all([pinged, asked]);
31316
31639
  return this.jsonTextResponse({
31317
31640
  editor: {
31318
31641
  ...this.getEditorStatusPayload(),
@@ -31324,8 +31647,9 @@ class GodotServer {
31324
31647
  problem: located.ok ? undefined : GodotLocator.refusal(located).message
31325
31648
  },
31326
31649
  game: {
31327
- processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
31650
+ processActive: active,
31328
31651
  playingInEditor: playing,
31652
+ ...hold,
31329
31653
  runtimeConnected: games.some((game) => game.reachable),
31330
31654
  runtimes: games,
31331
31655
  unreadable: announced.unspoken.map((game) => ({
@@ -31343,7 +31667,7 @@ class GodotServer {
31343
31667
  return null;
31344
31668
  }
31345
31669
  try {
31346
- return settingKeys(readFileSync12(join12(projectPath, "project.godot"), "utf8"));
31670
+ return settingKeys(readFileSync13(join13(projectPath, "project.godot"), "utf8"));
31347
31671
  } catch {
31348
31672
  return null;
31349
31673
  }
@@ -31536,10 +31860,12 @@ class GodotServer {
31536
31860
  if (op === "check") {
31537
31861
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
31538
31862
  }
31863
+ await this.pickUpWhatTheEditorIsPlaying();
31539
31864
  let ended = null;
31540
- if (stillRunning(this.currentRun())) {
31865
+ const before = this.currentRun();
31866
+ if (before !== null && await this.runStillGoing(before)) {
31541
31867
  this.logDebug("Ending the running game before starting another");
31542
- ended = this.currentRun()?.pid ?? null;
31868
+ ended = { pid: before.pid ?? this.announcedPidOf(before) ?? null };
31543
31869
  await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
31544
31870
  }
31545
31871
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
@@ -31563,8 +31889,8 @@ class GodotServer {
31563
31889
  const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
31564
31890
  this.activeProcess = started;
31565
31891
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
31566
- const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
31567
- const stranded = wereHereFirst.filter((one) => one.pid !== ended && alive(one.pid));
31892
+ const endedForThis = endedToStartThis(ended);
31893
+ const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
31568
31894
  const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
31569
31895
  const alsoRunning = stranded.length === 0 ? "" : ` A game of this project was already running and still is: ${named}. Starting this one did not end it and nothing here can, because this server did not start it. End it yourself, or bring the halves to one version where a protocol is named.`;
31570
31896
  return this.jsonTextResponse({
@@ -31575,23 +31901,38 @@ class GodotServer {
31575
31901
  arguments: cmdArgs,
31576
31902
  transcript: started.transcript,
31577
31903
  refreshedClasses: refreshed.value,
31578
- endedPreviousRun: ended ?? undefined,
31904
+ endedPreviousRun: endedPreviousRun(ended),
31579
31905
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
31580
31906
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
31581
31907
  });
31582
31908
  }
31583
- async gameIsUp() {
31584
- const run = this.currentRun();
31585
- const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
31586
- return runIsUp(run, editorSays);
31587
- }
31588
31909
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
31589
- if (!existsSync9(join12(projectPath, RUNTIME_AUTOLOAD.path))) {
31910
+ if (!existsSync9(join13(projectPath, RUNTIME_AUTOLOAD.path))) {
31590
31911
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
31591
31912
  }
31913
+ let asking = null;
31914
+ let editorAskedAt = 0;
31915
+ let editorSaysGoing = true;
31592
31916
  const endpoint = await announcedSince(projectPath, before, {
31593
31917
  budgetMs,
31594
- giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
31918
+ giveUp: () => {
31919
+ if (this.dapClient?.isStopped() === true) {
31920
+ return true;
31921
+ }
31922
+ const run = this.currentRun();
31923
+ if (run === null || !run.throughEditor) {
31924
+ return !stillRunning(run);
31925
+ }
31926
+ if (asking === null && Date.now() - editorAskedAt >= RUN_POLL_MS) {
31927
+ editorAskedAt = Date.now();
31928
+ asking = this.runStillGoing(run).then((going) => {
31929
+ editorSaysGoing = going;
31930
+ }).finally(() => {
31931
+ asking = null;
31932
+ });
31933
+ }
31934
+ return !editorSaysGoing;
31935
+ }
31595
31936
  });
31596
31937
  const going = this.currentRun();
31597
31938
  if (endpoint !== null && going?.throughEditor === true && going.announcedPid === undefined) {
@@ -31601,7 +31942,7 @@ class GodotServer {
31601
31942
  addon: true,
31602
31943
  budgetMs,
31603
31944
  heldAt: this.dapClient?.whereItStopped() ?? null,
31604
- running: await this.gameIsUp(),
31945
+ running: endpoint !== null || going !== null && await this.runStillGoing(going),
31605
31946
  withArgs
31606
31947
  });
31607
31948
  }
@@ -31624,6 +31965,7 @@ class GodotServer {
31624
31965
  "Closing the other editor, or moving its debug adapter port in its editor settings, frees this one"
31625
31966
  ]);
31626
31967
  }
31968
+ const breakpoints = await this.dapHoldingBreakpointsOf(projectPath).reapplyBreakpoints();
31627
31969
  const answer = await this.handleViaBridge("play_scene", scene === null ? {} : { scenePath: `res://${scene}` });
31628
31970
  if (answer.isError === true) {
31629
31971
  return answer;
@@ -31641,7 +31983,8 @@ class GodotServer {
31641
31983
  startedAt,
31642
31984
  exitCode: null,
31643
31985
  throughEditor: true,
31644
- brokeOn: null
31986
+ brokeOn: null,
31987
+ announcedBefore: alreadyPlaying
31645
31988
  };
31646
31989
  this.activeProcess = played;
31647
31990
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
@@ -31652,9 +31995,14 @@ class GodotServer {
31652
31995
  scene: scene === null ? "the main scene" : `res://${scene}`,
31653
31996
  refreshedClasses,
31654
31997
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
31655
- endedPreviousRun: ended ?? undefined,
31998
+ endedPreviousRun: endedPreviousRun(ended),
31999
+ breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
32000
+ breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
32001
+ scriptPath: projectSpelling(projectPath, one.scriptPath),
32002
+ reason: one.reason
32003
+ })),
31656
32004
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
31657
- 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.`)
32005
+ message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + endedToStartThis(ended)
31658
32006
  });
31659
32007
  }
31660
32008
  drainEditorOutput(game) {
@@ -31850,7 +32198,7 @@ class GodotServer {
31850
32198
  return;
31851
32199
  }
31852
32200
  try {
31853
- await this.dap().connect();
32201
+ await this.dap().attach();
31854
32202
  } catch (error) {
31855
32203
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
31856
32204
  }
@@ -31872,6 +32220,18 @@ class GodotServer {
31872
32220
  };
31873
32221
  this.activeProcess = picked;
31874
32222
  }
32223
+ announcedPidOf(run) {
32224
+ if (!run.throughEditor || run.announcedPid !== undefined) {
32225
+ return run.announcedPid;
32226
+ }
32227
+ const before = run.announcedBefore ?? new Set;
32228
+ const fresh = this.allAnnouncedForOurProject(runtimesAnnounced().running).filter((one) => !before.has(one.pid));
32229
+ const theOne = fresh.length === 1 ? fresh[0] : undefined;
32230
+ if (theOne !== undefined) {
32231
+ run.announcedPid = theOne.pid;
32232
+ }
32233
+ return run.announcedPid;
32234
+ }
31875
32235
  adoptRecordedRun() {
31876
32236
  const record = readRunRecord();
31877
32237
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -32000,12 +32360,22 @@ class GodotServer {
32000
32360
  }
32001
32361
  const budgetMs = readPositiveNumber(args, "timeoutMs") ?? WAIT_FOR_RUN_MS;
32002
32362
  const until = Date.now() + budgetMs;
32003
- while (stillRunning(run) && Date.now() < until) {
32363
+ while (await this.runStillGoing(run) && Date.now() < until) {
32004
32364
  this.drainEditorOutput(run);
32005
32365
  this.drainTranscript(run);
32006
32366
  await delay3(RUN_POLL_MS);
32007
32367
  }
32008
- return await this.handleGetDebugOutput(args, stillRunning(run) ? budgetMs : undefined);
32368
+ return await this.handleGetDebugOutput(args, await this.runStillGoing(run) ? budgetMs : undefined);
32369
+ }
32370
+ async runStillGoing(run, editorSays) {
32371
+ if (!run.throughEditor) {
32372
+ return stillRunning(run);
32373
+ }
32374
+ const announced = this.announcedPidOf(run);
32375
+ if (announced !== undefined && !alive(announced)) {
32376
+ return false;
32377
+ }
32378
+ return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
32009
32379
  }
32010
32380
  async handleGetDebugOutput(args, waitedMs) {
32011
32381
  await this.pickUpWhatTheEditorIsPlaying();
@@ -32015,7 +32385,8 @@ class GodotServer {
32015
32385
  }
32016
32386
  this.drainEditorOutput(run);
32017
32387
  this.drainTranscript(run);
32018
- if (run.endedUnwatched !== true && !stillRunning(run)) {
32388
+ const going = await this.runStillGoing(run);
32389
+ if (run.endedUnwatched !== true && !going) {
32019
32390
  run.log.finish();
32020
32391
  run.endedUnwatched = run.exitCode === null;
32021
32392
  }
@@ -32026,12 +32397,13 @@ class GodotServer {
32026
32397
  contains: readNonEmptyString(args, "contains"),
32027
32398
  limit: readPositiveNumber(args, "limit") ?? 200
32028
32399
  });
32029
- const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
32400
+ const hold = await this.holdOf(run, going);
32030
32401
  const wanted = readBoolean(args, "cpu") ?? false;
32031
- const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
32402
+ const processToAsk = run.pid ?? this.announcedPidOf(run) ?? null;
32403
+ const cpuSeconds = wanted && processToAsk !== null && stillRunning(run) ? await cpuSecondsOf(processToAsk) : undefined;
32032
32404
  const notes = [];
32033
32405
  if (wanted && cpuSeconds === undefined && stillRunning(run)) {
32034
- notes.push(run.pid === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, so nothing here has its process id. editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds." : "cpu was asked for and this platform would not say what the run has used.");
32406
+ notes.push(processToAsk === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, and the game announced no runtime, so nothing here has its process id. A game with the runtime addon announces one; editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds either way." : "cpu was asked for and this platform would not say what the run has used.");
32035
32407
  }
32036
32408
  if (waitedMs !== undefined) {
32037
32409
  notes.push(`The wait of ${waitedMs}ms ran out and the run is still going, so this is what it had printed by then rather than everything it will print. editor_run wait again to keep waiting.`);
@@ -32055,15 +32427,15 @@ class GodotServer {
32055
32427
  notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: a transcript that stops growing is a run between prints. running above is asked of the operating system while there is a process to ask about, and for an editor-played run that announced no runtime it is the editor's answer instead, which can lag after the game has gone.`);
32056
32428
  }
32057
32429
  return this.jsonTextResponse({
32058
- running: stillRunning(run),
32430
+ running: going,
32059
32431
  exitCode: run.exitCode,
32060
32432
  through: run.throughEditor ? "editor" : "gdharness",
32061
- pid: run.pid,
32433
+ pid: run.pid ?? this.announcedPidOf(run) ?? null,
32062
32434
  errors: run.log.count("error"),
32063
32435
  warnings: run.log.count("warning"),
32064
32436
  clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
32065
32437
  consoleLost: run.consoleLost === true ? true : undefined,
32066
- heldAt: halt,
32438
+ ...hold,
32067
32439
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
32068
32440
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
32069
32441
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
@@ -32084,18 +32456,19 @@ class GodotServer {
32084
32456
  }
32085
32457
  this.drainEditorOutput(stopped);
32086
32458
  this.drainTranscript(stopped);
32459
+ const wasRunning = await this.runStillGoing(stopped);
32087
32460
  this.logDebug("Stopping the running game");
32088
32461
  await this.endActiveGame("editor_run stop");
32089
32462
  return this.jsonTextResponse({
32090
32463
  stopped: true,
32091
32464
  through: stopped.throughEditor ? "editor" : "gdharness",
32092
- endedPid: stopped.pid,
32093
- exitedBeforeStop: stopped.exitCode !== null,
32465
+ endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
32466
+ exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
32094
32467
  exitCode: stopped.exitCode,
32095
32468
  errors: stopped.log.count("error"),
32096
32469
  warnings: stopped.log.count("warning"),
32097
32470
  clean: stopped.log.count("error") === 0,
32098
- note: stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
32471
+ note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
32099
32472
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
32100
32473
  });
32101
32474
  }
@@ -32246,16 +32619,68 @@ class GodotServer {
32246
32619
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
32247
32620
  return Number.isInteger(override) && override > 0 ? override : 1e4;
32248
32621
  }
32622
+ knownHoldOn(endpoint) {
32623
+ const run = this.activeProcess;
32624
+ const session = this.dapClient;
32625
+ if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
32626
+ return null;
32627
+ }
32628
+ if (this.announcedPidOf(run) !== endpoint.pid || !session.holdIsKnown()) {
32629
+ return null;
32630
+ }
32631
+ return session.whereItStopped();
32632
+ }
32633
+ async holdOf(run, going) {
32634
+ if (!run.throughEditor || !(going ?? await this.runStillGoing(run))) {
32635
+ return { heldAt: null };
32636
+ }
32637
+ const session = this.dapClient;
32638
+ if (session?.holdIsKnown()) {
32639
+ return { heldAt: session.whereItStopped() };
32640
+ }
32641
+ const its = this.announcedPidOf(run);
32642
+ const endpoint = its === undefined ? undefined : runtimesAnnounced().running.find((one) => one.pid === its);
32643
+ if (endpoint === undefined) {
32644
+ return {
32645
+ heldAt: undefined,
32646
+ heldUnknown: true,
32647
+ 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."
32648
+ };
32649
+ }
32650
+ const reply = await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS);
32651
+ if (reply.ok) {
32652
+ session?.learnedRunning();
32653
+ return { heldAt: session?.whereItStopped() ?? null };
32654
+ }
32655
+ if (reply.reason === "busy") {
32656
+ return {
32657
+ heldAt: {
32658
+ reason: "unanswered",
32659
+ 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`,
32660
+ text: ""
32661
+ }
32662
+ };
32663
+ }
32664
+ return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
32665
+ }
32249
32666
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
32250
- const { op: _op, projectPath, ...params } = asParams(args);
32667
+ const { op: _op, projectPath, pid, ...params } = asParams(args);
32251
32668
  const announced = runtimesAnnounced();
32252
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
32669
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
32253
32670
  if ("problem" in choice) {
32254
32671
  return this.createErrorResponse(choice.problem);
32255
32672
  }
32673
+ const held = this.knownHoldOn(choice.endpoint);
32674
+ if (held !== null) {
32675
+ 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.`, [
32676
+ "debug_control continue lets it go, after which this call answers",
32677
+ "debug_state stack reads where it is held, and debug_state scopes what it is holding there",
32678
+ "editor_output says heldAt for the run, and editor_run stop ends it"
32679
+ ]);
32680
+ }
32256
32681
  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;
32682
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join13(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
32683
+ const screenshotPath = screenshotDir ? join13(screenshotDir, "capture.png") : null;
32259
32684
  try {
32260
32685
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
32261
32686
  if (!reply.ok) {
@@ -32276,7 +32701,7 @@ class GodotServer {
32276
32701
  return {
32277
32702
  content: [
32278
32703
  { type: "text", text: `Screenshot captured: ${dimensions}` },
32279
- { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
32704
+ { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
32280
32705
  ]
32281
32706
  };
32282
32707
  } finally {
@@ -32307,7 +32732,7 @@ class GodotServer {
32307
32732
  const includeHidden = readBoolean(args, "includeHidden");
32308
32733
  return await this.handleRuntimeCommand("find_nodes", {
32309
32734
  ...filters,
32310
- projectPath: args["projectPath"],
32735
+ ...whichGame(args),
32311
32736
  root: readNonEmptyString(args, "nodePath") ?? "/root",
32312
32737
  limit: readPositiveNumber(args, "limit") ?? 100,
32313
32738
  ...property === undefined ? {} : { property },
@@ -32318,18 +32743,18 @@ class GodotServer {
32318
32743
  if (op === "frames") {
32319
32744
  const frames = readPositiveNumber(args, "frames") ?? 1;
32320
32745
  const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
32321
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
32746
+ return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
32322
32747
  }
32323
32748
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
32324
32749
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
32325
32750
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
32326
32751
  return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
32327
- projectPath: args["projectPath"],
32752
+ ...whichGame(args),
32328
32753
  path: nodePath,
32329
32754
  signal: readString(args, "signal") ?? "",
32330
32755
  timeout_ms: timeoutMs
32331
32756
  }, patience) : await this.handleRuntimeCommand("wait_until", {
32332
- projectPath: args["projectPath"],
32757
+ ...whichGame(args),
32333
32758
  path: nodePath,
32334
32759
  property: readString(args, "property") ?? "",
32335
32760
  value: args["value"],