gdharness 1.0.12 → 1.0.14

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.
@@ -13,6 +13,8 @@ const Read = preload("reading.gd")
13
13
  const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
14
14
  ## Written beside the addon by the install, so it names the version this copy came from.
15
15
  const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
16
+ ## Written beside it, kept in step with `DIGEST_MARKER` in src/setup.ts.
17
+ const DIGEST_MARKER: String = "res://addons/gdharness_editor/.gdharness-digest"
16
18
  ## Written by the server that serves this project, saying where its bridge actually is. Kept in
17
19
  ## step with `announcementPath` in src/bridge-announce.ts.
18
20
  const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
@@ -70,6 +72,8 @@ var server_url: String = DEFAULT_URL
70
72
  ## What this copy was when it loaded, which is what the editor is running until it is restarted.
71
73
  ## See [method _loaded_version] for why it is held rather than read when it is wanted.
72
74
  var version_at_load: String = ""
75
+ ## The digest of the editor addon code this copy loaded, held for the same reason.
76
+ var digest_at_load: String = ""
73
77
 
74
78
  var _is_connected: bool = false
75
79
  var _reconnect_timer: Timer
@@ -98,6 +102,7 @@ var _connecting_for: float = 0.0
98
102
  func _ready() -> void:
99
103
  _project_path = ProjectSettings.globalize_path("res://")
100
104
  version_at_load = _loaded_version()
105
+ digest_at_load = _loaded_digest()
101
106
  _keep_breakpoints_through_sessions()
102
107
  OS.set_environment(EDITOR_PID_VARIABLE, str(OS.get_process_id()))
103
108
 
@@ -290,6 +295,7 @@ func _handle_connect() -> void:
290
295
  "type": "godot_ready",
291
296
  "project_path": _project_path,
292
297
  "addon_version": version_at_load,
298
+ "addon_digest": digest_at_load,
293
299
  "editor_pid": OS.get_process_id(),
294
300
  "lsp_port": _serves(LSP_ASKED, LSP_SETTING),
295
301
  "dap_port": _serves(DAP_ASKED, DAP_SETTING),
@@ -408,9 +414,19 @@ func _serving(setting: String) -> int:
408
414
  ## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
409
415
  ## what it exists to catch.
410
416
  func _loaded_version() -> String:
411
- if not FileAccess.file_exists(VERSION_MARKER):
417
+ return _read_marker(VERSION_MARKER)
418
+
419
+
420
+ ## The same for the digest of the editor addon code, which an upgrade that leaves the code alone
421
+ ## rewrites unchanged, so the server can tell an editor that needs a restart from one that does not.
422
+ func _loaded_digest() -> String:
423
+ return _read_marker(DIGEST_MARKER)
424
+
425
+
426
+ func _read_marker(path: String) -> String:
427
+ if not FileAccess.file_exists(path):
412
428
  return ""
413
- var file: FileAccess = FileAccess.open(VERSION_MARKER, FileAccess.READ)
429
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
414
430
  if file == null:
415
431
  return ""
416
432
  var text: String = file.get_as_text().strip_edges()
package/build/index.js CHANGED
@@ -10047,11 +10047,20 @@ var SERVER_VERSION = (() => {
10047
10047
  }
10048
10048
  })();
10049
10049
  var UNVERSIONED = "an addon from before versions were reported";
10050
- function addonMismatch(addonVersion, serverVersion) {
10051
- if (addonVersion === serverVersion) {
10050
+ function editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest) {
10051
+ if (addonDigest !== undefined && addonDigest !== "" && shippedDigest !== undefined) {
10052
+ return addonDigest !== shippedDigest;
10053
+ }
10054
+ return addonVersion !== serverVersion;
10055
+ }
10056
+ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
10057
+ if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
10052
10058
  return;
10053
10059
  }
10054
10060
  const reported = addonVersion ?? "";
10061
+ if (reported === serverVersion) {
10062
+ return `The editor is running a different build of the ${reported} addon from the one this server ships. Restart it with editor_launch restart to pick this one up.`;
10063
+ }
10055
10064
  const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
10056
10065
  const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
10057
10066
  if (reported !== "" && isNewer(reported, serverVersion)) {
@@ -10059,8 +10068,8 @@ function addonMismatch(addonVersion, serverVersion) {
10059
10068
  }
10060
10069
  return `${both} Restart it with editor_launch restart to pick the new one up.`;
10061
10070
  }
10062
- function markIfStale(answer, addonVersion, serverVersion) {
10063
- const staleNote = addonMismatch(addonVersion, serverVersion);
10071
+ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
10072
+ const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
10064
10073
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
10065
10074
  return answer;
10066
10075
  }
@@ -10178,16 +10187,16 @@ import {
10178
10187
  mkdirSync as mkdirSync10,
10179
10188
  mkdtempSync as mkdtempSync2,
10180
10189
  openSync as openSync3,
10181
- readdirSync as readdirSync6,
10190
+ readdirSync as readdirSync7,
10182
10191
  readFileSync as readFileSync16,
10183
10192
  readSync as readSync2,
10184
10193
  rmdirSync,
10185
- statSync as statSync6
10194
+ statSync as statSync7
10186
10195
  } from "node:fs";
10187
10196
  import { tmpdir as tmpdir4 } from "node:os";
10188
10197
  import { basename as basename3, dirname as dirname10, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
10189
10198
  import { setTimeout as delay3 } from "node:timers/promises";
10190
- import { fileURLToPath as fileURLToPath2 } from "node:url";
10199
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
10191
10200
  import { promisify as promisify5 } from "node:util";
10192
10201
 
10193
10202
  // node_modules/zod/v3/helpers/util.js
@@ -24761,7 +24770,7 @@ import { dirname as dirname2, join as join3 } from "node:path";
24761
24770
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync, unlinkSync } from "node:fs";
24762
24771
  import { createConnection } from "node:net";
24763
24772
  import { tmpdir as tmpdir2 } from "node:os";
24764
- import { dirname, join as join2, resolve } from "node:path";
24773
+ import { dirname, join as join2, resolve as resolve2 } from "node:path";
24765
24774
  import { setTimeout as delay } from "node:timers/promises";
24766
24775
 
24767
24776
  // src/launch.ts
@@ -24828,6 +24837,65 @@ function environmentFor(options, variables = process.env) {
24828
24837
  }
24829
24838
  var SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
24830
24839
 
24840
+ // src/paths.ts
24841
+ import { realpathSync } from "node:fs";
24842
+ import { isAbsolute, relative, resolve, sep } from "node:path";
24843
+ var RESOURCE_SCHEME = "res://";
24844
+ var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
24845
+ function realPathOr(path) {
24846
+ try {
24847
+ return realpathSync.native(path);
24848
+ } catch {
24849
+ return path;
24850
+ }
24851
+ }
24852
+ function isSameDirectory(onePath, otherPath) {
24853
+ return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
24854
+ }
24855
+ function isWithinRoot(rootPath, candidatePath) {
24856
+ const step = relative(resolve(rootPath), resolve(candidatePath));
24857
+ return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
24858
+ }
24859
+ function resolveWithinProject(projectPath, candidatePath) {
24860
+ const trimmed = candidatePath.trim();
24861
+ if (trimmed === "") {
24862
+ return { ok: false, reason: "Path is empty." };
24863
+ }
24864
+ if (trimmed.includes("\x00")) {
24865
+ return { ok: false, reason: "Path contains a null byte." };
24866
+ }
24867
+ const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
24868
+ const scheme = OTHER_SCHEME.exec(withoutScheme);
24869
+ if (scheme) {
24870
+ return {
24871
+ ok: false,
24872
+ reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
24873
+ };
24874
+ }
24875
+ if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
24876
+ return {
24877
+ ok: false,
24878
+ reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
24879
+ };
24880
+ }
24881
+ if (withoutScheme.includes(":")) {
24882
+ return {
24883
+ ok: false,
24884
+ reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
24885
+ };
24886
+ }
24887
+ const root = resolve(projectPath);
24888
+ const absolutePath = resolve(root, withoutScheme);
24889
+ if (!isWithinRoot(root, absolutePath)) {
24890
+ return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
24891
+ }
24892
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
24893
+ if (relativePath === "") {
24894
+ return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
24895
+ }
24896
+ return { ok: true, absolutePath, relativePath };
24897
+ }
24898
+
24831
24899
  // src/process-children.ts
24832
24900
  import { execFile, execFileSync, spawnSync } from "node:child_process";
24833
24901
  import { basename } from "node:path";
@@ -25101,7 +25169,7 @@ function runtimeDirectories(variables = process.env) {
25101
25169
  for (const base of fallbacks) {
25102
25170
  candidates.push(join2(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
25103
25171
  }
25104
- return [...new Set(candidates.map((path) => resolve(path)))];
25172
+ return [...new Set(candidates.map((path) => resolve2(path)))];
25105
25173
  }
25106
25174
  var ERROR_REPORT_PATTERN = /^runtime-(\d+)\.log$/;
25107
25175
  function errorReportOf(pid, directories = runtimeDirectories()) {
@@ -25196,8 +25264,7 @@ function announcedAndGone(projectPath) {
25196
25264
  return [];
25197
25265
  }
25198
25266
  const since = Date.now() - GONE_WINDOW_MS;
25199
- const wanted = resolve(projectPath);
25200
- return gone.filter((one) => one.noticedAt >= since && resolve(one.project) === wanted);
25267
+ return gone.filter((one) => one.noticedAt >= since && isSameDirectory(one.project, projectPath));
25201
25268
  }
25202
25269
  var STARTED_AFTER_ANNOUNCING_MS = 2000;
25203
25270
  var CONFIRMED_FOR_MS = 60000;
@@ -25310,11 +25377,10 @@ function sweepErrorReport(path, pid, now = Date.now()) {
25310
25377
  var LOOK_EVERY_MS = 50;
25311
25378
  var ANNOUNCE_BUDGET_MS = 5000;
25312
25379
  async function announcedSince(projectPath, before, waiting = {}) {
25313
- const wanted = resolve(projectPath);
25314
25380
  const directories = waiting.directories ?? runtimeDirectories();
25315
25381
  const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
25316
25382
  for (;; ) {
25317
- const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve(endpoint.project.path) === wanted && (waiting.accept?.(endpoint) ?? true));
25383
+ const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
25318
25384
  if (fresh !== undefined) {
25319
25385
  return fresh;
25320
25386
  }
@@ -25335,7 +25401,7 @@ function theOneProject(endpoints) {
25335
25401
  if (endpoints.some((endpoint) => endpoint.project.path === "")) {
25336
25402
  return;
25337
25403
  }
25338
- const paths = new Set(endpoints.map((endpoint) => resolve(endpoint.project.path)));
25404
+ const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve2(endpoint.project.path))));
25339
25405
  const only = [...paths][0];
25340
25406
  return paths.size === 1 ? only : undefined;
25341
25407
  }
@@ -25364,9 +25430,9 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
25364
25430
  if (pid !== undefined) {
25365
25431
  const named = endpoints.find((endpoint) => endpoint.pid === pid);
25366
25432
  if (named !== undefined) {
25367
- if (projectPath !== undefined && resolve(named.project.path) !== resolve(projectPath)) {
25433
+ if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
25368
25434
  return {
25369
- problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
25435
+ problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve2(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
25370
25436
  };
25371
25437
  }
25372
25438
  return { endpoint: named };
@@ -25406,8 +25472,8 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
25406
25472
  };
25407
25473
  }
25408
25474
  if (projectPath !== undefined) {
25409
- const wanted = resolve(projectPath);
25410
- const matching = endpoints.filter((endpoint) => resolve(endpoint.project.path) === wanted);
25475
+ const wanted = resolve2(projectPath);
25476
+ const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
25411
25477
  if (matching.length === 0) {
25412
25478
  return {
25413
25479
  problem: `No running game is from ${wanted}. Running: ${endpoints.map(describe2).join("; ")}.`
@@ -25551,67 +25617,6 @@ function waitSizedTo(lastBootMs, usual = ANNOUNCE_BUDGET_MS) {
25551
25617
  // src/breakpoint-note.ts
25552
25618
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
25553
25619
  import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2 } from "node:path";
25554
-
25555
- // src/paths.ts
25556
- import { realpathSync } from "node:fs";
25557
- import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
25558
- var RESOURCE_SCHEME = "res://";
25559
- var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
25560
- function realPathOr(path) {
25561
- try {
25562
- return realpathSync.native(path);
25563
- } catch {
25564
- return path;
25565
- }
25566
- }
25567
- function isSameDirectory(onePath, otherPath) {
25568
- return relative(realPathOr(resolve2(onePath)), realPathOr(resolve2(otherPath))) === "";
25569
- }
25570
- function isWithinRoot(rootPath, candidatePath) {
25571
- const step = relative(resolve2(rootPath), resolve2(candidatePath));
25572
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
25573
- }
25574
- function resolveWithinProject(projectPath, candidatePath) {
25575
- const trimmed = candidatePath.trim();
25576
- if (trimmed === "") {
25577
- return { ok: false, reason: "Path is empty." };
25578
- }
25579
- if (trimmed.includes("\x00")) {
25580
- return { ok: false, reason: "Path contains a null byte." };
25581
- }
25582
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
25583
- const scheme = OTHER_SCHEME.exec(withoutScheme);
25584
- if (scheme) {
25585
- return {
25586
- ok: false,
25587
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
25588
- };
25589
- }
25590
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
25591
- return {
25592
- ok: false,
25593
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
25594
- };
25595
- }
25596
- if (withoutScheme.includes(":")) {
25597
- return {
25598
- ok: false,
25599
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
25600
- };
25601
- }
25602
- const root = resolve2(projectPath);
25603
- const absolutePath = resolve2(root, withoutScheme);
25604
- if (!isWithinRoot(root, absolutePath)) {
25605
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
25606
- }
25607
- const relativePath = relative(root, absolutePath).split(sep).join("/");
25608
- if (relativePath === "") {
25609
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
25610
- }
25611
- return { ok: true, absolutePath, relativePath };
25612
- }
25613
-
25614
- // src/breakpoint-note.ts
25615
25620
  function breakpointNotePath(projectPath) {
25616
25621
  return join4(projectPath, ".godot", "gdharness-breakpoints.json");
25617
25622
  }
@@ -25849,6 +25854,19 @@ function cacheWrittenAt(projectPath) {
25849
25854
  return null;
25850
25855
  }
25851
25856
  }
25857
+ function declaredSince(projectPath, paths, names, since) {
25858
+ return names.filter((name) => {
25859
+ const path = paths.get(name);
25860
+ if (path === undefined || !path.startsWith("res://")) {
25861
+ return false;
25862
+ }
25863
+ try {
25864
+ return statSync2(join6(projectPath, path.slice("res://".length))).mtimeMs > since;
25865
+ } catch {
25866
+ return false;
25867
+ }
25868
+ });
25869
+ }
25852
25870
  function cachedAtMissingPaths(cached, projectPath) {
25853
25871
  return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync3(join6(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
25854
25872
  }
@@ -27179,6 +27197,7 @@ class GodotBridge extends EventEmitter {
27179
27197
  connectedAt: this.connectionInfo?.connectedAt,
27180
27198
  lastPongAt: this.connectionInfo?.lastPongAt,
27181
27199
  addonVersion: this.connectionInfo?.addonVersion,
27200
+ addonDigest: this.connectionInfo?.addonDigest,
27182
27201
  editorPid: this.connectionInfo?.editorPid,
27183
27202
  lspPort: this.connectionInfo?.lspPort,
27184
27203
  dapPort: this.connectionInfo?.dapPort,
@@ -27322,6 +27341,7 @@ class GodotBridge extends EventEmitter {
27322
27341
  }
27323
27342
  this.connectionInfo.projectPath = message.project_path;
27324
27343
  this.connectionInfo.addonVersion = message.addon_version ?? "";
27344
+ this.connectionInfo.addonDigest = message.addon_digest;
27325
27345
  this.connectionInfo.editorPid = message.editor_pid;
27326
27346
  this.connectionInfo.lspPort = servedPort(message.lsp_port);
27327
27347
  this.connectionInfo.dapPort = servedPort(message.dap_port);
@@ -29456,14 +29476,66 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29456
29476
  }
29457
29477
 
29458
29478
  // src/setup.ts
29459
- import { cpSync, existsSync as existsSync9, mkdirSync as mkdirSync9, readFileSync as readFileSync15, rmSync as rmSync7, writeFileSync as writeFileSync10 } from "node:fs";
29479
+ import { createHash as createHash3 } from "node:crypto";
29480
+ import {
29481
+ cpSync,
29482
+ existsSync as existsSync9,
29483
+ mkdirSync as mkdirSync9,
29484
+ readdirSync as readdirSync6,
29485
+ readFileSync as readFileSync15,
29486
+ rmSync as rmSync7,
29487
+ statSync as statSync6,
29488
+ writeFileSync as writeFileSync10
29489
+ } from "node:fs";
29460
29490
  import { dirname as dirname9, join as join15 } from "node:path";
29491
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
29461
29492
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
29493
+ var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
29462
29494
  var RUNTIME_AUTOLOAD = {
29463
29495
  name: "GdharnessRuntime",
29464
29496
  path: "addons/gdharness_runtime/runtime_autoload.gd"
29465
29497
  };
29466
29498
  var VERSION_MARKER = ".gdharness-version";
29499
+ var DIGEST_MARKER = ".gdharness-digest";
29500
+ function editorAddonDigest(from = shippedAddonsDirectory()) {
29501
+ const hash = createHash3("sha256");
29502
+ const walk = (directory, relative) => {
29503
+ for (const name of readdirSync6(directory).sort()) {
29504
+ if (name === VERSION_MARKER || name === DIGEST_MARKER) {
29505
+ continue;
29506
+ }
29507
+ const path = join15(directory, name);
29508
+ const inside = `${relative}/${name}`;
29509
+ if (statSync6(path).isDirectory()) {
29510
+ walk(path, inside);
29511
+ } else {
29512
+ hash.update(`${inside}
29513
+ `);
29514
+ hash.update(readFileSync15(path));
29515
+ hash.update(`
29516
+ `);
29517
+ }
29518
+ }
29519
+ };
29520
+ for (const name of EDITOR_PLUGINS) {
29521
+ walk(join15(from, name), name);
29522
+ }
29523
+ return hash.digest("hex");
29524
+ }
29525
+ var shippedDigest;
29526
+ function shippedEditorDigest() {
29527
+ if (shippedDigest === undefined) {
29528
+ try {
29529
+ shippedDigest = editorAddonDigest();
29530
+ } catch {
29531
+ shippedDigest = null;
29532
+ }
29533
+ }
29534
+ return shippedDigest ?? undefined;
29535
+ }
29536
+ function shippedAddonsDirectory() {
29537
+ return join15(dirname9(fileURLToPath2(import.meta.url)), "godot", "addons");
29538
+ }
29467
29539
  function installedAddonVersion(projectPath) {
29468
29540
  const marker = join15(projectPath, "addons", ADDONS[0], VERSION_MARKER);
29469
29541
  if (!existsSync9(marker)) {
@@ -30657,7 +30729,7 @@ function buildToolDefinitions() {
30657
30729
  var UPDATE_NOTICE_EVERY = 500;
30658
30730
  var FEEDBACK_NOTICE_EVERY = 250;
30659
30731
  var run5 = promisify5(execFile5);
30660
- var __dirname2 = dirname10(fileURLToPath2(import.meta.url));
30732
+ var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
30661
30733
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
30662
30734
  var HOLD_PING_MS = 2000;
30663
30735
  var SLOWEST_FRAME_RATE = 20;
@@ -30951,7 +31023,7 @@ function elsewhereIn(projectPath, asked) {
30951
31023
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
30952
31024
  let near = [];
30953
31025
  try {
30954
- near = readdirSync6(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
31026
+ near = readdirSync7(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
30955
31027
  } catch {
30956
31028
  return "";
30957
31029
  }
@@ -32111,7 +32183,7 @@ class GodotServer {
32111
32183
  let report = null;
32112
32184
  let reportProblem = null;
32113
32185
  try {
32114
- const written = existsSync10(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
32186
+ const written = existsSync10(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
32115
32187
  const newest = written.at(-1);
32116
32188
  if (newest !== undefined) {
32117
32189
  report = parseJUnit(readFileSync16(join16(reportsDir, newest, "results.xml"), "utf8"));
@@ -32240,7 +32312,7 @@ class GodotServer {
32240
32312
  warnings: diagnostics.length - errors,
32241
32313
  diagnostics,
32242
32314
  ...disagrees
32243
- }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION));
32315
+ }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
32244
32316
  }
32245
32317
  whereTheFileDisagrees(diagnostics, args) {
32246
32318
  const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
@@ -32427,7 +32499,7 @@ class GodotServer {
32427
32499
  getEditorStatusPayload() {
32428
32500
  const status = this.godotBridge.getStatus();
32429
32501
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
32430
- const stale = status.connected && status.addonVersion !== SERVER_VERSION;
32502
+ const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
32431
32503
  const unfinished = this.restartLeftUnfinished(status.connected);
32432
32504
  return {
32433
32505
  ...status,
@@ -32444,7 +32516,7 @@ class GodotServer {
32444
32516
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
32445
32517
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
32446
32518
  startupError: this.bridgeStartupError,
32447
- staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
32519
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32448
32520
  ...this.breakpointsAtRisk(),
32449
32521
  retryingBridge: this.bridgeRetry === null ? undefined : true,
32450
32522
  announcedAt: this.announcedAt ?? undefined,
@@ -32576,8 +32648,8 @@ class GodotServer {
32576
32648
  editorPid: now.editorPid,
32577
32649
  addonVersion: now.addonVersion,
32578
32650
  serverVersion: SERVER_VERSION,
32579
- addonIsStale: now.addonVersion !== SERVER_VERSION,
32580
- staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
32651
+ addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
32652
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
32581
32653
  ...this.whatTheEditorDropped(settingsBefore, after),
32582
32654
  tookMs: Date.now() - began
32583
32655
  });
@@ -32854,7 +32926,7 @@ class GodotServer {
32854
32926
  }
32855
32927
  let announcedAt;
32856
32928
  try {
32857
- announcedAt = statSync6(endpoint.file).mtimeMs;
32929
+ announcedAt = statSync7(endpoint.file).mtimeMs;
32858
32930
  } catch {
32859
32931
  return;
32860
32932
  }
@@ -33464,7 +33536,15 @@ class GodotServer {
33464
33536
  "editor_launch opens one, and an editor opened here has its console captured"
33465
33537
  ]);
33466
33538
  }
33467
- const console2 = editorConsole(projectPath, status.editorPid ?? null, status.openedByAServer === true);
33539
+ const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
33540
+ if (!status.connected && launched === null) {
33541
+ return this.createErrorResponse("No editor is connected, so there is no console to read.", [
33542
+ "editor_status says whether one is connected and whether it may yet be",
33543
+ "editor_launch opens one, and an editor opened here has its console captured"
33544
+ ]);
33545
+ }
33546
+ const editorPid = status.connected ? status.editorPid ?? null : launched;
33547
+ const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
33468
33548
  if ("kind" in console2) {
33469
33549
  return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
33470
33550
  "editor_launch restart replaces this editor with one whose console is captured",
@@ -33482,8 +33562,9 @@ class GodotServer {
33482
33562
  const grouped = bursts(everything, readPositiveNumber(args, "before") ?? 3);
33483
33563
  const shown = grouped.slice(0, MOST_GROUPS);
33484
33564
  return this.jsonTextResponse({
33485
- editorPid: status.editorPid,
33565
+ editorPid: editorPid ?? undefined,
33486
33566
  projectPath,
33567
+ ...status.connected ? {} : this.launchedEditorIsUp() ? { stillConnecting: true } : { exitedBeforeConnecting: true },
33487
33568
  capturedIn: console2.path,
33488
33569
  ...console2.of === undefined ? {} : {
33489
33570
  readFromTheStart: console2.readBytes,
@@ -33782,6 +33863,7 @@ class GodotServer {
33782
33863
  }
33783
33864
  async rebuildClassCache(projectPath) {
33784
33865
  const before = cachedClasses(projectPath);
33866
+ const writtenBefore = cacheWrittenAt(projectPath);
33785
33867
  const noted = readClassNote(projectPath);
33786
33868
  const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
33787
33869
  if (!rebuilt.ok) {
@@ -33792,21 +33874,30 @@ class GodotServer {
33792
33874
  return rebuilt;
33793
33875
  }
33794
33876
  const lost = noted === null || before === null ? [] : noted.rebuiltWith.filter((name) => !before.has(name) && after.has(name)).sort();
33795
- const seenLosing = lost.length > 0 && this.godotBridge.isConnected();
33877
+ const cameBack = writtenBefore === null ? [] : declaredSince(projectPath, after, lost, writtenBefore);
33878
+ const droppedByTheEditor = lost.filter((name) => !cameBack.includes(name));
33879
+ const seenLosing = droppedByTheEditor.length > 0 && this.godotBridge.isConnected();
33796
33880
  writeClassNote(projectPath, {
33797
33881
  rebuiltWith: [...after.keys()].sort(),
33798
33882
  ...seenLosing ? { shortListEditorPid: this.godotBridge.getStatus().editorPid ?? null } : noted?.shortListEditorPid === undefined ? {} : { shortListEditorPid: noted.shortListEditorPid }
33799
33883
  });
33800
- if (!seenLosing) {
33884
+ const notes = [];
33885
+ if (seenLosing) {
33886
+ const them = droppedByTheEditor.length === 1 ? "it" : "them";
33887
+ notes.push(`The cache held ${droppedByTheEditor.join(", ")} after the last rebuild and not when this one began, so the editor holding this project wrote it without ${them} in between: it holds a list shorter than the files and writes that list on every save and scan, and each rebuild puts ${them} back. editor_launch restart is what ends it.`);
33888
+ }
33889
+ if (cameBack.length > 0) {
33890
+ notes.push(`${cameBack.join(", ")} left the cache while ${cameBack.length === 1 ? "its script was" : "their scripts were"} gone: the script${cameBack.length === 1 ? " is" : "s are"} newer than the cache the editor last wrote, so it listed what was on disk then. editor_rescan picks ${cameBack.length === 1 ? "it" : "them"} up.`);
33891
+ }
33892
+ if (notes.length === 0) {
33801
33893
  return rebuilt;
33802
33894
  }
33803
- const them = lost.length === 1 ? "it" : "them";
33804
33895
  return {
33805
33896
  ...rebuilt,
33806
33897
  payload: {
33807
33898
  ...rebuilt.payload,
33808
33899
  lostSinceLastRebuild: lost,
33809
- note: `The cache held ${lost.join(", ")} after the last rebuild and not when this one began, so the editor holding this project wrote it without ${them} in between: it holds a list shorter than the files and writes that list on every save and scan, and each rebuild puts ${them} back. editor_launch restart is what ends it.`
33900
+ note: notes.join(" ")
33810
33901
  }
33811
33902
  };
33812
33903
  }
@@ -33882,7 +33973,7 @@ class GodotServer {
33882
33973
  ]);
33883
33974
  }
33884
33975
  try {
33885
- return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION));
33976
+ return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
33886
33977
  } catch (error) {
33887
33978
  return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
33888
33979
  }
@@ -33935,8 +34026,59 @@ class GodotServer {
33935
34026
  }
33936
34027
  return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
33937
34028
  }
34029
+ async ownRunOnItsWay(projectPath, budgetMs) {
34030
+ const run = this.currentRun();
34031
+ const project = projectPath ?? run?.projectPath ?? null;
34032
+ if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync10(join16(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
34033
+ return null;
34034
+ }
34035
+ const already = runtimesAnnounced().running;
34036
+ const announcedSinceTheStart = (one) => {
34037
+ if (run.announcedBefore !== undefined) {
34038
+ return !run.announcedBefore.has(one.pid);
34039
+ }
34040
+ try {
34041
+ return statSync7(one.file).mtimeMs >= run.startedAt;
34042
+ } catch {
34043
+ return false;
34044
+ }
34045
+ };
34046
+ if (already.some((one) => announcedSinceTheStart(one) && this.isTheGameOf(run, one, project))) {
34047
+ return null;
34048
+ }
34049
+ const which = run.throughEditor ? "the game the editor is playing for it" : `pid ${run.pid ?? "unknown"}`;
34050
+ const comingUpUntil = run.startedAt + waitSizedTo(readBootNote(project)) + budgetMs;
34051
+ const remaining = comingUpUntil - Date.now();
34052
+ if (remaining <= 0) {
34053
+ const seconds = Math.round((Date.now() - run.startedAt) / 1000);
34054
+ return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
34055
+ }
34056
+ const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
34057
+ budgetMs: Math.min(budgetMs, remaining),
34058
+ accept: (one) => this.isTheGameOf(this.currentRun(), one, project),
34059
+ giveUp: () => this.currentRun() !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
34060
+ });
34061
+ if (endpoint !== null) {
34062
+ const tiedMeanwhile = () => run.announcedPid !== undefined;
34063
+ if (!tiedMeanwhile()) {
34064
+ this.tie(run, endpoint);
34065
+ }
34066
+ return null;
34067
+ }
34068
+ if (this.currentRun() !== run || !await this.runStillGoing(run)) {
34069
+ return null;
34070
+ }
34071
+ return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
34072
+ }
33938
34073
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
33939
34074
  const { op: _op, projectPath, pid, ...params } = asParams(args);
34075
+ const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
34076
+ if (waited !== null) {
34077
+ return this.createErrorResponse(waited, [
34078
+ "editor_status lists it under runtimes once it has announced",
34079
+ "editor_output says what it has printed so far"
34080
+ ]);
34081
+ }
33940
34082
  const announced = runtimesAnnounced();
33941
34083
  const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
33942
34084
  const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted) ?? await this.ownGameThroughTheTree(announced.running, wanted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
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",