gdharness 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -14711,6 +14711,9 @@ var init_types = __esm(() => {
14711
14711
 
14712
14712
  // src/paths.ts
14713
14713
  import { isAbsolute, relative, resolve, sep } from "node:path";
14714
+ function isSameDirectory(onePath, otherPath) {
14715
+ return relative(resolve(onePath), resolve(otherPath)) === "";
14716
+ }
14714
14717
  function isWithinRoot(rootPath, candidatePath) {
14715
14718
  const step = relative(resolve(rootPath), resolve(candidatePath));
14716
14719
  return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
@@ -15845,7 +15848,11 @@ var init_tool_definitions = __esm(() => {
15845
15848
  blank: true,
15846
15849
  description: `set: the value, fitted to the property's type. "" writes an empty string.`
15847
15850
  },
15848
- method: { type: "string", ops: ["call"] },
15851
+ method: {
15852
+ type: "string",
15853
+ ops: ["call"],
15854
+ description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
15855
+ },
15849
15856
  args: {
15850
15857
  type: "array",
15851
15858
  ops: ["call"],
@@ -15858,7 +15865,10 @@ var init_tool_definitions = __esm(() => {
15858
15865
  summary: "set a property, on a node or on an object it holds",
15859
15866
  requires: ["property", "value"]
15860
15867
  },
15861
- call: { summary: "call a method and return its result", requires: ["method"] }
15868
+ call: {
15869
+ summary: "call a method, on a node or on an object it holds, and return its result",
15870
+ requires: ["method"]
15871
+ }
15862
15872
  }
15863
15873
  },
15864
15874
  {
@@ -30772,11 +30782,29 @@ var init_stdio2 = __esm(() => {
30772
30782
  });
30773
30783
 
30774
30784
  // src/bridge-announce.ts
30775
- import { mkdirSync as mkdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30785
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30776
30786
  import { dirname as dirname6, join as join11 } from "node:path";
30777
30787
  function announcementPath(projectPath) {
30778
30788
  return join11(projectPath, ".godot", "gdharness-bridge.json");
30779
30789
  }
30790
+ function readAnnouncement(path) {
30791
+ try {
30792
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
30793
+ if (typeof parsed !== "object" || parsed === null) {
30794
+ return null;
30795
+ }
30796
+ const said = parsed;
30797
+ if (typeof said.port !== "number" || typeof said.pid !== "number") {
30798
+ return null;
30799
+ }
30800
+ return said;
30801
+ } catch {
30802
+ return null;
30803
+ }
30804
+ }
30805
+ function announcementIsOurs(path) {
30806
+ return readAnnouncement(path)?.pid === process.pid;
30807
+ }
30780
30808
  function announceBridge(projectPath, bridge) {
30781
30809
  const path = announcementPath(projectPath);
30782
30810
  try {
@@ -30798,7 +30826,7 @@ function announceBridge(projectPath, bridge) {
30798
30826
  }
30799
30827
  }
30800
30828
  function withdrawBridge(path) {
30801
- if (path === null) {
30829
+ if (path === null || !announcementIsOurs(path)) {
30802
30830
  return;
30803
30831
  }
30804
30832
  try {
@@ -34324,14 +34352,15 @@ function resolveDefaultBridgeHost() {
34324
34352
  function servedPort(said) {
34325
34353
  return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
34326
34354
  }
34327
- function getDefaultBridge(mayMove = false) {
34328
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34355
+ function getDefaultBridge(ownProject = null) {
34356
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
34329
34357
  return defaultBridge;
34330
34358
  }
34331
- var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, GodotBridge, defaultBridge = null;
34359
+ var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, GodotBridge, defaultBridge = null;
34332
34360
  var init_godot_bridge = __esm(() => {
34333
34361
  init_wrapper();
34334
34362
  init_errors();
34363
+ init_paths();
34335
34364
  init_ports();
34336
34365
  GodotBridge = class GodotBridge extends EventEmitter {
34337
34366
  httpServer = null;
@@ -34345,13 +34374,16 @@ var init_godot_bridge = __esm(() => {
34345
34374
  boundPort = null;
34346
34375
  host;
34347
34376
  timeoutMs;
34348
- mayMove;
34349
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
34377
+ ownProject;
34378
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, ownProject = null) {
34350
34379
  super();
34351
34380
  this.wantedPort = port;
34352
34381
  this.host = host;
34353
34382
  this.timeoutMs = timeoutMs;
34354
- this.mayMove = mayMove;
34383
+ this.ownProject = ownProject;
34384
+ }
34385
+ get mayMove() {
34386
+ return this.ownProject !== null;
34355
34387
  }
34356
34388
  get port() {
34357
34389
  return this.boundPort ?? this.wantedPort;
@@ -34597,6 +34629,10 @@ var init_godot_bridge = __esm(() => {
34597
34629
  }
34598
34630
  case "godot_ready":
34599
34631
  if (this.connectionInfo) {
34632
+ if (this.belongsElsewhere(message.project_path)) {
34633
+ this.sendElsewhere(message.project_path);
34634
+ return;
34635
+ }
34600
34636
  this.connectionInfo.projectPath = message.project_path;
34601
34637
  this.connectionInfo.addonVersion = message.addon_version ?? "";
34602
34638
  this.connectionInfo.editorPid = message.editor_pid;
@@ -34679,6 +34715,21 @@ var init_godot_bridge = __esm(() => {
34679
34715
  clearInterval(this.pingInterval);
34680
34716
  this.pingInterval = null;
34681
34717
  }
34718
+ belongsElsewhere(theirProject) {
34719
+ if (this.ownProject === null || theirProject.trim() === "") {
34720
+ return false;
34721
+ }
34722
+ return !isSameDirectory(this.ownProject, theirProject);
34723
+ }
34724
+ sendElsewhere(theirProject) {
34725
+ const said = `this server serves ${this.ownProject ?? ""}, and that editor has ${theirProject} open`;
34726
+ this.log("warn", `Turning away an editor from another project: ${said}`);
34727
+ const stranger = this.socket;
34728
+ this.socket = null;
34729
+ this.connectionInfo = null;
34730
+ this.stopKeepalive();
34731
+ stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
34732
+ }
34682
34733
  handleDisconnect(disconnectedSocket, reason) {
34683
34734
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
34684
34735
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -35603,7 +35654,7 @@ var init_lsp_client = __esm(() => {
35603
35654
  });
35604
35655
 
35605
35656
  // src/project-scan.ts
35606
- import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
35657
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
35607
35658
  import { join as join12 } from "node:path";
35608
35659
  function projectStructure(projectPath) {
35609
35660
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -35666,7 +35717,7 @@ function searchProject(projectPath, options) {
35666
35717
  }
35667
35718
  result.summary.files_searched += 1;
35668
35719
  const matches = [];
35669
- for (const [index, line] of readFileSync9(entryPath, "utf8").split(`
35720
+ for (const [index, line] of readFileSync10(entryPath, "utf8").split(`
35670
35721
  `).entries()) {
35671
35722
  if (full()) {
35672
35723
  break;
@@ -35694,7 +35745,7 @@ var init_project_scan = __esm(() => {
35694
35745
  });
35695
35746
 
35696
35747
  // src/runtime-client.ts
35697
- import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10, unlinkSync } from "node:fs";
35748
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync11, unlinkSync } from "node:fs";
35698
35749
  import { createConnection as createConnection3 } from "node:net";
35699
35750
  import { tmpdir as tmpdir4 } from "node:os";
35700
35751
  import { join as join13, resolve as resolve4 } from "node:path";
@@ -35725,7 +35776,7 @@ function processAlive(pid) {
35725
35776
  function parseAnnouncement(file, pid) {
35726
35777
  let fields;
35727
35778
  try {
35728
- fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
35779
+ fields = asParams(JSON.parse(readFileSync11(file, "utf8")));
35729
35780
  } catch {
35730
35781
  return { kind: "rubbish" };
35731
35782
  }
@@ -35918,14 +35969,14 @@ var init_runtime_client = __esm(() => {
35918
35969
  });
35919
35970
 
35920
35971
  // src/setup.ts
35921
- import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35972
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35922
35973
  import { dirname as dirname8, join as join14 } from "node:path";
35923
35974
  function installedAddonVersion(projectPath) {
35924
35975
  const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
35925
35976
  if (!existsSync11(marker)) {
35926
35977
  return null;
35927
35978
  }
35928
- const said = readFileSync11(marker, "utf8").trim();
35979
+ const said = readFileSync12(marker, "utf8").trim();
35929
35980
  return said === "" ? null : said;
35930
35981
  }
35931
35982
  var ADDONS2, VERSION_MARKER2 = ".gdharness-version";
@@ -35939,7 +35990,7 @@ var init_setup = __esm(() => {
35939
35990
 
35940
35991
  // src/server.ts
35941
35992
  import { execFile as execFile5, spawn } from "node:child_process";
35942
- import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35993
+ import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35943
35994
  import { tmpdir as tmpdir5 } from "node:os";
35944
35995
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
35945
35996
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -36020,11 +36071,11 @@ function aboveTheRunner(entry) {
36020
36071
  };
36021
36072
  }
36022
36073
  function hasMainScene(projectFile) {
36023
- const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
36074
+ const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36024
36075
  return typeof scene === "string" && scene !== "";
36025
36076
  }
36026
36077
  function editorPlaysHeadless(projectFile) {
36027
- const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36078
+ const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36028
36079
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36029
36080
  }
36030
36081
  function camelCased(params) {
@@ -36052,13 +36103,14 @@ class GodotServer {
36052
36103
  dapClient = null;
36053
36104
  bridgeStartupError = null;
36054
36105
  bridgeRetry = null;
36106
+ successorWatch = null;
36055
36107
  lastProjectPath = null;
36056
36108
  shutdownInitiated = false;
36057
36109
  ownProject;
36058
36110
  announcedAt = null;
36059
36111
  constructor() {
36060
36112
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
36061
- this.godotBridge = getDefaultBridge(this.ownProject !== null);
36113
+ this.godotBridge = getDefaultBridge(this.ownProject);
36062
36114
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
36063
36115
  this.setupToolHandlers();
36064
36116
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -36151,6 +36203,7 @@ class GodotServer {
36151
36203
  port: status.port,
36152
36204
  version: SERVER_VERSION
36153
36205
  });
36206
+ this.watchForASuccessor();
36154
36207
  }
36155
36208
  stopTryingTheBridge() {
36156
36209
  if (this.bridgeRetry !== null) {
@@ -36158,9 +36211,46 @@ class GodotServer {
36158
36211
  this.bridgeRetry = null;
36159
36212
  }
36160
36213
  }
36214
+ watchForASuccessor() {
36215
+ if (this.ownProject === null || this.successorWatch !== null) {
36216
+ return;
36217
+ }
36218
+ const watch = setInterval(() => {
36219
+ const successor = this.supersededBy();
36220
+ if (successor === null) {
36221
+ return;
36222
+ }
36223
+ console.error(`[SERVER] pid ${successor} now serves ${this.ownProject}; this server is finished and is stopping.`);
36224
+ this.handleShutdown(`superseded by ${successor}`, 0);
36225
+ }, SUCCESSOR_CHECK_MS);
36226
+ watch.unref();
36227
+ this.successorWatch = watch;
36228
+ }
36229
+ supersededBy() {
36230
+ if (this.ownProject === null) {
36231
+ return null;
36232
+ }
36233
+ const announced = readAnnouncement(announcementPath(this.ownProject));
36234
+ if (announced === null || announced.pid === process.pid) {
36235
+ return null;
36236
+ }
36237
+ try {
36238
+ process.kill(announced.pid, 0);
36239
+ return announced.pid;
36240
+ } catch {
36241
+ return null;
36242
+ }
36243
+ }
36244
+ stopWatchingForASuccessor() {
36245
+ if (this.successorWatch !== null) {
36246
+ clearInterval(this.successorWatch);
36247
+ this.successorWatch = null;
36248
+ }
36249
+ }
36161
36250
  async cleanup() {
36162
36251
  this.logDebug("Cleaning up resources");
36163
36252
  this.stopTryingTheBridge();
36253
+ this.stopWatchingForASuccessor();
36164
36254
  withdrawBridge(this.announcedAt);
36165
36255
  this.announcedAt = null;
36166
36256
  if (this.activeProcess) {
@@ -36742,7 +36832,7 @@ class GodotServer {
36742
36832
  if (!engine.ok) {
36743
36833
  return engine.response;
36744
36834
  }
36745
- const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
36835
+ const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
36746
36836
  const name = application["config/name"];
36747
36837
  const mainScene = application["run/main_scene"];
36748
36838
  const info = {
@@ -36917,7 +37007,7 @@ class GodotServer {
36917
37007
  const written = existsSync12(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36918
37008
  const newest = written.at(-1);
36919
37009
  if (newest !== undefined) {
36920
- report = parseJUnit(readFileSync12(join15(reportsDir, newest, "results.xml"), "utf8"));
37010
+ report = parseJUnit(readFileSync13(join15(reportsDir, newest, "results.xml"), "utf8"));
36921
37011
  }
36922
37012
  } catch (error) {
36923
37013
  reportProblem = errorMessage(error);
@@ -37507,7 +37597,7 @@ class GodotServer {
37507
37597
  return {
37508
37598
  content: [
37509
37599
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37510
- { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
37600
+ { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
37511
37601
  ]
37512
37602
  };
37513
37603
  } finally {
@@ -37573,7 +37663,7 @@ async function runGodotServer() {
37573
37663
  const server = new GodotServer;
37574
37664
  await server.run();
37575
37665
  }
37576
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37666
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37577
37667
  var init_server3 = __esm(() => {
37578
37668
  init_mcp();
37579
37669
  init_stdio2();
@@ -18,6 +18,10 @@ const ANNOUNCE_PROTOCOL: int = 1
18
18
  const RECONNECT_DELAY: float = 3.0
19
19
  const MAX_RECONNECT_DELAY: float = 30.0
20
20
 
21
+ ## What a server closes with when the editor that said hello belongs to another project. Kept in
22
+ ## step with `OTHER_PROJECT_CLOSE_CODE` in src/godot-bridge.ts.
23
+ const ELSEWHERE_CLOSE_CODE: int = 4001
24
+
21
25
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
22
26
  ## file is one for every editor on the machine, so two open at once want the same three and the
23
27
  ## second one binds nothing.
@@ -67,6 +71,10 @@ var _since_looked: float = 0.0
67
71
  ## anything connects or the project names a different one.
68
72
  var _refused_url: String = ""
69
73
  var _tried_announced: bool = false
74
+
75
+ ## The address this editor has already been told is another project's, so a reconnect loop says it
76
+ ## once rather than every few seconds for the rest of the session.
77
+ var _told_about_url: String = ""
70
78
  var _connecting_for: float = 0.0
71
79
 
72
80
 
@@ -343,12 +351,35 @@ func _loaded_version() -> String:
343
351
 
344
352
  func _handle_disconnect() -> void:
345
353
  _is_connected = false
354
+ _said_elsewhere()
346
355
  disconnected.emit()
347
356
 
348
357
  if _should_reconnect:
349
358
  _schedule_reconnect()
350
359
 
351
360
 
361
+ ## Says so when the server turned this editor away as another project's.
362
+ ##
363
+ ## The fallback address is tried by every project whose own server has not run yet and so has
364
+ ## announced nothing, which means any server holding it took the connection and served it. It is
365
+ ## refused now, and the refusal is worth a person's attention rather than a quiet backoff: nothing
366
+ ## about waiting reaches a server that is not this project's, and the answer is to start one.
367
+ ##
368
+ ## Said once per address. This is a reconnect loop, and a line every few seconds for the rest of
369
+ ## the session is a line nobody reads.
370
+ func _said_elsewhere() -> void:
371
+ if socket.get_close_code() != ELSEWHERE_CLOSE_CODE:
372
+ return
373
+ if _told_about_url == server_url:
374
+ return
375
+ _told_about_url = server_url
376
+ var said: String = (
377
+ "[gdharness] %s is another project's server, so this editor is not connected: %s."
378
+ + " Start this project's own server, or point it at one with GDHARNESS_BRIDGE_PORT."
379
+ )
380
+ push_warning(said % [server_url, socket.get_close_reason()])
381
+
382
+
352
383
  func _schedule_reconnect() -> void:
353
384
  if _reconnect_timer == null:
354
385
  return
@@ -2,6 +2,8 @@ extends RefCounted
2
2
 
3
3
  ## A picture of the running game, as a PNG written where the server asked for it.
4
4
 
5
+ const Values = preload("runtime_values.gd")
6
+
5
7
  var _host: Node
6
8
 
7
9
 
@@ -18,9 +20,10 @@ func capture_viewport(params: Dictionary) -> Dictionary:
18
20
  if viewport_path.is_empty():
19
21
  return capture_screenshot(params)
20
22
 
21
- var node: Node = _host.get_tree().root.get_node_or_null(viewport_path)
22
- if node == null:
23
- return {"type": "error", "message": "Viewport not found: " + viewport_path}
23
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, viewport_path)
24
+ if standing.has("message"):
25
+ return standing
26
+ var node: Node = standing["node"]
24
27
  if not node is Viewport:
25
28
  return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
26
29
  return _capture(node, params)
@@ -440,9 +440,10 @@ func click(params: Dictionary) -> Dictionary:
440
440
  if node_path.is_empty():
441
441
  return {"type": "error", "message": "Node path required"}
442
442
 
443
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
444
- if node == null:
445
- return {"type": "error", "message": "Node not found: " + node_path}
443
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
444
+ if standing.has("message"):
445
+ return standing
446
+ var node: Node = standing["node"]
446
447
  if node is Node3D:
447
448
  return await _click_in_the_world(node_path, node, params)
448
449
  if not node is Control:
@@ -580,9 +581,10 @@ func choose(params: Dictionary) -> Dictionary:
580
581
  var node_path: String = str(params.get("path", ""))
581
582
  if node_path.is_empty():
582
583
  return {"type": "error", "message": "Node path required"}
583
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
584
- if node == null:
585
- return {"type": "error", "message": "Node not found: " + node_path}
584
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
585
+ if standing.has("message"):
586
+ return standing
587
+ var node: Node = standing["node"]
586
588
 
587
589
  var menu: PopupMenu = _menu_of(node)
588
590
  if menu == null:
@@ -34,9 +34,10 @@ func get_tree(params: Dictionary) -> Dictionary:
34
34
  var max_depth: int = int(params.get("depth", 3))
35
35
  var include_properties: bool = bool(params.get("include_properties", false))
36
36
 
37
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
38
- if root == null:
39
- return {"type": "error", "message": "Node not found: " + root_path}
37
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
38
+ if reached.has("message"):
39
+ return reached
40
+ var root: Node = reached["node"]
40
41
 
41
42
  return {"type": "tree", "root": _serialize_node_tree(root, 0, max_depth, include_properties)}
42
43
 
@@ -64,9 +65,10 @@ func find_nodes(params: Dictionary) -> Dictionary:
64
65
  if not wanted["script"].is_empty() and not wanted["script"].begins_with("res://"):
65
66
  wanted["script"] = "res://" + wanted["script"]
66
67
 
67
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
68
- if root == null:
69
- return {"type": "error", "message": "Node not found: " + root_path}
68
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
69
+ if reached.has("message"):
70
+ return reached
71
+ var root: Node = reached["node"]
70
72
 
71
73
  var found: Array[Dictionary] = []
72
74
  var pending: Array[Node] = [root]
@@ -128,7 +130,7 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
128
130
  entry["has_property"] = false
129
131
  return entry
130
132
  var holder: Object = reached["holder"]
131
- var named: String = reached["property"]
133
+ var named: String = reached["name"]
132
134
  var has: bool = _has_property(holder, named)
133
135
  entry["has_property"] = has
134
136
  if has:
@@ -208,9 +210,10 @@ func read_text(params: Dictionary) -> Dictionary:
208
210
  var include_hidden: bool = bool(params.get("include_hidden", false))
209
211
  var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
210
212
 
211
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
212
- if root == null:
213
- return {"type": "error", "message": "Node not found: " + root_path}
213
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
214
+ if reached.has("message"):
215
+ return reached
216
+ var root: Node = reached["node"]
214
217
 
215
218
  # One line further than asked for, so that whether anything was left behind is read off the
216
219
  # walk rather than guessed at from the count: a panel of exactly as many lines as the caller
@@ -275,9 +278,10 @@ func get_rect(params: Dictionary) -> Dictionary:
275
278
  if node_path.is_empty():
276
279
  return {"type": "error", "message": "Node path required"}
277
280
 
278
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
279
- if node == null:
280
- return {"type": "error", "message": "Node not found: " + node_path}
281
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
282
+ if standing.has("message"):
283
+ return standing
284
+ var node: Node = standing["node"]
281
285
 
282
286
  if node is Control:
283
287
  var control: Control = node
@@ -426,16 +430,17 @@ func get_property(params: Dictionary) -> Dictionary:
426
430
  if node_path.is_empty() or property.is_empty():
427
431
  return {"type": "error", "message": "Node path and property required"}
428
432
 
429
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
430
- if node == null:
431
- return {"type": "error", "message": "Node not found: " + node_path}
433
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
434
+ if standing.has("message"):
435
+ return standing
436
+ var node: Node = standing["node"]
432
437
 
433
438
  var reached: Dictionary = _reached(node, node_path, property)
434
439
  if reached.has("message"):
435
440
  return reached
436
441
 
437
442
  var holder: Object = reached["holder"]
438
- var named: String = reached["property"]
443
+ var named: String = reached["name"]
439
444
  # Asked of the property list rather than read and compared to null, because a property the
440
445
  # holder does not have and a property that is null both read as null.
441
446
  if not _has_property(holder, named):
@@ -449,11 +454,14 @@ func get_property(params: Dictionary) -> Dictionary:
449
454
  }
450
455
 
451
456
 
452
- ## What [param property] is a property of, which is the node itself until the name has a colon in
453
- ## it, and the last name along that path.
457
+ ## What [param reaching] names something on, which is the node itself until the name has a colon
458
+ ## in it, and the last name along that path.
454
459
  ##
455
460
  ## A game's state does not sit on nodes, it hangs off them: the speed of the clock is a property of
456
461
  ## a [RefCounted] held by a [RefCounted] held by the root, and that is what an agent asks about.
462
+ ## What a game does hangs off them the same way, so the last name is a property to read, a property
463
+ ## to write or a method to call, and the walk to it is one walk.
464
+ ##
457
465
  ## Colons, because that is the separator [method Object.get_indexed] already takes. Walked a step
458
466
  ## at a time rather than handed to that method, which answers null for a path that goes wrong
459
467
  ## halfway along and for one that ends on null.
@@ -461,8 +469,8 @@ func get_property(params: Dictionary) -> Dictionary:
461
469
  ## Answers with a `message` instead when a step along the way is not there or holds something that
462
470
  ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
463
471
  ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
464
- func _reached(node: Node, node_path: String, property: String) -> Dictionary:
465
- var parts: PackedStringArray = property.split(":")
472
+ func _reached(node: Node, node_path: String, reaching: String) -> Dictionary:
473
+ var parts: PackedStringArray = reaching.split(":")
466
474
  var holder: Object = node
467
475
  var called: String = node_path
468
476
  for step: int in parts.size() - 1:
@@ -475,7 +483,7 @@ func _reached(node: Node, node_path: String, property: String) -> Dictionary:
475
483
  var wanted: String = parts[step + 1]
476
484
  return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
477
485
  holder = next
478
- return {"holder": holder, "property": parts[parts.size() - 1], "called": called}
486
+ return {"holder": holder, "name": parts[parts.size() - 1], "called": called}
479
487
 
480
488
 
481
489
  ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
@@ -495,9 +503,10 @@ func set_property(params: Dictionary) -> Dictionary:
495
503
  if node_path.is_empty() or property.is_empty():
496
504
  return {"type": "error", "message": "Node path and property required"}
497
505
 
498
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
499
- if node == null:
500
- return {"type": "error", "message": "Node not found: " + node_path}
506
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
507
+ if standing.has("message"):
508
+ return standing
509
+ var node: Node = standing["node"]
501
510
 
502
511
  # Through a path as well, for the reason [method _reached] gives, and read back off the same
503
512
  # holder afterwards: a set that does not take says so by answering with the old value, which is
@@ -507,7 +516,7 @@ func set_property(params: Dictionary) -> Dictionary:
507
516
  return reached
508
517
 
509
518
  var holder: Object = reached["holder"]
510
- var named: String = reached["property"]
519
+ var named: String = reached["name"]
511
520
  var old_value: Variant = holder.get(named)
512
521
  holder.set(named, _values.fitted(value, typeof(old_value)))
513
522
 
@@ -528,18 +537,28 @@ func call_method(params: Dictionary) -> Dictionary:
528
537
  if node_path.is_empty() or method.is_empty():
529
538
  return {"type": "error", "message": "Node path and method required"}
530
539
 
531
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
532
- if node == null:
533
- return {"type": "error", "message": "Node not found: " + node_path}
540
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
541
+ if standing.has("message"):
542
+ return standing
543
+ var node: Node = standing["node"]
534
544
 
535
- if not node.has_method(method):
536
- return {"type": "error", "message": "Method not found: " + method}
545
+ # Through a path as well, for the reason [method _reached] gives. What a game does hangs off its
546
+ # nodes as much as its state does, so reading `_game:run:day` while being unable to call
547
+ # `_game:run:advance` answers half of what a node holds and refuses the other half.
548
+ var reached: Dictionary = _reached(node, node_path, method)
549
+ if reached.has("message"):
550
+ return reached
551
+
552
+ var holder: Object = reached["holder"]
553
+ var named: String = reached["name"]
554
+ if not holder.has_method(named):
555
+ return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
537
556
 
538
557
  var deserialized_args: Array = []
539
558
  for index: int in args.size():
540
- deserialized_args.append(_values.fitted(args[index], _values.parameter_type(node, method, index)))
559
+ deserialized_args.append(_values.fitted(args[index], _values.parameter_type(holder, named, index)))
541
560
 
542
- var result: Variant = node.callv(method, deserialized_args)
561
+ var result: Variant = holder.callv(named, deserialized_args)
543
562
 
544
563
  return {"type": "method_result", "path": node_path, "method": method, "result": _values.serialize(result)}
545
564
 
@@ -42,6 +42,37 @@ func serialize(value: Variant) -> Variant:
42
42
  return call(serialiser, value) if not serialiser.is_empty() else value
43
43
 
44
44
 
45
+ ## The node [param path] names under [param root], or a refusal saying why there is not one.
46
+ ##
47
+ ## The other direction of what a node serialises as, and here for that reason: a node crosses the
48
+ ## wire as its path, so a path crossing back is a node or it is nothing. One answer for every op,
49
+ ## because each of them read a path for itself and they could disagree about what one means.
50
+ ##
51
+ ## A colon is the disagreement worth its own sentence. Godot reads everything after the first one
52
+ ## as subnames and [method Node.get_node_or_null] drops them, so "/root/Main:_game:run" was quietly
53
+ ## answered about "/root/Main", and an op that then said the path had no method `advance` was right
54
+ ## about an object nobody had asked for. Colons reach through what a node holds, and the arguments
55
+ ## that take them are the property and the method.
56
+ static func node_at(root: Node, path: String) -> Dictionary:
57
+ if path.is_empty():
58
+ return {"type": "error", "message": "Node path required"}
59
+
60
+ if path.contains(":"):
61
+ var shape: String = (
62
+ '"%s" names a node and colons reach past one: "%s" is the node,'
63
+ + ' and "%s" goes in the property or the method'
64
+ )
65
+ return {
66
+ "type": "error",
67
+ "message": shape % [path, path.get_slice(":", 0), path.substr(path.find(":") + 1)],
68
+ }
69
+
70
+ var node: Node = root.get_node_or_null(path)
71
+ if node == null:
72
+ return {"type": "error", "message": "Node not found: " + path}
73
+ return {"node": node}
74
+
75
+
45
76
  func _serialize_nil(_value: Variant) -> Variant:
46
77
  return null
47
78
 
@@ -53,9 +53,10 @@ func wait_signal(params: Dictionary) -> Dictionary:
53
53
  if node_path.is_empty() or signal_name.is_empty():
54
54
  return {"type": "error", "message": "Node path and signal name required"}
55
55
 
56
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
57
- if node == null:
58
- return {"type": "error", "message": "Node not found: " + node_path}
56
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
57
+ if standing.has("message"):
58
+ return standing
59
+ var node: Node = standing["node"]
59
60
  if not node.has_signal(signal_name):
60
61
  return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
61
62
 
@@ -90,8 +91,9 @@ func wait_until(params: Dictionary) -> Dictionary:
90
91
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
91
92
  if node_path.is_empty():
92
93
  return {"type": "error", "message": "Node path required"}
93
- if _host.get_tree().root.get_node_or_null(node_path) == null:
94
- return {"type": "error", "message": "Node not found: " + node_path}
94
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
95
+ if standing.has("message"):
96
+ return standing
95
97
  if not says.is_empty():
96
98
  return await _wait_until_said(node_path, says, timeout_ms)
97
99
  if property.is_empty():
@@ -99,7 +101,7 @@ func wait_until(params: Dictionary) -> Dictionary:
99
101
  if not params.has("value"):
100
102
  return {"type": "error", "message": "A value to wait for is required"}
101
103
 
102
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
104
+ var node: Node = standing["node"]
103
105
 
104
106
  var current: Variant = node.get(property)
105
107
  var wanted: Variant = _values.fitted(params["value"], typeof(current))
package/build/index.js CHANGED
@@ -10126,7 +10126,7 @@ function defectReport(where, error, godotVersion) {
10126
10126
 
10127
10127
  // src/server.ts
10128
10128
  import { execFile as execFile3, spawn } from "node:child_process";
10129
- import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync8, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10129
+ import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
10131
  import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10132
10132
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -24697,12 +24697,30 @@ class StdioServerTransport {
24697
24697
  }
24698
24698
 
24699
24699
  // src/bridge-announce.ts
24700
- import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
24700
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
24701
24701
  import { dirname, join as join2 } from "node:path";
24702
24702
  function announcementPath(projectPath) {
24703
24703
  return join2(projectPath, ".godot", "gdharness-bridge.json");
24704
24704
  }
24705
24705
  var BRIDGE_ANNOUNCE_PROTOCOL = 1;
24706
+ function readAnnouncement(path) {
24707
+ try {
24708
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
24709
+ if (typeof parsed !== "object" || parsed === null) {
24710
+ return null;
24711
+ }
24712
+ const said = parsed;
24713
+ if (typeof said.port !== "number" || typeof said.pid !== "number") {
24714
+ return null;
24715
+ }
24716
+ return said;
24717
+ } catch {
24718
+ return null;
24719
+ }
24720
+ }
24721
+ function announcementIsOurs(path) {
24722
+ return readAnnouncement(path)?.pid === process.pid;
24723
+ }
24706
24724
  function announceBridge(projectPath, bridge) {
24707
24725
  const path = announcementPath(projectPath);
24708
24726
  try {
@@ -24724,7 +24742,7 @@ function announceBridge(projectPath, bridge) {
24724
24742
  }
24725
24743
  }
24726
24744
  function withdrawBridge(path) {
24727
- if (path === null) {
24745
+ if (path === null || !announcementIsOurs(path)) {
24728
24746
  return;
24729
24747
  }
24730
24748
  try {
@@ -24735,7 +24753,7 @@ function withdrawBridge(path) {
24735
24753
  }
24736
24754
 
24737
24755
  // src/class-cache.ts
24738
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
24756
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync4 } from "node:fs";
24739
24757
  import { join as join3 } from "node:path";
24740
24758
  function declaredClasses(projectPath) {
24741
24759
  const declared = new Map;
@@ -24748,7 +24766,7 @@ function declaredClasses(projectPath) {
24748
24766
  if (entry.isDirectory()) {
24749
24767
  visit(path, `${prefix}${entry.name}/`);
24750
24768
  } else if (entry.isFile() && entry.name.endsWith(".gd")) {
24751
- const found = /^class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec(readFileSync3(path, "utf8"));
24769
+ const found = /^class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec(readFileSync4(path, "utf8"));
24752
24770
  if (found?.[1]) {
24753
24771
  declared.set(found[1], `res://${prefix}${entry.name}`);
24754
24772
  }
@@ -24764,7 +24782,7 @@ function cachedClasses(projectPath) {
24764
24782
  return null;
24765
24783
  }
24766
24784
  const listed = new Map;
24767
- const text = readFileSync3(cache, "utf8");
24785
+ const text = readFileSync4(cache, "utf8");
24768
24786
  for (const entry of text.matchAll(/"class":\s*&"([^"]+)"[\s\S]*?"path":\s*"([^"]+)"/g)) {
24769
24787
  listed.set(entry[1] ?? "", entry[2] ?? "");
24770
24788
  }
@@ -25494,12 +25512,64 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
25494
25512
  var import_websocket = __toESM(require_websocket(), 1);
25495
25513
  var import_websocket_server = __toESM(require_websocket_server(), 1);
25496
25514
 
25515
+ // src/paths.ts
25516
+ import { isAbsolute, relative, resolve, sep } from "node:path";
25517
+ var RESOURCE_SCHEME = "res://";
25518
+ var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
25519
+ function isSameDirectory(onePath, otherPath) {
25520
+ return relative(resolve(onePath), resolve(otherPath)) === "";
25521
+ }
25522
+ function isWithinRoot(rootPath, candidatePath) {
25523
+ const step = relative(resolve(rootPath), resolve(candidatePath));
25524
+ return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
25525
+ }
25526
+ function resolveWithinProject(projectPath, candidatePath) {
25527
+ const trimmed = candidatePath.trim();
25528
+ if (trimmed === "") {
25529
+ return { ok: false, reason: "Path is empty." };
25530
+ }
25531
+ if (trimmed.includes("\x00")) {
25532
+ return { ok: false, reason: "Path contains a null byte." };
25533
+ }
25534
+ const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
25535
+ const scheme = OTHER_SCHEME.exec(withoutScheme);
25536
+ if (scheme) {
25537
+ return {
25538
+ ok: false,
25539
+ reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
25540
+ };
25541
+ }
25542
+ if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
25543
+ return {
25544
+ ok: false,
25545
+ reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
25546
+ };
25547
+ }
25548
+ if (withoutScheme.includes(":")) {
25549
+ return {
25550
+ ok: false,
25551
+ reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
25552
+ };
25553
+ }
25554
+ const root = resolve(projectPath);
25555
+ const absolutePath = resolve(root, withoutScheme);
25556
+ if (!isWithinRoot(root, absolutePath)) {
25557
+ return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
25558
+ }
25559
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
25560
+ if (relativePath === "") {
25561
+ return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
25562
+ }
25563
+ return { ok: true, absolutePath, relativePath };
25564
+ }
25565
+
25497
25566
  // src/godot-bridge.ts
25498
25567
  var DEFAULT_PORT = 6505;
25499
25568
  var DEFAULT_HOST = "127.0.0.1";
25500
25569
  var DEFAULT_TIMEOUT_MS = 30000;
25501
25570
  var KEEPALIVE_INTERVAL_MS = 1e4;
25502
25571
  var SECOND_CONNECTION_CLOSE_CODE = 4000;
25572
+ var OTHER_PROJECT_CLOSE_CODE = 4001;
25503
25573
  function resolveDefaultBridgePort() {
25504
25574
  return portFromEnv("GDHARNESS_BRIDGE_PORT", DEFAULT_PORT);
25505
25575
  }
@@ -25523,13 +25593,16 @@ class GodotBridge extends EventEmitter {
25523
25593
  boundPort = null;
25524
25594
  host;
25525
25595
  timeoutMs;
25526
- mayMove;
25527
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
25596
+ ownProject;
25597
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, ownProject = null) {
25528
25598
  super();
25529
25599
  this.wantedPort = port;
25530
25600
  this.host = host;
25531
25601
  this.timeoutMs = timeoutMs;
25532
- this.mayMove = mayMove;
25602
+ this.ownProject = ownProject;
25603
+ }
25604
+ get mayMove() {
25605
+ return this.ownProject !== null;
25533
25606
  }
25534
25607
  get port() {
25535
25608
  return this.boundPort ?? this.wantedPort;
@@ -25775,6 +25848,10 @@ class GodotBridge extends EventEmitter {
25775
25848
  }
25776
25849
  case "godot_ready":
25777
25850
  if (this.connectionInfo) {
25851
+ if (this.belongsElsewhere(message.project_path)) {
25852
+ this.sendElsewhere(message.project_path);
25853
+ return;
25854
+ }
25778
25855
  this.connectionInfo.projectPath = message.project_path;
25779
25856
  this.connectionInfo.addonVersion = message.addon_version ?? "";
25780
25857
  this.connectionInfo.editorPid = message.editor_pid;
@@ -25857,6 +25934,21 @@ class GodotBridge extends EventEmitter {
25857
25934
  clearInterval(this.pingInterval);
25858
25935
  this.pingInterval = null;
25859
25936
  }
25937
+ belongsElsewhere(theirProject) {
25938
+ if (this.ownProject === null || theirProject.trim() === "") {
25939
+ return false;
25940
+ }
25941
+ return !isSameDirectory(this.ownProject, theirProject);
25942
+ }
25943
+ sendElsewhere(theirProject) {
25944
+ const said = `this server serves ${this.ownProject ?? ""}, and that editor has ${theirProject} open`;
25945
+ this.log("warn", `Turning away an editor from another project: ${said}`);
25946
+ const stranger = this.socket;
25947
+ this.socket = null;
25948
+ this.connectionInfo = null;
25949
+ this.stopKeepalive();
25950
+ stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
25951
+ }
25860
25952
  handleDisconnect(disconnectedSocket, reason) {
25861
25953
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
25862
25954
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -25933,8 +26025,8 @@ class GodotBridge extends EventEmitter {
25933
26025
  }
25934
26026
  }
25935
26027
  var defaultBridge = null;
25936
- function getDefaultBridge(mayMove = false) {
25937
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
26028
+ function getDefaultBridge(ownProject = null) {
26029
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
25938
26030
  return defaultBridge;
25939
26031
  }
25940
26032
 
@@ -26495,56 +26587,6 @@ import { readFile, realpath } from "node:fs/promises";
26495
26587
  import { createConnection as createConnection2 } from "node:net";
26496
26588
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
26497
26589
  import { fileURLToPath, pathToFileURL } from "node:url";
26498
-
26499
- // src/paths.ts
26500
- import { isAbsolute, relative, resolve, sep } from "node:path";
26501
- var RESOURCE_SCHEME = "res://";
26502
- var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
26503
- function isWithinRoot(rootPath, candidatePath) {
26504
- const step = relative(resolve(rootPath), resolve(candidatePath));
26505
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
26506
- }
26507
- function resolveWithinProject(projectPath, candidatePath) {
26508
- const trimmed = candidatePath.trim();
26509
- if (trimmed === "") {
26510
- return { ok: false, reason: "Path is empty." };
26511
- }
26512
- if (trimmed.includes("\x00")) {
26513
- return { ok: false, reason: "Path contains a null byte." };
26514
- }
26515
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
26516
- const scheme = OTHER_SCHEME.exec(withoutScheme);
26517
- if (scheme) {
26518
- return {
26519
- ok: false,
26520
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
26521
- };
26522
- }
26523
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
26524
- return {
26525
- ok: false,
26526
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
26527
- };
26528
- }
26529
- if (withoutScheme.includes(":")) {
26530
- return {
26531
- ok: false,
26532
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
26533
- };
26534
- }
26535
- const root = resolve(projectPath);
26536
- const absolutePath = resolve(root, withoutScheme);
26537
- if (!isWithinRoot(root, absolutePath)) {
26538
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
26539
- }
26540
- const relativePath = relative(root, absolutePath).split(sep).join("/");
26541
- if (relativePath === "") {
26542
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
26543
- }
26544
- return { ok: true, absolutePath, relativePath };
26545
- }
26546
-
26547
- // src/lsp_client.ts
26548
26590
  var DEFAULT_LSP_PORT = 6005;
26549
26591
  var DIAGNOSTICS_TIMEOUT_MS = 5000;
26550
26592
  function diagnosticsKey(uri) {
@@ -27038,7 +27080,7 @@ async function handleLSPTool(client, toolName, args) {
27038
27080
  }
27039
27081
 
27040
27082
  // src/project-scan.ts
27041
- import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
27083
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
27042
27084
  import { join as join6 } from "node:path";
27043
27085
  var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
27044
27086
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
@@ -27103,7 +27145,7 @@ function searchProject(projectPath, options) {
27103
27145
  }
27104
27146
  result.summary.files_searched += 1;
27105
27147
  const matches = [];
27106
- for (const [index, line] of readFileSync4(entryPath, "utf8").split(`
27148
+ for (const [index, line] of readFileSync5(entryPath, "utf8").split(`
27107
27149
  `).entries()) {
27108
27150
  if (full()) {
27109
27151
  break;
@@ -27126,7 +27168,7 @@ function searchProject(projectPath, options) {
27126
27168
  }
27127
27169
 
27128
27170
  // src/resources.ts
27129
- import { readFileSync as readFileSync5 } from "node:fs";
27171
+ import { readFileSync as readFileSync6 } from "node:fs";
27130
27172
  import { extname, resolve as resolve3 } from "node:path";
27131
27173
  var STATIC_RESOURCES = [
27132
27174
  {
@@ -27297,7 +27339,7 @@ function readResourceText(uri, getProjectPath) {
27297
27339
  const parsedUri = parseGodotUri(uri);
27298
27340
  if (parsedUri.kind === "project-info") {
27299
27341
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
27300
- const rawProject = readFileSync5(projectFilePath, "utf-8");
27342
+ const rawProject = readFileSync6(projectFilePath, "utf-8");
27301
27343
  const parsedProject = parseProjectGodot(rawProject);
27302
27344
  return {
27303
27345
  mimeType: "application/json",
@@ -27306,7 +27348,7 @@ function readResourceText(uri, getProjectPath) {
27306
27348
  }
27307
27349
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
27308
27350
  ensureAllowedExtension(parsedUri.kind, filePath);
27309
- const text = readFileSync5(filePath, "utf-8");
27351
+ const text = readFileSync6(filePath, "utf-8");
27310
27352
  return {
27311
27353
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
27312
27354
  text
@@ -27343,7 +27385,7 @@ function setupResourceHandlers(mcp, getProjectPath) {
27343
27385
  }
27344
27386
 
27345
27387
  // src/runtime-client.ts
27346
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, unlinkSync } from "node:fs";
27388
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
27347
27389
  import { createConnection as createConnection3 } from "node:net";
27348
27390
  import { tmpdir as tmpdir3 } from "node:os";
27349
27391
  import { join as join7, resolve as resolve4 } from "node:path";
@@ -27429,7 +27471,7 @@ function processAlive(pid) {
27429
27471
  function parseAnnouncement(file, pid) {
27430
27472
  let fields;
27431
27473
  try {
27432
- fields = asParams(JSON.parse(readFileSync6(file, "utf8")));
27474
+ fields = asParams(JSON.parse(readFileSync7(file, "utf8")));
27433
27475
  } catch {
27434
27476
  return { kind: "rubbish" };
27435
27477
  }
@@ -27619,7 +27661,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27619
27661
  }
27620
27662
 
27621
27663
  // src/setup.ts
27622
- import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27664
+ import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27623
27665
  import { dirname as dirname3, join as join8 } from "node:path";
27624
27666
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27625
27667
  var VERSION_MARKER = ".gdharness-version";
@@ -27628,7 +27670,7 @@ function installedAddonVersion(projectPath) {
27628
27670
  if (!existsSync6(marker)) {
27629
27671
  return null;
27630
27672
  }
27631
- const said = readFileSync7(marker, "utf8").trim();
27673
+ const said = readFileSync8(marker, "utf8").trim();
27632
27674
  return said === "" ? null : said;
27633
27675
  }
27634
27676
 
@@ -28398,7 +28440,11 @@ var TOOL_SPECS = [
28398
28440
  blank: true,
28399
28441
  description: `set: the value, fitted to the property's type. "" writes an empty string.`
28400
28442
  },
28401
- method: { type: "string", ops: ["call"] },
28443
+ method: {
28444
+ type: "string",
28445
+ ops: ["call"],
28446
+ description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
28447
+ },
28402
28448
  args: {
28403
28449
  type: "array",
28404
28450
  ops: ["call"],
@@ -28411,7 +28457,10 @@ var TOOL_SPECS = [
28411
28457
  summary: "set a property, on a node or on an object it holds",
28412
28458
  requires: ["property", "value"]
28413
28459
  },
28414
- call: { summary: "call a method and return its result", requires: ["method"] }
28460
+ call: {
28461
+ summary: "call a method, on a node or on an object it holds, and return its result",
28462
+ requires: ["method"]
28463
+ }
28415
28464
  }
28416
28465
  },
28417
28466
  {
@@ -28714,6 +28763,7 @@ function wrongTypes(spec, args) {
28714
28763
  return complaints;
28715
28764
  }
28716
28765
  var BRIDGE_RETRY_MS = 2000;
28766
+ var SUCCESSOR_CHECK_MS = 1e4;
28717
28767
  var PATH_SOLUTIONS = [
28718
28768
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
28719
28769
  "Point projectPath at the project the file belongs to"
@@ -28801,11 +28851,11 @@ function aboveTheRunner(entry) {
28801
28851
  };
28802
28852
  }
28803
28853
  function hasMainScene(projectFile) {
28804
- const scene = parseProjectGodot(readFileSync8(projectFile, "utf8"))["application"]?.["run/main_scene"];
28854
+ const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
28805
28855
  return typeof scene === "string" && scene !== "";
28806
28856
  }
28807
28857
  function editorPlaysHeadless(projectFile) {
28808
- const runArgs = parseProjectGodot(readFileSync8(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28858
+ const runArgs = parseProjectGodot(readFileSync9(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28809
28859
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28810
28860
  }
28811
28861
  function camelCased(params) {
@@ -28833,13 +28883,14 @@ class GodotServer {
28833
28883
  dapClient = null;
28834
28884
  bridgeStartupError = null;
28835
28885
  bridgeRetry = null;
28886
+ successorWatch = null;
28836
28887
  lastProjectPath = null;
28837
28888
  shutdownInitiated = false;
28838
28889
  ownProject;
28839
28890
  announcedAt = null;
28840
28891
  constructor() {
28841
28892
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
28842
- this.godotBridge = getDefaultBridge(this.ownProject !== null);
28893
+ this.godotBridge = getDefaultBridge(this.ownProject);
28843
28894
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
28844
28895
  this.setupToolHandlers();
28845
28896
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -28932,6 +28983,7 @@ class GodotServer {
28932
28983
  port: status.port,
28933
28984
  version: SERVER_VERSION
28934
28985
  });
28986
+ this.watchForASuccessor();
28935
28987
  }
28936
28988
  stopTryingTheBridge() {
28937
28989
  if (this.bridgeRetry !== null) {
@@ -28939,9 +28991,46 @@ class GodotServer {
28939
28991
  this.bridgeRetry = null;
28940
28992
  }
28941
28993
  }
28994
+ watchForASuccessor() {
28995
+ if (this.ownProject === null || this.successorWatch !== null) {
28996
+ return;
28997
+ }
28998
+ const watch = setInterval(() => {
28999
+ const successor = this.supersededBy();
29000
+ if (successor === null) {
29001
+ return;
29002
+ }
29003
+ console.error(`[SERVER] pid ${successor} now serves ${this.ownProject}; this server is finished and is stopping.`);
29004
+ this.handleShutdown(`superseded by ${successor}`, 0);
29005
+ }, SUCCESSOR_CHECK_MS);
29006
+ watch.unref();
29007
+ this.successorWatch = watch;
29008
+ }
29009
+ supersededBy() {
29010
+ if (this.ownProject === null) {
29011
+ return null;
29012
+ }
29013
+ const announced = readAnnouncement(announcementPath(this.ownProject));
29014
+ if (announced === null || announced.pid === process.pid) {
29015
+ return null;
29016
+ }
29017
+ try {
29018
+ process.kill(announced.pid, 0);
29019
+ return announced.pid;
29020
+ } catch {
29021
+ return null;
29022
+ }
29023
+ }
29024
+ stopWatchingForASuccessor() {
29025
+ if (this.successorWatch !== null) {
29026
+ clearInterval(this.successorWatch);
29027
+ this.successorWatch = null;
29028
+ }
29029
+ }
28942
29030
  async cleanup() {
28943
29031
  this.logDebug("Cleaning up resources");
28944
29032
  this.stopTryingTheBridge();
29033
+ this.stopWatchingForASuccessor();
28945
29034
  withdrawBridge(this.announcedAt);
28946
29035
  this.announcedAt = null;
28947
29036
  if (this.activeProcess) {
@@ -29523,7 +29612,7 @@ class GodotServer {
29523
29612
  if (!engine.ok) {
29524
29613
  return engine.response;
29525
29614
  }
29526
- const application = parseProjectGodot(readFileSync8(project.value.file, "utf8"))["application"] ?? {};
29615
+ const application = parseProjectGodot(readFileSync9(project.value.file, "utf8"))["application"] ?? {};
29527
29616
  const name = application["config/name"];
29528
29617
  const mainScene = application["run/main_scene"];
29529
29618
  const info = {
@@ -29698,7 +29787,7 @@ class GodotServer {
29698
29787
  const written = existsSync7(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29699
29788
  const newest = written.at(-1);
29700
29789
  if (newest !== undefined) {
29701
- report = parseJUnit(readFileSync8(join9(reportsDir, newest, "results.xml"), "utf8"));
29790
+ report = parseJUnit(readFileSync9(join9(reportsDir, newest, "results.xml"), "utf8"));
29702
29791
  }
29703
29792
  } catch (error) {
29704
29793
  reportProblem = errorMessage(error);
@@ -30288,7 +30377,7 @@ class GodotServer {
30288
30377
  return {
30289
30378
  content: [
30290
30379
  { type: "text", text: `Screenshot captured: ${dimensions}` },
30291
- { type: "image", data: readFileSync8(screenshotPath).toString("base64"), mimeType: "image/png" }
30380
+ { type: "image", data: readFileSync9(screenshotPath).toString("base64"), mimeType: "image/png" }
30292
30381
  ]
30293
30382
  };
30294
30383
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",