gdharness 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -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;
@@ -25645,6 +25718,7 @@ class GodotBridge extends EventEmitter {
25645
25718
  lspPort: this.connectionInfo?.lspPort,
25646
25719
  dapPort: this.connectionInfo?.dapPort,
25647
25720
  debugPort: this.connectionInfo?.debugPort,
25721
+ openedByAServer: this.connectionInfo?.openedByAServer,
25648
25722
  pendingRequests: this.pendingRequests.size,
25649
25723
  queuedResources: this.resourceQueues.size
25650
25724
  };
@@ -25775,12 +25849,17 @@ class GodotBridge extends EventEmitter {
25775
25849
  }
25776
25850
  case "godot_ready":
25777
25851
  if (this.connectionInfo) {
25852
+ if (this.belongsElsewhere(message.project_path)) {
25853
+ this.sendElsewhere(message.project_path);
25854
+ return;
25855
+ }
25778
25856
  this.connectionInfo.projectPath = message.project_path;
25779
25857
  this.connectionInfo.addonVersion = message.addon_version ?? "";
25780
25858
  this.connectionInfo.editorPid = message.editor_pid;
25781
25859
  this.connectionInfo.lspPort = servedPort(message.lsp_port);
25782
25860
  this.connectionInfo.dapPort = servedPort(message.dap_port);
25783
25861
  this.connectionInfo.debugPort = servedPort(message.debug_port);
25862
+ this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
25784
25863
  this.log("info", `Godot ready: ${message.project_path}`);
25785
25864
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
25786
25865
  }
@@ -25857,6 +25936,21 @@ class GodotBridge extends EventEmitter {
25857
25936
  clearInterval(this.pingInterval);
25858
25937
  this.pingInterval = null;
25859
25938
  }
25939
+ belongsElsewhere(theirProject) {
25940
+ if (this.ownProject === null || theirProject.trim() === "") {
25941
+ return false;
25942
+ }
25943
+ return !isSameDirectory(this.ownProject, theirProject);
25944
+ }
25945
+ sendElsewhere(theirProject) {
25946
+ const said = `this server serves ${this.ownProject ?? ""}, and that editor has ${theirProject} open`;
25947
+ this.log("warn", `Turning away an editor from another project: ${said}`);
25948
+ const stranger = this.socket;
25949
+ this.socket = null;
25950
+ this.connectionInfo = null;
25951
+ this.stopKeepalive();
25952
+ stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
25953
+ }
25860
25954
  handleDisconnect(disconnectedSocket, reason) {
25861
25955
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
25862
25956
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -25933,8 +26027,8 @@ class GodotBridge extends EventEmitter {
25933
26027
  }
25934
26028
  }
25935
26029
  var defaultBridge = null;
25936
- function getDefaultBridge(mayMove = false) {
25937
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
26030
+ function getDefaultBridge(ownProject = null) {
26031
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
25938
26032
  return defaultBridge;
25939
26033
  }
25940
26034
 
@@ -26056,6 +26150,7 @@ function runArguments(options) {
26056
26150
  function editorArguments(projectPath, ports) {
26057
26151
  return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
26058
26152
  }
26153
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
26059
26154
  function userDataIn(home, variables = process.env) {
26060
26155
  const moved = ["APPDATA", "XDG_DATA_HOME"];
26061
26156
  const carried = {};
@@ -26495,56 +26590,6 @@ import { readFile, realpath } from "node:fs/promises";
26495
26590
  import { createConnection as createConnection2 } from "node:net";
26496
26591
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
26497
26592
  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
26593
  var DEFAULT_LSP_PORT = 6005;
26549
26594
  var DIAGNOSTICS_TIMEOUT_MS = 5000;
26550
26595
  function diagnosticsKey(uri) {
@@ -27038,7 +27083,7 @@ async function handleLSPTool(client, toolName, args) {
27038
27083
  }
27039
27084
 
27040
27085
  // src/project-scan.ts
27041
- import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
27086
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
27042
27087
  import { join as join6 } from "node:path";
27043
27088
  var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
27044
27089
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
@@ -27103,7 +27148,7 @@ function searchProject(projectPath, options) {
27103
27148
  }
27104
27149
  result.summary.files_searched += 1;
27105
27150
  const matches = [];
27106
- for (const [index, line] of readFileSync4(entryPath, "utf8").split(`
27151
+ for (const [index, line] of readFileSync5(entryPath, "utf8").split(`
27107
27152
  `).entries()) {
27108
27153
  if (full()) {
27109
27154
  break;
@@ -27126,7 +27171,7 @@ function searchProject(projectPath, options) {
27126
27171
  }
27127
27172
 
27128
27173
  // src/resources.ts
27129
- import { readFileSync as readFileSync5 } from "node:fs";
27174
+ import { readFileSync as readFileSync6 } from "node:fs";
27130
27175
  import { extname, resolve as resolve3 } from "node:path";
27131
27176
  var STATIC_RESOURCES = [
27132
27177
  {
@@ -27297,7 +27342,7 @@ function readResourceText(uri, getProjectPath) {
27297
27342
  const parsedUri = parseGodotUri(uri);
27298
27343
  if (parsedUri.kind === "project-info") {
27299
27344
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
27300
- const rawProject = readFileSync5(projectFilePath, "utf-8");
27345
+ const rawProject = readFileSync6(projectFilePath, "utf-8");
27301
27346
  const parsedProject = parseProjectGodot(rawProject);
27302
27347
  return {
27303
27348
  mimeType: "application/json",
@@ -27306,7 +27351,7 @@ function readResourceText(uri, getProjectPath) {
27306
27351
  }
27307
27352
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
27308
27353
  ensureAllowedExtension(parsedUri.kind, filePath);
27309
- const text = readFileSync5(filePath, "utf-8");
27354
+ const text = readFileSync6(filePath, "utf-8");
27310
27355
  return {
27311
27356
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
27312
27357
  text
@@ -27343,7 +27388,7 @@ function setupResourceHandlers(mcp, getProjectPath) {
27343
27388
  }
27344
27389
 
27345
27390
  // src/runtime-client.ts
27346
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, unlinkSync } from "node:fs";
27391
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
27347
27392
  import { createConnection as createConnection3 } from "node:net";
27348
27393
  import { tmpdir as tmpdir3 } from "node:os";
27349
27394
  import { join as join7, resolve as resolve4 } from "node:path";
@@ -27429,7 +27474,7 @@ function processAlive(pid) {
27429
27474
  function parseAnnouncement(file, pid) {
27430
27475
  let fields;
27431
27476
  try {
27432
- fields = asParams(JSON.parse(readFileSync6(file, "utf8")));
27477
+ fields = asParams(JSON.parse(readFileSync7(file, "utf8")));
27433
27478
  } catch {
27434
27479
  return { kind: "rubbish" };
27435
27480
  }
@@ -27619,7 +27664,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27619
27664
  }
27620
27665
 
27621
27666
  // src/setup.ts
27622
- import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27667
+ import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27623
27668
  import { dirname as dirname3, join as join8 } from "node:path";
27624
27669
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27625
27670
  var VERSION_MARKER = ".gdharness-version";
@@ -27628,7 +27673,7 @@ function installedAddonVersion(projectPath) {
27628
27673
  if (!existsSync6(marker)) {
27629
27674
  return null;
27630
27675
  }
27631
- const said = readFileSync7(marker, "utf8").trim();
27676
+ const said = readFileSync8(marker, "utf8").trim();
27632
27677
  return said === "" ? null : said;
27633
27678
  }
27634
27679
 
@@ -28398,7 +28443,11 @@ var TOOL_SPECS = [
28398
28443
  blank: true,
28399
28444
  description: `set: the value, fitted to the property's type. "" writes an empty string.`
28400
28445
  },
28401
- method: { type: "string", ops: ["call"] },
28446
+ method: {
28447
+ type: "string",
28448
+ ops: ["call"],
28449
+ 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.'
28450
+ },
28402
28451
  args: {
28403
28452
  type: "array",
28404
28453
  ops: ["call"],
@@ -28411,7 +28460,10 @@ var TOOL_SPECS = [
28411
28460
  summary: "set a property, on a node or on an object it holds",
28412
28461
  requires: ["property", "value"]
28413
28462
  },
28414
- call: { summary: "call a method and return its result", requires: ["method"] }
28463
+ call: {
28464
+ summary: "call a method, on a node or on an object it holds, and return its result",
28465
+ requires: ["method"]
28466
+ }
28415
28467
  }
28416
28468
  },
28417
28469
  {
@@ -28668,6 +28720,17 @@ var SLOWEST_FRAME_RATE = 20;
28668
28720
  function patienceForFrames(frames, atLeast) {
28669
28721
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
28670
28722
  }
28723
+ function alive(pid) {
28724
+ if (pid === undefined) {
28725
+ return false;
28726
+ }
28727
+ try {
28728
+ process.kill(pid, 0);
28729
+ return true;
28730
+ } catch {
28731
+ return false;
28732
+ }
28733
+ }
28671
28734
  function isOfType(value, kind) {
28672
28735
  switch (kind) {
28673
28736
  case "string":
@@ -28714,6 +28777,7 @@ function wrongTypes(spec, args) {
28714
28777
  return complaints;
28715
28778
  }
28716
28779
  var BRIDGE_RETRY_MS = 2000;
28780
+ var SUCCESSOR_CHECK_MS = 1e4;
28717
28781
  var PATH_SOLUTIONS = [
28718
28782
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
28719
28783
  "Point projectPath at the project the file belongs to"
@@ -28801,11 +28865,11 @@ function aboveTheRunner(entry) {
28801
28865
  };
28802
28866
  }
28803
28867
  function hasMainScene(projectFile) {
28804
- const scene = parseProjectGodot(readFileSync8(projectFile, "utf8"))["application"]?.["run/main_scene"];
28868
+ const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
28805
28869
  return typeof scene === "string" && scene !== "";
28806
28870
  }
28807
28871
  function editorPlaysHeadless(projectFile) {
28808
- const runArgs = parseProjectGodot(readFileSync8(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28872
+ const runArgs = parseProjectGodot(readFileSync9(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28809
28873
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28810
28874
  }
28811
28875
  function camelCased(params) {
@@ -28833,13 +28897,14 @@ class GodotServer {
28833
28897
  dapClient = null;
28834
28898
  bridgeStartupError = null;
28835
28899
  bridgeRetry = null;
28900
+ successorWatch = null;
28836
28901
  lastProjectPath = null;
28837
28902
  shutdownInitiated = false;
28838
28903
  ownProject;
28839
28904
  announcedAt = null;
28840
28905
  constructor() {
28841
28906
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
28842
- this.godotBridge = getDefaultBridge(this.ownProject !== null);
28907
+ this.godotBridge = getDefaultBridge(this.ownProject);
28843
28908
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
28844
28909
  this.setupToolHandlers();
28845
28910
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -28932,6 +28997,7 @@ class GodotServer {
28932
28997
  port: status.port,
28933
28998
  version: SERVER_VERSION
28934
28999
  });
29000
+ this.watchForASuccessor();
28935
29001
  }
28936
29002
  stopTryingTheBridge() {
28937
29003
  if (this.bridgeRetry !== null) {
@@ -28939,9 +29005,41 @@ class GodotServer {
28939
29005
  this.bridgeRetry = null;
28940
29006
  }
28941
29007
  }
29008
+ watchForASuccessor() {
29009
+ if (this.ownProject === null || this.successorWatch !== null) {
29010
+ return;
29011
+ }
29012
+ const watch = setInterval(() => {
29013
+ const successor = this.supersededBy();
29014
+ if (successor === null) {
29015
+ return;
29016
+ }
29017
+ console.error(`[SERVER] pid ${successor} now serves ${this.ownProject}; this server is finished and is stopping.`);
29018
+ this.handleShutdown(`superseded by ${successor}`, 0);
29019
+ }, SUCCESSOR_CHECK_MS);
29020
+ watch.unref();
29021
+ this.successorWatch = watch;
29022
+ }
29023
+ supersededBy() {
29024
+ if (this.ownProject === null) {
29025
+ return null;
29026
+ }
29027
+ const announced = readAnnouncement(announcementPath(this.ownProject));
29028
+ if (announced === null || announced.pid === process.pid) {
29029
+ return null;
29030
+ }
29031
+ return alive(announced.pid) ? announced.pid : null;
29032
+ }
29033
+ stopWatchingForASuccessor() {
29034
+ if (this.successorWatch !== null) {
29035
+ clearInterval(this.successorWatch);
29036
+ this.successorWatch = null;
29037
+ }
29038
+ }
28942
29039
  async cleanup() {
28943
29040
  this.logDebug("Cleaning up resources");
28944
29041
  this.stopTryingTheBridge();
29042
+ this.stopWatchingForASuccessor();
28945
29043
  withdrawBridge(this.announcedAt);
28946
29044
  this.announcedAt = null;
28947
29045
  if (this.activeProcess) {
@@ -29523,7 +29621,7 @@ class GodotServer {
29523
29621
  if (!engine.ok) {
29524
29622
  return engine.response;
29525
29623
  }
29526
- const application = parseProjectGodot(readFileSync8(project.value.file, "utf8"))["application"] ?? {};
29624
+ const application = parseProjectGodot(readFileSync9(project.value.file, "utf8"))["application"] ?? {};
29527
29625
  const name = application["config/name"];
29528
29626
  const mainScene = application["run/main_scene"];
29529
29627
  const info = {
@@ -29698,7 +29796,7 @@ class GodotServer {
29698
29796
  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
29797
  const newest = written.at(-1);
29700
29798
  if (newest !== undefined) {
29701
- report = parseJUnit(readFileSync8(join9(reportsDir, newest, "results.xml"), "utf8"));
29799
+ report = parseJUnit(readFileSync9(join9(reportsDir, newest, "results.xml"), "utf8"));
29702
29800
  }
29703
29801
  } catch (error) {
29704
29802
  reportProblem = errorMessage(error);
@@ -29897,7 +29995,8 @@ class GodotServer {
29897
29995
  "editor_status says whether the bridge is up and what has reached it"
29898
29996
  ]);
29899
29997
  }
29900
- const asked = await this.handleViaBridge("restart_editor", {});
29998
+ const mine = before.openedByAServer === true && before.projectPath !== undefined;
29999
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
29901
30000
  if (asked.isError === true) {
29902
30001
  return asked;
29903
30002
  }
@@ -29925,6 +30024,26 @@ class GodotServer {
29925
30024
  tookMs: Date.now() - began
29926
30025
  });
29927
30026
  }
30027
+ async startItAgain(projectPath, editorPid) {
30028
+ const engine = await this.engine();
30029
+ if (!engine.ok) {
30030
+ return engine.response;
30031
+ }
30032
+ const ports = {
30033
+ lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
30034
+ dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
30035
+ };
30036
+ const asked = await this.handleViaBridge("quit_editor", {});
30037
+ if (asked.isError === true) {
30038
+ return asked;
30039
+ }
30040
+ await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
30041
+ const opened = await this.openAnEditor(engine.value, projectPath, ports);
30042
+ if (opened.error !== null) {
30043
+ return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
30044
+ }
30045
+ return this.jsonTextResponse({ restarting: true, pid: opened.pid });
30046
+ }
29928
30047
  async waitForBridge(reached, deadline) {
29929
30048
  for (;; ) {
29930
30049
  if (reached()) {
@@ -29954,14 +30073,28 @@ class GodotServer {
29954
30073
  }
29955
30074
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
29956
30075
  const ports = await this.portsForAnEditor();
29957
- const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
30076
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports);
30077
+ if (opened.error !== null) {
30078
+ return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
30079
+ }
30080
+ return this.jsonTextResponse({
30081
+ launched: true,
30082
+ pid: opened.pid,
30083
+ projectPath: project.value.path,
30084
+ lspPort: ports.lsp,
30085
+ dapPort: ports.dap
30086
+ });
30087
+ }
30088
+ async openAnEditor(engine, projectPath, ports) {
30089
+ const editor = spawn(engine, editorArguments(projectPath, ports), {
29958
30090
  stdio: "ignore",
29959
30091
  detached: true,
29960
30092
  env: {
29961
30093
  ...process.env,
29962
30094
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
29963
30095
  GDHARNESS_LSP_PORT: String(ports.lsp),
29964
- GDHARNESS_DAP_PORT: String(ports.dap)
30096
+ GDHARNESS_DAP_PORT: String(ports.dap),
30097
+ [OPENED_BY_A_SERVER]: "1"
29965
30098
  }
29966
30099
  });
29967
30100
  const started = await new Promise((resolve) => {
@@ -29973,16 +30106,10 @@ class GodotServer {
29973
30106
  });
29974
30107
  });
29975
30108
  if (started !== null) {
29976
- return this.createErrorResponse(`Could not start the editor: ${started}`);
30109
+ return { pid: null, error: started };
29977
30110
  }
29978
30111
  editor.unref();
29979
- return this.jsonTextResponse({
29980
- launched: true,
29981
- pid: editor.pid ?? null,
29982
- projectPath: project.value.path,
29983
- lspPort: ports.lsp,
29984
- dapPort: ports.dap
29985
- });
30112
+ return { pid: editor.pid ?? null, error: null };
29986
30113
  }
29987
30114
  async portsForAnEditor() {
29988
30115
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -30288,7 +30415,7 @@ class GodotServer {
30288
30415
  return {
30289
30416
  content: [
30290
30417
  { type: "text", text: `Screenshot captured: ${dimensions}` },
30291
- { type: "image", data: readFileSync8(screenshotPath).toString("base64"), mimeType: "image/png" }
30418
+ { type: "image", data: readFileSync9(screenshotPath).toString("base64"), mimeType: "image/png" }
30292
30419
  ]
30293
30420
  };
30294
30421
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",