gdharness 0.5.8 → 0.5.9

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
@@ -30646,6 +30646,47 @@ var init_stdio2 = __esm(() => {
30646
30646
  init_stdio();
30647
30647
  });
30648
30648
 
30649
+ // src/bridge-announce.ts
30650
+ import { mkdirSync as mkdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30651
+ import { dirname as dirname6, join as join11 } from "node:path";
30652
+ function announcementPath(projectPath) {
30653
+ return join11(projectPath, ".godot", "gdharness-bridge.json");
30654
+ }
30655
+ function announceBridge(projectPath, bridge) {
30656
+ const path = announcementPath(projectPath);
30657
+ try {
30658
+ mkdirSync5(dirname6(path), { recursive: true });
30659
+ const announcement = {
30660
+ protocol: BRIDGE_ANNOUNCE_PROTOCOL,
30661
+ host: bridge.host,
30662
+ port: bridge.port,
30663
+ pid: process.pid,
30664
+ version: bridge.version,
30665
+ startedAt: new Date().toISOString()
30666
+ };
30667
+ writeFileSync7(path, `${JSON.stringify(announcement, null, 2)}
30668
+ `, "utf8");
30669
+ return path;
30670
+ } catch (error) {
30671
+ console.error(`[SERVER] Could not announce the editor bridge at ${path}: ${errorMessage(error)}`);
30672
+ return null;
30673
+ }
30674
+ }
30675
+ function withdrawBridge(path) {
30676
+ if (path === null) {
30677
+ return;
30678
+ }
30679
+ try {
30680
+ rmSync6(path, { force: true });
30681
+ } catch (error) {
30682
+ console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
30683
+ }
30684
+ }
30685
+ var BRIDGE_ANNOUNCE_PROTOCOL = 1;
30686
+ var init_bridge_announce = __esm(() => {
30687
+ init_errors();
30688
+ });
30689
+
30649
30690
  // src/framing.ts
30650
30691
  function frame(message) {
30651
30692
  const body = Buffer.from(JSON.stringify(message), "utf8");
@@ -34113,8 +34154,8 @@ function resolveDefaultBridgeHost() {
34113
34154
  const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
34114
34155
  return host === undefined || host === "" ? DEFAULT_HOST : host;
34115
34156
  }
34116
- function getDefaultBridge() {
34117
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost());
34157
+ function getDefaultBridge(mayMove = false) {
34158
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34118
34159
  return defaultBridge;
34119
34160
  }
34120
34161
  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;
@@ -34130,19 +34171,37 @@ var init_godot_bridge = __esm(() => {
34130
34171
  connectionInfo = null;
34131
34172
  pendingRequests = new Map;
34132
34173
  resourceQueues = new Map;
34133
- port;
34174
+ wantedPort;
34175
+ boundPort = null;
34134
34176
  host;
34135
34177
  timeoutMs;
34136
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS) {
34178
+ mayMove;
34179
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
34137
34180
  super();
34138
- this.port = port;
34181
+ this.wantedPort = port;
34139
34182
  this.host = host;
34140
34183
  this.timeoutMs = timeoutMs;
34184
+ this.mayMove = mayMove;
34185
+ }
34186
+ get port() {
34187
+ return this.boundPort ?? this.wantedPort;
34141
34188
  }
34142
- start() {
34189
+ async start() {
34143
34190
  if (this.httpServer) {
34144
- return Promise.resolve();
34191
+ return;
34192
+ }
34193
+ try {
34194
+ await this.listenOn(this.wantedPort);
34195
+ } catch (error) {
34196
+ const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
34197
+ if (!held || !this.mayMove) {
34198
+ throw error;
34199
+ }
34200
+ this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
34201
+ await this.listenOn(0);
34145
34202
  }
34203
+ }
34204
+ listenOn(port) {
34146
34205
  return new Promise((resolve, reject) => {
34147
34206
  const server = http.createServer((_req, res) => {
34148
34207
  res.writeHead(404);
@@ -34166,6 +34225,8 @@ var init_godot_bridge = __esm(() => {
34166
34225
  settled = true;
34167
34226
  this.httpServer = server;
34168
34227
  this.godotWss = godotWss;
34228
+ const bound = server.address();
34229
+ this.boundPort = typeof bound === "object" && bound !== null ? bound.port : port;
34169
34230
  this.log("info", `Editor bridge listening on ${this.host}:${this.port}`);
34170
34231
  resolve();
34171
34232
  });
@@ -34182,7 +34243,7 @@ var init_godot_bridge = __esm(() => {
34182
34243
  godotWss.on("error", (error) => {
34183
34244
  this.log("error", `Godot WebSocket server error: ${error.message}`);
34184
34245
  });
34185
- server.listen(this.port, this.host);
34246
+ server.listen(port, this.host);
34186
34247
  });
34187
34248
  }
34188
34249
  async stop() {
@@ -34216,6 +34277,7 @@ var init_godot_bridge = __esm(() => {
34216
34277
  this.httpServer = null;
34217
34278
  }
34218
34279
  await Promise.all(closeTasks);
34280
+ this.boundPort = null;
34219
34281
  this.connectionInfo = null;
34220
34282
  this.log("info", "WebSocket bridge stopped");
34221
34283
  }
@@ -34865,7 +34927,7 @@ var init_junit = __esm(() => {
34865
34927
  import { realpathSync } from "node:fs";
34866
34928
  import { readFile, realpath } from "node:fs/promises";
34867
34929
  import { createConnection as createConnection2 } from "node:net";
34868
- import { dirname as dirname6, resolve as resolve3 } from "node:path";
34930
+ import { dirname as dirname7, resolve as resolve3 } from "node:path";
34869
34931
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
34870
34932
  function diagnosticsKey(uri) {
34871
34933
  let decoded;
@@ -34991,7 +35053,7 @@ class GodotLSPClient {
34991
35053
  if (this.initialized) {
34992
35054
  return;
34993
35055
  }
34994
- const rootPath = this.rootPath ?? dirname6(resolve3(filePath));
35056
+ const rootPath = this.rootPath ?? dirname7(resolve3(filePath));
34995
35057
  await this.initialize(rootPath);
34996
35058
  }
34997
35059
  async sendRequest(method, params) {
@@ -35366,7 +35428,7 @@ var init_lsp_client = __esm(() => {
35366
35428
 
35367
35429
  // src/project-scan.ts
35368
35430
  import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
35369
- import { join as join11 } from "node:path";
35431
+ import { join as join12 } from "node:path";
35370
35432
  function projectStructure(projectPath) {
35371
35433
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
35372
35434
  const visit = (directory) => {
@@ -35375,7 +35437,7 @@ function projectStructure(projectPath) {
35375
35437
  continue;
35376
35438
  }
35377
35439
  if (entry.isDirectory()) {
35378
- visit(join11(directory, entry.name));
35440
+ visit(join12(directory, entry.name));
35379
35441
  } else if (entry.isFile()) {
35380
35442
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
35381
35443
  if (extension === "tscn") {
@@ -35417,7 +35479,7 @@ function searchProject(projectPath, options) {
35417
35479
  if (SKIPPED.has(entry.name)) {
35418
35480
  continue;
35419
35481
  }
35420
- const entryPath = join11(directory, entry.name);
35482
+ const entryPath = join12(directory, entry.name);
35421
35483
  if (entry.isDirectory()) {
35422
35484
  visit(entryPath);
35423
35485
  continue;
@@ -35459,20 +35521,20 @@ var init_project_scan = __esm(() => {
35459
35521
  import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10, unlinkSync } from "node:fs";
35460
35522
  import { createConnection as createConnection3 } from "node:net";
35461
35523
  import { tmpdir as tmpdir4 } from "node:os";
35462
- import { join as join12, resolve as resolve4 } from "node:path";
35524
+ import { join as join13, resolve as resolve4 } from "node:path";
35463
35525
  function runtimeDirectory(variables = process.env) {
35464
35526
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
35465
35527
  if (explicit) {
35466
35528
  return explicit;
35467
35529
  }
35468
35530
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
35469
- return join12(perUser ?? tmpdir4(), "gdharness");
35531
+ return join13(perUser ?? tmpdir4(), "gdharness");
35470
35532
  }
35471
35533
  function runtimeDirectories(variables = process.env) {
35472
35534
  const candidates = [runtimeDirectory(variables)];
35473
35535
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
35474
35536
  for (const base of fallbacks) {
35475
- candidates.push(join12(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
35537
+ candidates.push(join13(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
35476
35538
  }
35477
35539
  return [...new Set(candidates.map((path) => resolve4(path)))];
35478
35540
  }
@@ -35535,7 +35597,7 @@ function announcedIn(directory) {
35535
35597
  if (!match) {
35536
35598
  continue;
35537
35599
  }
35538
- const file = join12(directory, entry);
35600
+ const file = join13(directory, entry);
35539
35601
  const pid = Number.parseInt(match[1] ?? "", 10);
35540
35602
  const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
35541
35603
  if (announced.kind === "rubbish") {
@@ -35681,9 +35743,9 @@ var init_runtime_client = __esm(() => {
35681
35743
 
35682
35744
  // src/server.ts
35683
35745
  import { execFile as execFile5, spawn } from "node:child_process";
35684
- import { existsSync as existsSync11, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync11, realpathSync as realpathSync2, rmSync as rmSync6 } from "node:fs";
35746
+ import { existsSync as existsSync11, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync11, realpathSync as realpathSync2, rmSync as rmSync7 } from "node:fs";
35685
35747
  import { tmpdir as tmpdir5 } from "node:os";
35686
- import { basename as basename2, dirname as dirname7, join as join13, normalize as normalize3 } from "node:path";
35748
+ import { basename as basename2, dirname as dirname8, join as join14, normalize as normalize3 } from "node:path";
35687
35749
  import { setTimeout as delay2 } from "node:timers/promises";
35688
35750
  import { fileURLToPath as fileURLToPath4 } from "node:url";
35689
35751
  import { promisify as promisify5 } from "node:util";
@@ -35729,7 +35791,7 @@ function camelCased(params) {
35729
35791
  class GodotServer {
35730
35792
  mcp;
35731
35793
  locator = new GodotLocator2;
35732
- operationsScript = join13(__dirname2, "godot", "operations", "godot_operations.gd");
35794
+ operationsScript = join14(__dirname2, "godot", "operations", "godot_operations.gd");
35733
35795
  godotBridge;
35734
35796
  tools = buildToolDefinitions();
35735
35797
  activeProcess = null;
@@ -35743,8 +35805,11 @@ class GodotServer {
35743
35805
  bridgeRetry = null;
35744
35806
  lastProjectPath = null;
35745
35807
  shutdownInitiated = false;
35808
+ ownProject;
35809
+ announcedAt = null;
35746
35810
  constructor() {
35747
- this.godotBridge = getDefaultBridge();
35811
+ this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
35812
+ this.godotBridge = getDefaultBridge(this.ownProject !== null);
35748
35813
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
35749
35814
  this.setupToolHandlers();
35750
35815
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -35792,6 +35857,7 @@ class GodotServer {
35792
35857
  this.bridgeStartupError = null;
35793
35858
  const bridgeStatus = this.godotBridge.getStatus();
35794
35859
  console.error(`[SERVER] Godot Editor Bridge started on ${bridgeStatus.host}:${bridgeStatus.port}`);
35860
+ this.announceTheBridge();
35795
35861
  } catch (bridgeError) {
35796
35862
  const code = bridgeError instanceof Error && "code" in bridgeError && typeof bridgeError.code === "string" ? bridgeError.code : null;
35797
35863
  const reason = errorMessage(bridgeError);
@@ -35824,6 +35890,18 @@ class GodotServer {
35824
35890
  this.stopTryingTheBridge();
35825
35891
  const bridgeStatus = this.godotBridge.getStatus();
35826
35892
  console.error(`[SERVER] Godot Editor Bridge came up on ${bridgeStatus.host}:${bridgeStatus.port}; editor tools are live.`);
35893
+ this.announceTheBridge();
35894
+ }
35895
+ announceTheBridge() {
35896
+ if (this.ownProject === null) {
35897
+ return;
35898
+ }
35899
+ const status = this.godotBridge.getStatus();
35900
+ this.announcedAt = announceBridge(this.ownProject, {
35901
+ host: status.host,
35902
+ port: status.port,
35903
+ version: SERVER_VERSION
35904
+ });
35827
35905
  }
35828
35906
  stopTryingTheBridge() {
35829
35907
  if (this.bridgeRetry !== null) {
@@ -35834,6 +35912,8 @@ class GodotServer {
35834
35912
  async cleanup() {
35835
35913
  this.logDebug("Cleaning up resources");
35836
35914
  this.stopTryingTheBridge();
35915
+ withdrawBridge(this.announcedAt);
35916
+ this.announcedAt = null;
35837
35917
  if (this.activeProcess) {
35838
35918
  this.activeProcess.process?.kill();
35839
35919
  this.activeProcess = null;
@@ -36216,7 +36296,7 @@ class GodotServer {
36216
36296
  if (path === undefined) {
36217
36297
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
36218
36298
  }
36219
- const file = join13(path, "project.godot");
36299
+ const file = join14(path, "project.godot");
36220
36300
  if (!existsSync11(file)) {
36221
36301
  return {
36222
36302
  ok: false,
@@ -36481,7 +36561,7 @@ class GodotServer {
36481
36561
  return contained.response;
36482
36562
  }
36483
36563
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
36484
- if (!existsSync11(join13(project.value.path, runner))) {
36564
+ if (!existsSync11(join14(project.value.path, runner))) {
36485
36565
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
36486
36566
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
36487
36567
  ]);
@@ -36514,7 +36594,7 @@ class GodotServer {
36514
36594
  ];
36515
36595
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
36516
36596
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
36517
- const userData = mkdtempSync3(join13(tmpdir5(), "gdharness-tests-"));
36597
+ const userData = mkdtempSync3(join14(tmpdir5(), "gdharness-tests-"));
36518
36598
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
36519
36599
  const hung = await new Promise((resolve) => {
36520
36600
  const timer = setTimeout(() => {
@@ -36530,20 +36610,20 @@ class GodotServer {
36530
36610
  resolve(false);
36531
36611
  });
36532
36612
  });
36533
- const reportsDir = join13(project.value.path, ".godot", "gdharness-reports");
36613
+ const reportsDir = join14(project.value.path, ".godot", "gdharness-reports");
36534
36614
  let report = null;
36535
36615
  let reportProblem = null;
36536
36616
  try {
36537
36617
  const written = existsSync11(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36538
36618
  const newest = written.at(-1);
36539
36619
  if (newest !== undefined) {
36540
- report = parseJUnit(readFileSync11(join13(reportsDir, newest, "results.xml"), "utf8"));
36620
+ report = parseJUnit(readFileSync11(join14(reportsDir, newest, "results.xml"), "utf8"));
36541
36621
  }
36542
36622
  } catch (error) {
36543
36623
  reportProblem = errorMessage(error);
36544
36624
  } finally {
36545
- rmSync6(reportsDir, { recursive: true, force: true });
36546
- rmSync6(userData, { recursive: true, force: true });
36625
+ rmSync7(reportsDir, { recursive: true, force: true });
36626
+ rmSync7(userData, { recursive: true, force: true });
36547
36627
  }
36548
36628
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
36549
36629
  const verdicts = {
@@ -36656,6 +36736,7 @@ class GodotServer {
36656
36736
  startupError: this.bridgeStartupError,
36657
36737
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
36658
36738
  retryingBridge: this.bridgeRetry === null ? undefined : true,
36739
+ announcedAt: this.announcedAt ?? undefined,
36659
36740
  note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
36660
36741
  suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
36661
36742
  };
@@ -37051,8 +37132,8 @@ class GodotServer {
37051
37132
  return this.createErrorResponse(choice.problem);
37052
37133
  }
37053
37134
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
37054
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join13(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
37055
- const screenshotPath = screenshotDir ? join13(screenshotDir, "capture.png") : null;
37135
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join14(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
37136
+ const screenshotPath = screenshotDir ? join14(screenshotDir, "capture.png") : null;
37056
37137
  try {
37057
37138
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
37058
37139
  if (!reply.ok) {
@@ -37078,7 +37159,7 @@ class GodotServer {
37078
37159
  };
37079
37160
  } finally {
37080
37161
  if (screenshotDir) {
37081
- rmSync6(screenshotDir, { recursive: true, force: true });
37162
+ rmSync7(screenshotDir, { recursive: true, force: true });
37082
37163
  }
37083
37164
  }
37084
37165
  }
@@ -37140,6 +37221,7 @@ var init_server3 = __esm(() => {
37140
37221
  init_mcp();
37141
37222
  init_stdio2();
37142
37223
  init_types();
37224
+ init_bridge_announce();
37143
37225
  init_class_cache();
37144
37226
  init_dap_client();
37145
37227
  init_errors();
@@ -37158,7 +37240,7 @@ var init_server3 = __esm(() => {
37158
37240
  init_tool_definitions();
37159
37241
  init_update_check();
37160
37242
  run5 = promisify5(execFile5);
37161
- __dirname2 = dirname7(fileURLToPath4(import.meta.url));
37243
+ __dirname2 = dirname8(fileURLToPath4(import.meta.url));
37162
37244
  PATH_SOLUTIONS = [
37163
37245
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
37164
37246
  "Point projectPath at the project the file belongs to"
@@ -37216,7 +37298,7 @@ var init_server3 = __esm(() => {
37216
37298
 
37217
37299
  // src/cli.ts
37218
37300
  import { existsSync as existsSync12 } from "fs";
37219
- import { join as join14, resolve as resolve5 } from "path";
37301
+ import { join as join15, resolve as resolve5 } from "path";
37220
37302
 
37221
37303
  // src/errors.ts
37222
37304
  class Refusal extends Error {
@@ -37426,9 +37508,13 @@ function isEmptyCollection(node) {
37426
37508
  init_errors();
37427
37509
  init_runner();
37428
37510
  var SERVER_KEY = "gdharness";
37429
- function launchFor(version, godotPath, runner = currentRunner()) {
37511
+ function launchFor(version, godotPath, projectPath, runner = currentRunner()) {
37430
37512
  const spawn = spawnFor(runner, version);
37431
- return { command: spawn.command, args: spawn.args, env: { GODOT_PATH: godotPath } };
37513
+ return {
37514
+ command: spawn.command,
37515
+ args: spawn.args,
37516
+ env: { GODOT_PATH: godotPath, GDHARNESS_PROJECT: projectPath }
37517
+ };
37432
37518
  }
37433
37519
  function tomlArray(values) {
37434
37520
  return `[${values.map((value) => `"${value}"`).join(", ")}]`;
@@ -38614,7 +38700,7 @@ class UsageError extends Refusal {
38614
38700
  function projectArgument(at) {
38615
38701
  const named = args.slice(at).find((value) => !value.startsWith("--"));
38616
38702
  const projectPath = resolve5(named ?? ".");
38617
- if (!existsSync12(join14(projectPath, "project.godot"))) {
38703
+ if (!existsSync12(join15(projectPath, "project.godot"))) {
38618
38704
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
38619
38705
  }
38620
38706
  return projectPath;
@@ -38740,7 +38826,7 @@ async function setup() {
38740
38826
  console.log(`class list rebuilt: ${String(classes.payload["classes"])} classes`);
38741
38827
  }
38742
38828
  if (!args.includes("--no-connect")) {
38743
- const launch = launchFor(getLocalVersion(), godot.godotPath);
38829
+ const launch = launchFor(getLocalVersion(), godot.godotPath, projectPath);
38744
38830
  const ask = new Ask;
38745
38831
  let chosen;
38746
38832
  try {
@@ -38835,7 +38921,7 @@ async function upgrade() {
38835
38921
  said(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
38836
38922
  }
38837
38923
  said(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
38838
- const launch = launchFor(version, godot.godotPath);
38924
+ const launch = launchFor(version, godot.godotPath, projectPath);
38839
38925
  const already = HARNESSES.filter((harness) => registered(harness, projectPath));
38840
38926
  for (const group of groupByFile(already, projectPath)) {
38841
38927
  reportConnection(group, launch, projectPath);
@@ -11,11 +11,34 @@ signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
11
11
  const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
12
12
  ## Written beside the addon by the install, so it names the version this copy came from.
13
13
  const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
14
+ ## Written by the server that serves this project, saying where its bridge actually is. Kept in
15
+ ## step with `announcementPath` in src/bridge-announce.ts.
16
+ const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
17
+ const ANNOUNCE_PROTOCOL: int = 1
14
18
  const RECONNECT_DELAY: float = 3.0
15
19
  const MAX_RECONNECT_DELAY: float = 30.0
16
20
 
21
+ ## How long one attempt is given before the address is called a bad one.
22
+ ##
23
+ ## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
24
+ ## pointed at something that accepts and then never speaks WebSocket does not give up at all:
25
+ ## measured here, still connecting after forty-five seconds, because the peer has no handshake
26
+ ## timeout of its own in 4.7. A leftover announcement can be either, and a port that has been
27
+ ## reused by some other program is the second.
28
+ const CONNECT_TIMEOUT: float = 10.0
29
+
30
+ ## How often the announcement is read again while connected, so a newer server is moved to
31
+ ## rather than waited for. A harness reconnect leaves the server it replaced running and holding
32
+ ## the old port, and an editor with no reason to look elsewhere stayed on it for the session.
33
+ const FOLLOW_INTERVAL: float = 5.0
34
+
17
35
  var socket: WebSocketPeer = WebSocketPeer.new()
18
36
  var server_url: String = DEFAULT_URL
37
+
38
+ ## What this copy was when it loaded, which is what the editor is running until it is restarted.
39
+ ## See [method _loaded_version] for why it is held rather than read when it is wanted.
40
+ var version_at_load: String = ""
41
+
19
42
  var _is_connected: bool = false
20
43
  var _reconnect_timer: Timer
21
44
  var _current_reconnect_delay: float = RECONNECT_DELAY
@@ -24,9 +47,21 @@ var _should_reconnect: bool = false
24
47
  var _project_path: String
25
48
  var _initialized: bool = false
26
49
 
50
+ ## Whether the address came from a caller rather than from the project, in which case it is not
51
+ ## this node's to change.
52
+ var _named_by_caller: bool = false
53
+ var _since_looked: float = 0.0
54
+
55
+ ## The announced address that did not answer, so the fallback gets a turn. Cleared the moment
56
+ ## anything connects or the project names a different one.
57
+ var _refused_url: String = ""
58
+ var _tried_announced: bool = false
59
+ var _connecting_for: float = 0.0
60
+
27
61
 
28
62
  func _ready() -> void:
29
63
  _project_path = ProjectSettings.globalize_path("res://")
64
+ version_at_load = _loaded_version()
30
65
 
31
66
  _reconnect_timer = Timer.new()
32
67
  _reconnect_timer.one_shot = true
@@ -48,6 +83,8 @@ func _process(_delta: float) -> void:
48
83
  # A connection refused never opened, so it reaches CLOSED without passing through
49
84
  # _handle_disconnect and nothing would ask again. An editor opened before the server
50
85
  # is the ordinary way that happens, and it then sat there for the rest of the day.
86
+ if _tried_announced:
87
+ _refused_url = server_url
51
88
  _schedule_reconnect()
52
89
  return
53
90
 
@@ -57,11 +94,17 @@ func _process(_delta: float) -> void:
57
94
  WebSocketPeer.STATE_OPEN:
58
95
  if not _is_connected:
59
96
  _handle_connect()
97
+ _follow_whoever_is_newest(_delta)
60
98
 
61
99
  while socket.get_available_packet_count() > 0:
62
100
  var packet: PackedByteArray = socket.get_packet()
63
101
  _handle_message(packet.get_string_from_utf8())
64
102
 
103
+ WebSocketPeer.STATE_CONNECTING:
104
+ _connecting_for += _delta
105
+ if _connecting_for >= CONNECT_TIMEOUT:
106
+ _give_up_on_this_address()
107
+
65
108
  WebSocketPeer.STATE_CLOSING:
66
109
  pass
67
110
 
@@ -70,7 +113,38 @@ func _process(_delta: float) -> void:
70
113
  _handle_disconnect()
71
114
 
72
115
 
116
+ ## Moves to the server the project now names, when that is not the one this is talking to.
117
+ ##
118
+ ## A harness reconnect leaves the server it replaced running, still holding the port it bound and
119
+ ## still answering: the editor has no reason to notice, and stayed on a server nothing was
120
+ ## speaking to for the rest of the session. The replacement announces where it landed, so the
121
+ ## editor can go to it rather than anybody ending a process.
122
+ ##
123
+ ## Only while connected by a URL this worked out for itself. A caller that named one is holding
124
+ ## this to that address, which is what every fixture does.
125
+ func _follow_whoever_is_newest(delta: float) -> void:
126
+ if _named_by_caller:
127
+ return
128
+ _since_looked += delta
129
+ if _since_looked < FOLLOW_INTERVAL:
130
+ return
131
+ _since_looked = 0.0
132
+
133
+ var announced: String = announced_url()
134
+ if announced == "" or announced == server_url or announced == _refused_url:
135
+ return
136
+ server_url = announced
137
+ # Put down before it is picked up again, because the arrival is what tells a server who this
138
+ # editor is: left standing, the open socket on the new address would never be greeted and the
139
+ # server would report no editor while holding one.
140
+ _is_connected = false
141
+ disconnected.emit()
142
+ _current_reconnect_delay = RECONNECT_DELAY
143
+ _attempt_connection()
144
+
145
+
73
146
  func connect_to_server(url: String = "") -> void:
147
+ _named_by_caller = url != ""
74
148
  server_url = _resolve_server_url(url)
75
149
  _should_reconnect = true
76
150
  _current_reconnect_delay = RECONNECT_DELAY
@@ -81,6 +155,12 @@ func _resolve_server_url(explicit_url: String) -> String:
81
155
  if explicit_url != "":
82
156
  return explicit_url
83
157
 
158
+ var announced: String = announced_url()
159
+ if announced != "" and announced != _refused_url:
160
+ _tried_announced = true
161
+ return announced
162
+ _tried_announced = false
163
+
84
164
  # The same variable the server reads, so the two agree on the port by construction.
85
165
  var raw: String = OS.get_environment("GDHARNESS_BRIDGE_PORT")
86
166
  if raw != "":
@@ -91,6 +171,48 @@ func _resolve_server_url(explicit_url: String) -> String:
91
171
  return DEFAULT_URL
92
172
 
93
173
 
174
+ ## Stops waiting on an address that is not answering, and asks again elsewhere.
175
+ func _give_up_on_this_address() -> void:
176
+ if _tried_announced:
177
+ _refused_url = server_url
178
+ _connecting_for = 0.0
179
+ socket.close()
180
+ _schedule_reconnect()
181
+
182
+
183
+ ## Where the server says its bridge is, or "" when nothing has said.
184
+ ##
185
+ ## Written by a server that knows which project it serves, inside that project, so the two sides
186
+ ## agree by construction rather than by deriving a temporary directory the same way: they do not
187
+ ## share an environment, and the runtime's own announcement cost a session learning that.
188
+ ##
189
+ ## A leftover is found out by trying it rather than by asking whether its process is still there.
190
+ ## `OS.is_process_running` answers that only for a child of the caller on Unix, where it prints
191
+ ## "does not exist or is not a child of the calling process" and says no about every server there
192
+ ## is: it works on Windows and quietly disables the whole thing everywhere else. So an
193
+ ## announcement that does not answer is set aside, the fallback gets the next turn, and a
194
+ ## different announcement puts it back in play.
195
+ func announced_url() -> String:
196
+ if not FileAccess.file_exists(ANNOUNCEMENT):
197
+ return ""
198
+ var file: FileAccess = FileAccess.open(ANNOUNCEMENT, FileAccess.READ)
199
+ if file == null:
200
+ return ""
201
+ var said: Variant = JSON.parse_string(file.get_as_text())
202
+ file.close()
203
+ if not said is Dictionary:
204
+ return ""
205
+
206
+ var announcement: Dictionary = said
207
+ if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
208
+ return ""
209
+ var port: int = int(announcement.get("port", 0))
210
+ if port < 1 or port > 65535:
211
+ return ""
212
+ var host: String = str(announcement.get("host", "127.0.0.1"))
213
+ return "ws://%s:%d/godot" % [host, port]
214
+
215
+
94
216
  func disconnect_from_server() -> void:
95
217
  _should_reconnect = false
96
218
  if _reconnect_timer:
@@ -104,6 +226,7 @@ func _attempt_connection() -> void:
104
226
  if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
105
227
  socket.close()
106
228
 
229
+ _connecting_for = 0.0
107
230
  var err: Error = socket.connect_to_url(server_url)
108
231
  if err != OK:
109
232
  push_error("[gdharness] Failed to connect to %s: %s" % [server_url, error_string(err)])
@@ -113,6 +236,7 @@ func _attempt_connection() -> void:
113
236
  func _handle_connect() -> void:
114
237
  _is_connected = true
115
238
  _current_reconnect_delay = RECONNECT_DELAY
239
+ _refused_url = ""
116
240
 
117
241
  # The version reported is the one this editor loaded at startup, not the one on disk: an
118
242
  # upgrade replaces the files under a running editor, which goes on serving the old code until
@@ -123,7 +247,7 @@ func _handle_connect() -> void:
123
247
  {
124
248
  "type": "godot_ready",
125
249
  "project_path": _project_path,
126
- "addon_version": _loaded_version(),
250
+ "addon_version": version_at_load,
127
251
  "editor_pid": OS.get_process_id()
128
252
  }
129
253
  )
@@ -132,6 +256,13 @@ func _handle_connect() -> void:
132
256
 
133
257
 
134
258
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
259
+ ##
260
+ ## Read once, when this copy loads, and never again: an upgrade replaces the files under a
261
+ ## running editor and rewrites the marker with them, so reading it at connect time answers with
262
+ ## the version on disk rather than the one in memory. That is the wrong answer at the one moment
263
+ ## it matters. An upgrade ends with the harness reconnecting, the editor reconnecting behind it,
264
+ ## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
265
+ ## what it exists to catch.
135
266
  func _loaded_version() -> String:
136
267
  if not FileAccess.file_exists(VERSION_MARKER):
137
268
  return ""
@@ -159,6 +290,10 @@ func _schedule_reconnect() -> void:
159
290
 
160
291
 
161
292
  func _on_reconnect_timer() -> void:
293
+ # Asked again rather than remembered: the reason a connection dropped is often that its server
294
+ # did, and the one that replaced it has said where it is since.
295
+ if not _named_by_caller:
296
+ server_url = _resolve_server_url("")
162
297
  _attempt_connection()
163
298
 
164
299
 
package/build/index.js CHANGED
@@ -10126,9 +10126,9 @@ 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 existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync2 } from "node:fs";
10129
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync3 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
- import { basename, dirname as dirname2, join as join7, normalize as normalize2 } from "node:path";
10131
+ import { basename, dirname as dirname3, join as join8, normalize as normalize2 } from "node:path";
10132
10132
  import { setTimeout as delay2 } from "node:timers/promises";
10133
10133
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10134
10134
  import { promisify as promisify3 } from "node:util";
@@ -24696,9 +24696,47 @@ class StdioServerTransport {
24696
24696
  }
24697
24697
  }
24698
24698
 
24699
+ // src/bridge-announce.ts
24700
+ import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
24701
+ import { dirname, join as join2 } from "node:path";
24702
+ function announcementPath(projectPath) {
24703
+ return join2(projectPath, ".godot", "gdharness-bridge.json");
24704
+ }
24705
+ var BRIDGE_ANNOUNCE_PROTOCOL = 1;
24706
+ function announceBridge(projectPath, bridge) {
24707
+ const path = announcementPath(projectPath);
24708
+ try {
24709
+ mkdirSync2(dirname(path), { recursive: true });
24710
+ const announcement = {
24711
+ protocol: BRIDGE_ANNOUNCE_PROTOCOL,
24712
+ host: bridge.host,
24713
+ port: bridge.port,
24714
+ pid: process.pid,
24715
+ version: bridge.version,
24716
+ startedAt: new Date().toISOString()
24717
+ };
24718
+ writeFileSync2(path, `${JSON.stringify(announcement, null, 2)}
24719
+ `, "utf8");
24720
+ return path;
24721
+ } catch (error) {
24722
+ console.error(`[SERVER] Could not announce the editor bridge at ${path}: ${errorMessage(error)}`);
24723
+ return null;
24724
+ }
24725
+ }
24726
+ function withdrawBridge(path) {
24727
+ if (path === null) {
24728
+ return;
24729
+ }
24730
+ try {
24731
+ rmSync(path, { force: true });
24732
+ } catch (error) {
24733
+ console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
24734
+ }
24735
+ }
24736
+
24699
24737
  // src/class-cache.ts
24700
24738
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
24701
- import { join as join2 } from "node:path";
24739
+ import { join as join3 } from "node:path";
24702
24740
  function declaredClasses(projectPath) {
24703
24741
  const declared = new Map;
24704
24742
  const visit = (directory, prefix) => {
@@ -24706,7 +24744,7 @@ function declaredClasses(projectPath) {
24706
24744
  if (entry.name.startsWith(".")) {
24707
24745
  continue;
24708
24746
  }
24709
- const path = join2(directory, entry.name);
24747
+ const path = join3(directory, entry.name);
24710
24748
  if (entry.isDirectory()) {
24711
24749
  visit(path, `${prefix}${entry.name}/`);
24712
24750
  } else if (entry.isFile() && entry.name.endsWith(".gd")) {
@@ -24721,7 +24759,7 @@ function declaredClasses(projectPath) {
24721
24759
  return declared;
24722
24760
  }
24723
24761
  function cachedClasses(projectPath) {
24724
- const cache = join2(projectPath, ".godot", "global_script_class_cache.cfg");
24762
+ const cache = join3(projectPath, ".godot", "global_script_class_cache.cfg");
24725
24763
  if (!existsSync2(cache)) {
24726
24764
  return null;
24727
24765
  }
@@ -25426,19 +25464,37 @@ class GodotBridge extends EventEmitter {
25426
25464
  connectionInfo = null;
25427
25465
  pendingRequests = new Map;
25428
25466
  resourceQueues = new Map;
25429
- port;
25467
+ wantedPort;
25468
+ boundPort = null;
25430
25469
  host;
25431
25470
  timeoutMs;
25432
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS) {
25471
+ mayMove;
25472
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
25433
25473
  super();
25434
- this.port = port;
25474
+ this.wantedPort = port;
25435
25475
  this.host = host;
25436
25476
  this.timeoutMs = timeoutMs;
25477
+ this.mayMove = mayMove;
25478
+ }
25479
+ get port() {
25480
+ return this.boundPort ?? this.wantedPort;
25437
25481
  }
25438
- start() {
25482
+ async start() {
25439
25483
  if (this.httpServer) {
25440
- return Promise.resolve();
25484
+ return;
25441
25485
  }
25486
+ try {
25487
+ await this.listenOn(this.wantedPort);
25488
+ } catch (error) {
25489
+ const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
25490
+ if (!held || !this.mayMove) {
25491
+ throw error;
25492
+ }
25493
+ this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
25494
+ await this.listenOn(0);
25495
+ }
25496
+ }
25497
+ listenOn(port) {
25442
25498
  return new Promise((resolve, reject) => {
25443
25499
  const server = http.createServer((_req, res) => {
25444
25500
  res.writeHead(404);
@@ -25462,6 +25518,8 @@ class GodotBridge extends EventEmitter {
25462
25518
  settled = true;
25463
25519
  this.httpServer = server;
25464
25520
  this.godotWss = godotWss;
25521
+ const bound = server.address();
25522
+ this.boundPort = typeof bound === "object" && bound !== null ? bound.port : port;
25465
25523
  this.log("info", `Editor bridge listening on ${this.host}:${this.port}`);
25466
25524
  resolve();
25467
25525
  });
@@ -25478,7 +25536,7 @@ class GodotBridge extends EventEmitter {
25478
25536
  godotWss.on("error", (error) => {
25479
25537
  this.log("error", `Godot WebSocket server error: ${error.message}`);
25480
25538
  });
25481
- server.listen(this.port, this.host);
25539
+ server.listen(port, this.host);
25482
25540
  });
25483
25541
  }
25484
25542
  async stop() {
@@ -25512,6 +25570,7 @@ class GodotBridge extends EventEmitter {
25512
25570
  this.httpServer = null;
25513
25571
  }
25514
25572
  await Promise.all(closeTasks);
25573
+ this.boundPort = null;
25515
25574
  this.connectionInfo = null;
25516
25575
  this.log("info", "WebSocket bridge stopped");
25517
25576
  }
@@ -25813,8 +25872,8 @@ class GodotBridge extends EventEmitter {
25813
25872
  }
25814
25873
  }
25815
25874
  var defaultBridge = null;
25816
- function getDefaultBridge() {
25817
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost());
25875
+ function getDefaultBridge(mayMove = false) {
25876
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
25818
25877
  return defaultBridge;
25819
25878
  }
25820
25879
 
@@ -25827,7 +25886,7 @@ import { promisify } from "node:util";
25827
25886
  // src/detection.ts
25828
25887
  import { existsSync as existsSync3, readdirSync as readdirSync2, statSync } from "node:fs";
25829
25888
  import { homedir as homedir2 } from "node:os";
25830
- import { join as join3 } from "node:path";
25889
+ import { join as join4 } from "node:path";
25831
25890
  function resolveHomeDirectory() {
25832
25891
  try {
25833
25892
  return homedir2();
@@ -25851,7 +25910,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25851
25910
  if (!pattern.test(name)) {
25852
25911
  continue;
25853
25912
  }
25854
- const fullPath = join3(directory, name);
25913
+ const fullPath = join4(directory, name);
25855
25914
  try {
25856
25915
  const stat = statSync(fullPath);
25857
25916
  if (stat.isFile()) {
@@ -25860,7 +25919,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25860
25919
  } catch {}
25861
25920
  }
25862
25921
  matches.sort((a, b) => b.mtime - a.mtime);
25863
- return matches.map((m) => join3(directory, m.name));
25922
+ return matches.map((m) => join4(directory, m.name));
25864
25923
  }
25865
25924
  function conventionalPaths(platform, home) {
25866
25925
  const paths = ["godot"];
@@ -26003,9 +26062,9 @@ class GodotLocator {
26003
26062
 
26004
26063
  // src/headless.ts
26005
26064
  import { execFile as execFile2 } from "node:child_process";
26006
- import { mkdtempSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
26065
+ import { mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
26007
26066
  import { tmpdir as tmpdir2 } from "node:os";
26008
- import { join as join4 } from "node:path";
26067
+ import { join as join5 } from "node:path";
26009
26068
  import { promisify as promisify2 } from "node:util";
26010
26069
  var run2 = promisify2(execFile2);
26011
26070
  function snakeCased(params) {
@@ -26046,9 +26105,9 @@ function reason(stdout, stderr) {
26046
26105
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
26047
26106
  }
26048
26107
  async function runOperation(engine, operation, params, projectPath) {
26049
- const paramsDir = mkdtempSync(join4(tmpdir2(), "gdharness-params-"));
26050
- const paramsFile = join4(paramsDir, `${operation}.json`);
26051
- writeFileSync2(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26108
+ const paramsDir = mkdtempSync(join5(tmpdir2(), "gdharness-params-"));
26109
+ const paramsFile = join5(paramsDir, `${operation}.json`);
26110
+ writeFileSync3(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26052
26111
  const args = [
26053
26112
  "--headless",
26054
26113
  "--path",
@@ -26078,7 +26137,7 @@ async function runOperation(engine, operation, params, projectPath) {
26078
26137
  messages: []
26079
26138
  };
26080
26139
  } finally {
26081
- rmSync(paramsDir, { recursive: true, force: true });
26140
+ rmSync2(paramsDir, { recursive: true, force: true });
26082
26141
  }
26083
26142
  const payload = lastJsonObject(stdout);
26084
26143
  if (payload === null) {
@@ -26373,7 +26432,7 @@ function parseJUnit(xml) {
26373
26432
  import { realpathSync } from "node:fs";
26374
26433
  import { readFile, realpath } from "node:fs/promises";
26375
26434
  import { createConnection as createConnection2 } from "node:net";
26376
- import { dirname, resolve as resolve2 } from "node:path";
26435
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
26377
26436
  import { fileURLToPath, pathToFileURL } from "node:url";
26378
26437
 
26379
26438
  // src/paths.ts
@@ -26551,7 +26610,7 @@ class GodotLSPClient {
26551
26610
  if (this.initialized) {
26552
26611
  return;
26553
26612
  }
26554
- const rootPath = this.rootPath ?? dirname(resolve2(filePath));
26613
+ const rootPath = this.rootPath ?? dirname2(resolve2(filePath));
26555
26614
  await this.initialize(rootPath);
26556
26615
  }
26557
26616
  async sendRequest(method, params) {
@@ -26919,7 +26978,7 @@ async function handleLSPTool(client, toolName, args) {
26919
26978
 
26920
26979
  // src/project-scan.ts
26921
26980
  import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
26922
- import { join as join5 } from "node:path";
26981
+ import { join as join6 } from "node:path";
26923
26982
  var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
26924
26983
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
26925
26984
  function projectStructure(projectPath) {
@@ -26930,7 +26989,7 @@ function projectStructure(projectPath) {
26930
26989
  continue;
26931
26990
  }
26932
26991
  if (entry.isDirectory()) {
26933
- visit(join5(directory, entry.name));
26992
+ visit(join6(directory, entry.name));
26934
26993
  } else if (entry.isFile()) {
26935
26994
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
26936
26995
  if (extension === "tscn") {
@@ -26972,7 +27031,7 @@ function searchProject(projectPath, options) {
26972
27031
  if (SKIPPED.has(entry.name)) {
26973
27032
  continue;
26974
27033
  }
26975
- const entryPath = join5(directory, entry.name);
27034
+ const entryPath = join6(directory, entry.name);
26976
27035
  if (entry.isDirectory()) {
26977
27036
  visit(entryPath);
26978
27037
  continue;
@@ -27226,7 +27285,7 @@ function setupResourceHandlers(mcp, getProjectPath) {
27226
27285
  import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, unlinkSync } from "node:fs";
27227
27286
  import { createConnection as createConnection3 } from "node:net";
27228
27287
  import { tmpdir as tmpdir3 } from "node:os";
27229
- import { join as join6, resolve as resolve4 } from "node:path";
27288
+ import { join as join7, resolve as resolve4 } from "node:path";
27230
27289
 
27231
27290
  // src/tool-args.ts
27232
27291
  function asParams(value) {
@@ -27288,13 +27347,13 @@ function runtimeDirectory(variables = process.env) {
27288
27347
  return explicit;
27289
27348
  }
27290
27349
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
27291
- return join6(perUser ?? tmpdir3(), "gdharness");
27350
+ return join7(perUser ?? tmpdir3(), "gdharness");
27292
27351
  }
27293
27352
  function runtimeDirectories(variables = process.env) {
27294
27353
  const candidates = [runtimeDirectory(variables)];
27295
27354
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
27296
27355
  for (const base of fallbacks) {
27297
- candidates.push(join6(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
27356
+ candidates.push(join7(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
27298
27357
  }
27299
27358
  return [...new Set(candidates.map((path) => resolve4(path)))];
27300
27359
  }
@@ -27357,7 +27416,7 @@ function announcedIn(directory) {
27357
27416
  if (!match) {
27358
27417
  continue;
27359
27418
  }
27360
- const file = join6(directory, entry);
27419
+ const file = join7(directory, entry);
27361
27420
  const pid = Number.parseInt(match[1] ?? "", 10);
27362
27421
  const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
27363
27422
  if (announced.kind === "rubbish") {
@@ -28413,7 +28472,7 @@ function buildToolDefinitions() {
28413
28472
  var UPDATE_NOTICE_EVERY = 500;
28414
28473
  var FEEDBACK_NOTICE_EVERY = 250;
28415
28474
  var run3 = promisify3(execFile3);
28416
- var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
28475
+ var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
28417
28476
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
28418
28477
  var BRIDGE_RETRY_MS = 2000;
28419
28478
  var PATH_SOLUTIONS = [
@@ -28511,7 +28570,7 @@ function camelCased(params) {
28511
28570
  class GodotServer {
28512
28571
  mcp;
28513
28572
  locator = new GodotLocator;
28514
- operationsScript = join7(__dirname2, "godot", "operations", "godot_operations.gd");
28573
+ operationsScript = join8(__dirname2, "godot", "operations", "godot_operations.gd");
28515
28574
  godotBridge;
28516
28575
  tools = buildToolDefinitions();
28517
28576
  activeProcess = null;
@@ -28525,8 +28584,11 @@ class GodotServer {
28525
28584
  bridgeRetry = null;
28526
28585
  lastProjectPath = null;
28527
28586
  shutdownInitiated = false;
28587
+ ownProject;
28588
+ announcedAt = null;
28528
28589
  constructor() {
28529
- this.godotBridge = getDefaultBridge();
28590
+ this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
28591
+ this.godotBridge = getDefaultBridge(this.ownProject !== null);
28530
28592
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
28531
28593
  this.setupToolHandlers();
28532
28594
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -28574,6 +28636,7 @@ class GodotServer {
28574
28636
  this.bridgeStartupError = null;
28575
28637
  const bridgeStatus = this.godotBridge.getStatus();
28576
28638
  console.error(`[SERVER] Godot Editor Bridge started on ${bridgeStatus.host}:${bridgeStatus.port}`);
28639
+ this.announceTheBridge();
28577
28640
  } catch (bridgeError) {
28578
28641
  const code = bridgeError instanceof Error && "code" in bridgeError && typeof bridgeError.code === "string" ? bridgeError.code : null;
28579
28642
  const reason = errorMessage(bridgeError);
@@ -28606,6 +28669,18 @@ class GodotServer {
28606
28669
  this.stopTryingTheBridge();
28607
28670
  const bridgeStatus = this.godotBridge.getStatus();
28608
28671
  console.error(`[SERVER] Godot Editor Bridge came up on ${bridgeStatus.host}:${bridgeStatus.port}; editor tools are live.`);
28672
+ this.announceTheBridge();
28673
+ }
28674
+ announceTheBridge() {
28675
+ if (this.ownProject === null) {
28676
+ return;
28677
+ }
28678
+ const status = this.godotBridge.getStatus();
28679
+ this.announcedAt = announceBridge(this.ownProject, {
28680
+ host: status.host,
28681
+ port: status.port,
28682
+ version: SERVER_VERSION
28683
+ });
28609
28684
  }
28610
28685
  stopTryingTheBridge() {
28611
28686
  if (this.bridgeRetry !== null) {
@@ -28616,6 +28691,8 @@ class GodotServer {
28616
28691
  async cleanup() {
28617
28692
  this.logDebug("Cleaning up resources");
28618
28693
  this.stopTryingTheBridge();
28694
+ withdrawBridge(this.announcedAt);
28695
+ this.announcedAt = null;
28619
28696
  if (this.activeProcess) {
28620
28697
  this.activeProcess.process?.kill();
28621
28698
  this.activeProcess = null;
@@ -28998,7 +29075,7 @@ class GodotServer {
28998
29075
  if (path === undefined) {
28999
29076
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
29000
29077
  }
29001
- const file = join7(path, "project.godot");
29078
+ const file = join8(path, "project.godot");
29002
29079
  if (!existsSync6(file)) {
29003
29080
  return {
29004
29081
  ok: false,
@@ -29263,7 +29340,7 @@ class GodotServer {
29263
29340
  return contained.response;
29264
29341
  }
29265
29342
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
29266
- if (!existsSync6(join7(project.value.path, runner))) {
29343
+ if (!existsSync6(join8(project.value.path, runner))) {
29267
29344
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
29268
29345
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
29269
29346
  ]);
@@ -29296,7 +29373,7 @@ class GodotServer {
29296
29373
  ];
29297
29374
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29298
29375
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29299
- const userData = mkdtempSync2(join7(tmpdir4(), "gdharness-tests-"));
29376
+ const userData = mkdtempSync2(join8(tmpdir4(), "gdharness-tests-"));
29300
29377
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29301
29378
  const hung = await new Promise((resolve) => {
29302
29379
  const timer = setTimeout(() => {
@@ -29312,20 +29389,20 @@ class GodotServer {
29312
29389
  resolve(false);
29313
29390
  });
29314
29391
  });
29315
- const reportsDir = join7(project.value.path, ".godot", "gdharness-reports");
29392
+ const reportsDir = join8(project.value.path, ".godot", "gdharness-reports");
29316
29393
  let report = null;
29317
29394
  let reportProblem = null;
29318
29395
  try {
29319
29396
  const written = existsSync6(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29320
29397
  const newest = written.at(-1);
29321
29398
  if (newest !== undefined) {
29322
- report = parseJUnit(readFileSync7(join7(reportsDir, newest, "results.xml"), "utf8"));
29399
+ report = parseJUnit(readFileSync7(join8(reportsDir, newest, "results.xml"), "utf8"));
29323
29400
  }
29324
29401
  } catch (error) {
29325
29402
  reportProblem = errorMessage(error);
29326
29403
  } finally {
29327
- rmSync2(reportsDir, { recursive: true, force: true });
29328
- rmSync2(userData, { recursive: true, force: true });
29404
+ rmSync3(reportsDir, { recursive: true, force: true });
29405
+ rmSync3(userData, { recursive: true, force: true });
29329
29406
  }
29330
29407
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
29331
29408
  const verdicts = {
@@ -29438,6 +29515,7 @@ class GodotServer {
29438
29515
  startupError: this.bridgeStartupError,
29439
29516
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
29440
29517
  retryingBridge: this.bridgeRetry === null ? undefined : true,
29518
+ announcedAt: this.announcedAt ?? undefined,
29441
29519
  note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
29442
29520
  suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
29443
29521
  };
@@ -29833,8 +29911,8 @@ class GodotServer {
29833
29911
  return this.createErrorResponse(choice.problem);
29834
29912
  }
29835
29913
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
29836
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join7(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29837
- const screenshotPath = screenshotDir ? join7(screenshotDir, "capture.png") : null;
29914
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join8(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29915
+ const screenshotPath = screenshotDir ? join8(screenshotDir, "capture.png") : null;
29838
29916
  try {
29839
29917
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
29840
29918
  if (!reply.ok) {
@@ -29860,7 +29938,7 @@ class GodotServer {
29860
29938
  };
29861
29939
  } finally {
29862
29940
  if (screenshotDir) {
29863
- rmSync2(screenshotDir, { recursive: true, force: true });
29941
+ rmSync3(screenshotDir, { recursive: true, force: true });
29864
29942
  }
29865
29943
  }
29866
29944
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
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",